Vue3组合式API的数据响应式及其基本原理

在 Vue2 中的选项式 API 中会用到data函数来声明变量的响应式状态,data函数的返回值是一个对象,Vue2 在创建实例时调用该函数,并将函数返回的对象通过响应式系统进行包装来实现响应式。

在 Vue3 的组合式 API 中已经没有data函数了,那又是如何实现数据响应式的呢?

响应式基础

setup函数中,如果直接定义变量,然后使用watch监听变量,会发现watch是不会起作用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<button @click="add">add</button>
</template>

<script setup>
import { watch } from 'vue';

var a = 0;
var add = () => {
a++
}

watch(a, () => {
console.log("a changed");
})
</script>

并且控制台还会报出错误:

1
[Vue warn]: Invalid watch source:  0 A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.

因为在setup函数中直接定义变量的话,变量不是响应式的,要定义响应式的变量,要使用ref/reactive

响应式变量

ref方法用于创建一个响应式的值类型变量(但是其实里面是对象或数组也是没问题的)。下面用ref来实现上面的自增响应式代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<button @click="add">add</button>
</template>

<script setup>
import { watch, ref } from 'vue';

var a = ref(0);
var add = () => {
a.value++;
}

watch(a, () => {
console.log("a changed");
})

</script>

通过ref创建的变量,是一个带有value属性的RefImpl​ 对象。它的value属性是响应式的。如果要修改ref的值,要修改的是它的value属性,而不是直接修改ref,如上面代码中的a.value++;​。

ref也可以创建响应式的对象或数组。当值是对象类型时,会用reactive自动转化它的value属性,将 Proxy 代理赋值给它的value,因此在监听ref创建的对象类型时,要监听它的value

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<button @click="add">add</button>
</template>

<script setup>
import { watch, ref } from 'vue';

var a = ref([0]);
var add = () => {
a.value.push(Math.random() * 10 | 0)
}

// 要注意监听ref创建的对象,要监听它的value
watch(a.value, (newValue) => {
console.log("a changed", newValue);
})

</script>

ref创建的变量在模板中使用就不用使用.value

响应式对象或数组

可以使用reactive创建响应式的对象或数组:

1
2
3
4
5
6
7
8
9
10
import { watch, reactive } from "vue";

var a = reactive([0]);
var add = () => {
a.push((Math.random() * 10) | 0);
};

watch(a, (newValue) => {
console.log("a changed", newValue);
});

响应式的对象就是 JavaScript 的 Proxy。它的表现和普通的对象行为相同,它是深层响应式的,意味着响应式对象下的所有属性都具有响应式:

1
2
3
4
5
6
7
8
9
10
import { watch, reactive } from "vue";

var obj = reactive({ a: [0] });
var add = () => {
obj.a.push((Math.random() * 10) | 0);
};

watch(obj.a, (newValue) => {
console.log("obj.a changed", newValue);
});

上面代码中的watch是可以正常监听到的。在修改它的值时也不需要加上.value

但是如果我们将响应式对象的属性解构或赋值给新的变量时,新的变量就不会具有响应式了,因为对象响应式是通过属性访问进行追踪的:

1
2
3
4
5
6
7
8
9
10
11
import { watch, reactive } from "vue";

var obj = reactive({ a: 1 });
var { a } = obj;
var add = () => {
a++;
};
// 无法监听到a的改变
watch(a, (newValue) => {
console.log("a changed", newValue);
});

响应式原理

原生 JavaScript 没有任何机制可以直接追踪变量的改变,但是我们可以追踪对象属性的读写。JavaScript 中有两种追踪对象属性读写的方法:getter/setter和 Proxy。在 Vue2 中出于对浏览器的兼容使用的是getter/setter,Vue3 中则使用了 Proxy 来创建响应式对象。仅将getter/setter用于 ref。

副作用函数和响应式数据

副作用函数是指会产生副作用的函数:

1
2
3
function effect() {
document.body.innerText = obj.a;
}

effect函数执行时,它会修改body的文本内容,但除了effect函数之外的任何函数都可以读取或设置body的内容,也就是说,effect函数的执行会直接或间接影响其他函数的执行。

1
2
3
4
5
var obj = { a: 1 };

function effect() {
document.body.innerText = obj.a;
}

假设在一个函数中读取了某个对象的属性,当obj.a的值改变时,我们希望副作用函数effect会重新执行。如果能实现这个目标,那么对象就是响应式数据。

基本实现

如何让obj变成响应式数据呢?我们知道:

  • 当副作用函数effect执行时,会触发obj.a的读取操作。
  • 当修改obj.a的值时,会触发obj.a的设置操作。

如果能拦截对象的读取/设置操作,事情就简单了。当读取obj.a时,我们把副作用函数effect存储到一个桶里。在设置obj.a时,再把effect从桶里面取出来并执行即可。

下面用 Proxy 来实现上面的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var data = { a: 1 };
var bucket = new Set();
var obj = new Proxy(data, {
get(target, key) {
bucket.add(effect);
return target[key];
},
set(target, key, value) {
target[key] = value;
bucket.forEach((fn) => fn());
return true;
},
});

function effect() {
document.body.innerText = obj.a;
}

setInterval(() => {
obj.a++;
}, 1000);

运行页面后,可以看到页面上的数字会自动增加了。

但是还是存在缺陷,比如上面的代码直接通过硬编码的effect函数名来获取副作用函数,副作用函数的名字应该可以任意取,因此我们要去掉这种硬编码的机制。

设计一个完善的响应式系统

一个响应式系统的工作流程只有两个:

  • 当读取操作发生时,将副作用函数收集到桶中。
  • 当设置操作发生时,从桶中取出副作用函数并执行。

在上一节中的代码中存在副作用函数硬编码的问题,而我们希望副作用函数可以随便命名甚至是一个匿名函数,为了实现这一点,我们需要一个用于注册副作用函数的机制:

1
2
3
4
5
6
7
8
9
10
11
// 一个全局变量用于存储被注册的副作用函数
let activeEffect;
// whenDepsChange函数用于注册副作用函数
function whenDepsChange(update) {
const effect = () => {
activeEffect = effect;
update();
activeEffect = null;
};
effect();
}

首先定义了一个全局变量activeEffect,初始值是undefined,它的作用是存储未被注册的副作用函数,接着定义了whenDepsChange函数,它用于注册副作用函数,我们可以像下面的方式使用whenDepsChange

1
2
3
whenDepsChange(() => {
document.body.innerText = obj.a;
});

然后修改 Proxy 对象内的get拦截函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
var obj = new Proxy(data, {
get(target, key) {
if (activeEffect) {
bucket.add(activeEffect);
}
return target[key];
},
set(target, key, value) {
target[key] = value;
bucket.forEach((fn) => fn());
return true;
},
});

由于副作用函数已经存储到了activeEffect中, 所以在get拦截内应该把activeEffect收集到桶中了,因此响应式系统就不以来函数名字了。

但是如果我们使用下面的代码进行测试:

1
2
3
4
5
6
7
8
whenDepsChange(() => {
document.body.innerText = obj.a;
console.log("obj.a is read.");
});

setInterval(() => {
obj.b++;
}, 1000);

可以看到副作用函数读取的是obj.a,在定时器中修改的obj.b,但是运行页面时,控制台依然打印了obj.a is read.。导致整个问题的原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系。因此我们需要重新设计bucket这个桶的数据结构。

我们把bucket重新设计成WeakMap<target, Map<key, Set<effect>>>​ 这样的一个数据结构,它是一个WeakMap,它可以看成一个树形的结构,它的顶层的键是响应式的对象,值是这个响应式对象的属性和它们的副作用组成的一个Map。第二层的Map的键是响应式对象的属性,它的值是对应的副作用。

接下来我们用代码来实现新的桶,首先需要用WeakMap代替Set作为桶:

1
let bucket = new WeakMap();

然后修改 get/set 拦截代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var obj = new Proxy(data, {
get(target, key) {
// 没有副作用,直接return
if (!activeEffect) {
return target[key];
}
// 获取对应的depsMap
let depsMap = bucket.get(target);
// 如果没有depsMap,那就创建一个
if (!depsMap) {
// depsMap的值是一个Map
bucket.set(target, (depsMap = new Map()));
}
// 获取key对应的deps
let deps = depsMap.get(key);
// 如果没有deps,就创建一个
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 在deps中添加副作用
deps.add(activeEffect);
return target[key];
},
set(target, key, value) {
target[key] = value;
// 获取depsMap
let depsMap = bucket.get(target);
// 如果没有depsMap就直接return
if (!depsMap) return;
// 遍历执行deps
let effect = depsMap.get(key);
effect && effect.forEach((fn) => fn());
return true;
},
});

从代码中我们可以看到桶的数据结构:

  • WeakMaptarget –> Map构成
  • Mapkey –> Set构成

我们可以对上面的代码进行一些改进,我们可以把get函数中把副作用函数收集到桶里的这部分逻辑封装成track函数,同时也可以把触发副作用部分的逻辑封装到trigger函数,在tracktrigger函数中,我们可以把获取副作用集合的函数封装到getSubscribersForProperty​ 函数,最后,我们再封装一个reactive函数,就类似于 Vue3 中的,接受一个对象,返回一个 Proxy:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
let activeEffect;
let bucket = new WeakMap();

function getSubscribersForProperty(target, key) {
var depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
return deps;
}

function track(target, key) {
if (activeEffect) {
const effects = getSubscribersForProperty(target, key);
effects.add(activeEffect);
}
}

function trigger(target, key) {
const effects = getSubscribersForProperty(target, key);
effects.forEach((effect) => effect());
}

function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key);
return target[key];
},
set(target, key, value) {
target[key] = value;
trigger(target, key);
},
});
}

function whenDepsChange(update) {
const effect = () => {
activeEffect = effect;
update();
activeEffect = null;
};
effect();
}

测试一下效果:

1
2
3
4
5
6
7
var obj = reactive({ a: 1 });
setInterval(() => {
obj.a++;
}, 1000);
whenDepsChange(() => {
document.body.innerText = obj.a;
});

现在就很类似于 Vue 定义的响应式对象了。上面的代码分别把逻辑封装到tracktrigger函数内,这能为我们带来极大的灵活性。

用类似 reactive 的方法,可以实现ref

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function ref(value) {
var refObj = {
get value() {
track(refObj, "value");
return value;
},
set value(newValue) {
value = newValue;
trigger(refObj, "value");
return true;
},
};
return refObj;
}