4.1 响应式数据和副作用函数
4.1.1 副作用函数
js
function effect(){
document.getElementById("app").innerText = "Hello Vue3"
}
当执行effect方法,它会设置<div id="app"></div>
的文本内容,但除了effect之外的任何函数都可以读取或者设置<div id="app"></div>
的文本内容。effect的执行会直接或间接地影响其他函数的执行,这时我们就说effect函数产生了副作用。
4.1.2 响应式数据
js
const obj = {
name: "张三",
};
function effect() {
document.getElementById("app").innerText = obj.name;
}
上面🌰中effect函数会设置<div id="app"></div>
的文本内容,当我们修改了obj.name的值,希望effect函数能自动重新执行,如果能实现这个目标,那么对象obj就是响应式数据。
4.2 响应式数据的基本实现
如何才能让obj变成响应式数据呢?这里有两点线索:
- 当副作用函数effect执行时,会触发字段obj.name的Getter操作;
- 当修改obj.name时,会触发字段obj.name的Setter操作; 如果我们能拦截一个对象的读取和设置操作,当读取字段obj.name时,我们可以把effect函数存储到一个"桶"里。

当设置obj.name时,再把副作用函数effect从"桶"里取出并执行。

我们如何去拦截一个对象的读取和设置操作呢?在ES2015之前,只能通过Object.defineProperty函数实现,这是Vue2的实现方式。在ES2015+中,我们可以使用代理对象Proxy
来实现,这也是Vue3的实现方式。接着我们就用Proxy
实现:
// 代码自己想

我们已经初步实现了响应式数据(◍˃̶ᗜ˂̶◍)✩,尽管还有一些问题等待处理。
4.3 设计一个完整的响应系统
上面的🌰中我们硬编码了副作用函数的名字(effect),这显然是不合理的,我们希望的是副作用函数哪怕是一个匿名函数,也能够被成功地收集到"桶"里。
为了实现这一点,可以通过传参的方式,将匿名函数传递给effect方法。
// 代码自己想
当我们对这个系统稍加测试,例如在响应式数据proxy上设置一个不存在的属性时:
js
effect(() => {
console.log("effect run");
document.getElementById("app").innerText = proxy.name;
});
setTimeout(() => {
proxy.age = 30;
}, 2000);

上图中匿名副作用函数执行了两次。定时器中proxy中不存在age属性,但还是触发了Proxy的Setter行为,从而执行了副作用函数,这是不正确的。为了解决这个问题,我们需要重新设计"桶"的结构。
上一节中,我们使用Set实现"桶",它不能把副作用函数和proxy的属性关联起来。无论我们修改proxy哪个属性,都会触发Getter,从而执行匿名副作用函数。因此我们需要建立起副作用函数和被操作的属性之间的联系。首先我们看一下下面的代码:
js
effect(() => {
console.log("effect run");
document.getElementById("app").innerText = proxy.name;
});
这段代码中有三个角色:
- 被读取的代理对象proxy,用target表示。
- 被读取的字段名name,用key表示。
- 使用effect函数注册的匿名副作用函数,用effectFn表示。
如果两个副作用函数读取同一个属性值:
js
effect(() => {
target.key;
});
effect(() => {
target.key;
});

如果一个副作用函数读取了同一个对象的两个属性值:
js
effect(() => {
target.key1;
target.key2;
});
两个不同的副作用函数中读取了两个不同对象的属性值:
js
effect(() => {
target1.key1;
});
effect(() => {
target2.key2;
});

下图是bucket新的结构图。

首先它从一个Set改为了WeakMap。按照上图修改一下之前的代码。
// 代码自己想
接着我们将一些逻辑进行封装,在get方法中,把副作用函数放入"桶"的逻辑封装成track方法;在set方法中,把副作用函数查找并执行的逻辑封装成trigger方法。
// 代码自己想
4.4 分支切换与cleanup
js
const obj = {
ok: true,
text: "Hello Vue3",
};
effect(function effectFn{
document.getElementById("app").innerText = obj.ok ? obj.text : null;
});
effectFn函数中有一个三元表达式,根据obj.ok的值来执行不同的代码分支,当obj.ok的值发生变化时,这就是分支切换 。
分支切换可能会产生遗留的副作用函数。当obj.ok的初始值是true时,会读取obj.text的值,所以当effectFn函数执行时会触发obj.ok和obj.text的读取,此时他们的关系如图所示:
当obj.ok是false时,此时字段obj.text不会被读取,只会触发字段obj.ok的读取操作,所以理想情况下effectFn不会被字段obj.text所对应的依赖集合收集:

现在我们的代码,当obj.ok改成false,obj.text和对应的依赖集合的关系依旧会保存下来,这就产生了遗留的副作用函数。遗留的副作用函数会导致不必要的更新:当obj.ok为false时,修改了obj.text,仍然会导致副作用函数重新执行,即使document.getElementById("app").innerText
的值不需要变化。
因此我们需要在每次副作用函数执行时,先把它从所有与之关联的依赖集合中删除。
步骤:
- 在effect内部我们定义了新的effectFn函数,并为其添加了effectFn.deps属性,该属性是一个数组,用来存储所有包含当前副作用函数的依赖集合;
- effectFn.deps数组中的依赖合集是如何收集的呢?在track函数中,将deps添加到activeEffect.deps数组中。
- 在effectFn函数中,在每次副作用函数执行时,删除相关的依赖,实现一个cleanup方法,依次删除依赖合集中的effectFn。

// 代码自己想
上面的代码成功解决了副作用函数遗留的问题,但是还带来了一个新的问题,那就是它会无限循环 。问题出在trigger方法中deps.forEach
,它的执行过程类似于下面这段代码。
js
const set = new Set([1])
set.forEach(item => {
set.delete(1)
set.add(1)
console.log('遍历中')
}
在调用forEach遍历Set集合时,如果一个值已经被方法问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,该值会重新被访问。
如何解决呢?很简单,我们可以新建一个Set。
// 代码自己想
4.5 嵌套的effect与effect栈
effect是可以发生嵌套的,例如:
js
effect(function effectFn1() {
effect(function effectFn2() {
/** **/
});
});
在Vue项目中,组件嵌套时就会发生effect嵌套。
js
const Bar = () => {
render(){ /*...*/}
}
// Foo组件渲染了Bar组件
const Foo = () => {
render(){
return <Bar />
}
}
我们来看一下现在的响应系统是否支持effect的嵌套。我们希望的是当修改obj.foo时会触发effectFn1。由于effectFn2嵌套在effectFn1中,所以会间接触发effectFn2。当我们修改obj.bar时,只会触发effectFn2执行。
js
// 原始数据
const data = { foo: true, bar: true}
// 代理对象
const obj = new Proxy(data, {/*...*/})
let temp1, temp2;
effect(function effectFn1() {
console.log("effectFn1执行");
effect(function effectFn2() {
console.log("effectFn2执行");
// 在effectFn2中读取obj.bar
temp2 = obj.bar;
});
// 在effectFn1中读取obj.foo
temp1 = obj.foo;
});
在理想情况下,我们希望副作用函数与对象属性之间的联系如下:

当我们修改obj.foo时,打印如下图所示。打印出了effectFn2,但是没打印出effectFn1,这显然不符合预期了。

js
function effect(fn) {
const effectFn = () => {
// 调用cleanup函数完成清除工作
cleanup(effectFn);
activeEffect = effectFn;
fn();
};
// effectFn.deps用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
effectFn();
}

问题出在哪呢?问题出在track这里,会执行两次track,最后一次activeEffect会把前面的都覆盖掉。
为了解决这个问题,我们需要一个副作用函数栈effectStack
,在副作用函数执行时,将当前的副作用函数压入栈,待副作用函数执行完成将其从栈中弹出,并始终让activeEffect指向栈顶的元素。
// 代码自己想
我们定义了effectStack数组,用来模拟栈。当副作用发生嵌套时,栈底存储的是外层副作用函数,而栈顶存储的是内层副作用函数。

当内层副作用函数effectFn2执行完成后,会被弹出栈。并将副作用函数effectFn1设置为activeEffect。

4.6 避免无限递归循环
js
effect(() => {
proxy.foo++;
});
有一个🌰,在effect注册的副作用函数内有一个自增操作,该操作会引起栈溢出。
我们可以看一下它的执行过程:首先读取obj.foo的值,这会触发track操作,把当前副作用函数收集到"桶"中,接着增加1后赋值给obj.foo,此时会触发trigger操作,即把"桶"中的副作用函数取出并执行。但问题是该副作用函数还在执行中,还没有执行完成,就要开始下一次执行。这会导致无限递归地调用自己,于是就产生了溢栈。
那我们怎么解决呢?我们可以在trigger方法中增加一个条件:如果trigger触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。
// 代码自己想
4.7 调度执行
可调度性 ,指的是当trigger动作触发副作用函数执行时,有能力决定副作用函数执行的时机、次数以及方式
。
js
effect(() => {
console.log(proxy.foo);
});
proxy.foo++;
console.log("结束了");

假如我们想打印出下面这样的顺序,在不调整代码顺序的情况下:
- 1
- 结束了
- 2
这样就需要我们的响应系统支持调度了。我们可以为effect函数设计一个参数options,允许用户指定调度器。

js
effect(
() => {
console.log(proxy.foo);
},
{
scheduler(fn) {
setTimeout(fn);
},
}
);
proxy.foo++;
console.log("结束了");
js
function effect(fn, options = {}) {
// otpions挂载到effectFn上
effectFn.options = options;
}
在trigger方法触发副作用函数执行时,我们优先判断该副作用函数是否存在调度器,如果存在,则直接调用调度器函数,并把当前副作用函数作为参数传递过去,由用户自己控制如何执行;否则直接执行副作用函数。
// 代码自己想
除了控制副作用函数的执行顺序,通过调度器还需要控制副作用函数的执行次数。
js
effect(() => {
console.log(proxy.foo);
});
proxy.foo++;
proxy.foo++;
我们知道proxy.foo是一定会从1加到3的,2只是它的过渡状态。如果我们只想关心结果而不是过程,我们期望的打印结果是:
- 1
- 3
如何用调度器来实现呢?
js
// 定义一个任务队列
// 将一个任务添加到微任务队列
// 一个标志代表是否正在刷新队列
function flushJob(){
}
effect(() => {
console.log(obj.foo)
}, {
scheduler(fn){
jobQueue.add(fn)
flushJob()
}
})
obj.foo++
obj.foo++
// 代码自己补充
flushJob方法的作用就是为了延迟执行queueJob,等所有的Setter操作执行完成,统一执行一次副作用函数。大家肯定又有疑问了,为什么要微任务,而不是宏任务SetTimeout。
微任务比宏任务有更高的优先级。
每次循环,浏览器只会取一个宏任务执行,而微任务则是执行全部。如果queueJob使用宏任务,可能会有多个宏任务在queueJob前面,但每次只会取一个宏任务,所以queueJob执行的时机非常延后。

这个功能类似于Vue.js中连续多次修改响应式数据但只会触发一次更新,解决思路也是类似的。
4.8 计算属性computed与lazy
先来看看🌰懒执行的effect。
js
effect(() => {
// 这个函数会立即执行
console.log(obj.foo);
});
有些时候,我们不希望它立即执行,而是希望它在需要的时候再去执行。比如计算属性,我们可以在options中添加lazy属性来达到目的。
js
effect(
() => {
// 指定lazy选项
console.log(obj.foo);
},
{
lazy: true,
}
);
这时我们就可以修改effect函数的实现逻辑了,当options.lazy为true
时,则不立即执行副作用函数。
js
function effect(fn, options = {}){
const effectFn = () => {}
effectFn.deps = []
effectFn.options = options
if(!options.lazy){
effectFn()
}
return effectFn
}
上面的代码中返回了effectFn,所以当调用effect函数时,通过其返回值能够拿到对应的副作用函数,这样我们就能手动执行它了。
js
const effectFn = effect(() => {
// 这个函数会立即执行
console.log(obj.foo);
});
effectFn()
如果我们可以把传递给effect的函数看作是一个getter,那么这个getter函数可以返回任何值,这样手动执行副作用函数时,就能拿到它的返回值。
js
function effect(fn, options = {}) {
const effectFn = () => {
let res = fn();
return res;
};
}
我们已经实现懒执行的副作用函数了,并且能拿到副作用函数的执行结果,接下来就可以实现计算属性了。
js
function computed(fn) {
const effectFn = effect(fn, {
lazy: true,
});
const obj = {
// 当读取value时才会执行effectFn
get value() {
return effectFn();
},
};
return obj;
}
我们可以使用computed函数来创建一个计算属性。
js
const sumRes = computed(() => proxy.foo + proxy.bar);
console.log(sumRes.value);
当我们读取sumRes.vaule的值时,它才会重新计算并得到值。但还做不到对值进行缓存,假如我们多次访问sumRes.value的值,会导致effectFn进行多次计算,即使proxy.foo和proxy.bar的值本身没有变化。
js
console.log(sumRes.value); // 2
console.log(sumRes.value); // 2
console.log(sumRes.value); // 2
为了解决这个问题,就需要我们在实现computed函数时,添加值的缓存功能。
// 代码自己想
js
const sumRes = computed(() => proxy.foo + proxy.bar);
console.log(sumRes.value); // 2
console.log(sumRes.value); // 2
proxy.foo = 3;
console.log(sumRes.value); // 2
中间我们修改了proxy.foo的值,但sumRes.value打印出来还是2。因为我们的dirty被改为了false。所以我们得在proxy.foo和proxy.bar的值发生变化时,将dirty重置成true。这时我们就可以用到上一节实现的调度器了。
js
function computed(fn) {
let value;
let dirty = true;
const effectFn = effect(fn, {
lazy: true,
scheduler(fn) {
// 添加调度器,在调度器中将dirty置为true
dirty = true;
},
});
}
如果我们在另外一个effect中去读取计算属性的值时。当我们修改proxy.foo时,发现并不会触发副作用函数的渲染,此时这就有问题了。
js
const sumRes = computed(() => proxy.foo + proxy.bar);
effect(() => {
console.log(sumRes.value); // 还是打印2
});
proxy.bar = 3;
分析一下问题的原因,这个本质是effect嵌套的问题。一个计算属性内部有自己的effect,并且它是懒执行,执行getter时才会执行computed内部的effect。但如果把计算属性用于另外一个effect时,就会发生effect嵌套,外层的effect不会被内层的effect中的响应式数据收集。
所以我们得在计算属性的getter行为中可以手动track,然后当属性依赖的响应式数据发生变化时,手动trigger。
4.9 watch的实现原理
watch的本质是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。
js
watch(obj, () => {
console.log('数据变了')
})
实际上,watch的实现本质上就是利用了effect以及options.scheduler选项,如下代码所示:
js
effect(() => {
console.log(obj.foo)
}, {
scheduler(){
// 当obj.foo的值变化时,会执行scheduler调度函数
}
})