前言
上一篇我们实现了一个基本的响应式系统,但是其实还有诸多不足之处,这一篇我们来继续完善。
track和trigger
首先为了更好地维护代码,我们把上一篇对象的getter
和setter
里面地逻辑分别封装为track
和trigger
函数。点此查看代码。
遗留的副作用函数
我们提供以下测试例子:
effect(()=> {
console.log('effect')
document.body.innerHTML = data.isVue ? data.name : 'not' // data.isVue初始值为true
})
setTimeout(() => {
data.name = 'name is changed at the first time'
}, 500);
setTimeout(() => {
data.isVue = false
}, 1000);
setTimeout(() => {
data.name = 'name is changed at the second time'
}, 2000);
据上:
- 当
data.isVue
为true
时,我们很容易得到属性isVue
和name
都有一个副作用函数(也叫依赖,古下面的依赖也是这个意思),我们设为fn
500ms
后data.name
发生改变会执行fn
,页面的结果为data.name
的值1000ms
后data.isVue
变为false
,这个时候结果总是为not
,已经与data.name
无关了- 然而,当我们
2000ms
修改data.name
时,虽然页面的结果仍为not
,但实际上也执行了依赖fn
,实际上我们希望这个时候fn
是不被执行的。
这就是遗留的副作用函数问题。
解决这个问题其实很简单,我们可以把该依赖从所有引用到它的依赖集合中删除,然后执行依赖时让它重新被重新收集。梳理一下思路:
- 最开始每个对象的每个属性的依赖集合为空
- 依赖执行时,会被它里面的响应式对象收集(我们将这些对象假设叫集合
A
),A
集合里面的对象属性一旦发生变化,就会重新执行依赖,很好。问题是这个A
集合里面不是一成不变的,就像上面的测试例子,2000ms
后集合A
就不包括data.name
了,所以我们这么处理:在执行依赖之前,先把集合A
对应的所有依赖集合里收集到的该依赖删除掉,执行时重新收集它。所以我们设计代码如下:
const effect = (fn) => {
const effectFn = () => {
// 将当前的副作用函数赋给activeEffect
activeEffect = effectFn;
// 清除上一次所有对象属性对该依赖的收集
cleanUp();
// 执行副作用函数
fn();
};
effectFn();
};
const cleanUp = () => {
for (let i = 0; i < activeEffect?.deps?.length; i++) {
activeEffect.deps[i].delete(activeEffect);
}
// 保存哪些依赖集合包含该依赖,即文章中A集合对应的所有依赖集合
activeEffect.deps = [];
};
const trigger = (target, key) => {
const deps = bucket.get(target)?.get?.(key);
const depsToRun = new Set(deps) // 这一句是避免无限循环
depsToRun.forEach((fn) => fn());
};
const track = (target, key) => {
if (!activeEffect) return;
// 获取该对象的所有依赖映射
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 获取对应key的依赖集合
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activeEffect);
// 收集包含该依赖的依赖集合
activeEffect.deps.push(deps); // 新增
};
这样就可以解决遗留的副作用函数问题了,查看代码
effect嵌套和effect栈
为什么要实现这个功能呢?我们知道Vue.js
的组件渲染函数正是在一个effect
里面执行的,且组件可以嵌套组件。
比如现在我们定义2个组件:
const bar = {
render() {
return ....
}
}
const bar = {
render() {
return ...
}
}
则可以这么使用
<Bar>
<Foo/>
</Bar>
实际上执行的是:
effect(() => {
bar.render()
// 嵌套
effect(() => {
foo.render()
})
})
这就要求我们把effect
设计成可嵌套的,但是前面我们的代码并没有实现这个功能,测试如下
effect(function effectFn1() {
console.log('effectFn1执行', )
effect(function effectFn12() {
console.log('effectFn2执行')
document.body.innerHTML = data.name
})
document.body.innerHTML = data.version
})
setTimeout(() => {
data.version = 'version is changed'
}, 1000)
我们满心期待:上面的data.name
与effectFn2
绑定,data.version
与effectFn1
绑定。然而理想很美满,现实很骨感,1000ms
后,data.version
发生改变,执行的却是effectFn2
,分析如下:
当我们执行当前依赖时,会把该依赖赋给activeEffect
,这样当effectFn1
还没执行完,已经开始执effectFn2
,则activeEffect
的值变为effectFn2
,因此当执行到document.body.innerHTML = data.version
时,data.version
收集的依赖就变成effectFn2
了。
要解决这个问题,可以把当前执行的effect
压入栈,让activeEffect
总是指向栈顶,于是我们设计代码如下
let effectStack = [] // 新增
const effect = (fn) => {
const effectFn = () => {
// 将当前的副作用函数赋给activeEffect
activeEffect = effectFn;
effectStack.push(effectFn) // 新增
// 清除上一次所有对象属性对该依赖的收集
cleanUp();
// 执行副作用函数
fn();
effectStack.pop() // 当前依赖执行完后则弹出
activeEffect = effectStack[effectStack.length - 1] // 总是让activeEffect指向栈顶
};
effectFn();
};
但是以上代码存在以下问题:(后面再回来解决)
- 就算嵌套的
effect
(或子组件)没有发生改变也会重新执行(或更新) - 外层
effect
每次执行,内层effect
实际总是一个新的变量(effect
函数里面会执行const effectFn = () => {...}
,effectFn
才是真正被收集的依赖,但它此时总是一个新的变量),会被重复添加进依赖集合。
上述完整代码查看
无限递归循环
我们再提供以下测试例子:
effect(() => {
data.version++
document.body.innerHTML = data.version
})
打开控制台,发现
这是因为我们在一个
effect
里面同时进行了读取和设置操作,
data.version++
等价于
data.version = data.version + 1
于是我们分析深层原因如下:
- 当读取
data.version
时,触发track
函数,收集当前依赖 - 同时设置
data.version
,触发trigger
函数,执行当前依赖 - 问题就出在当前的依赖还没执行完就得再次执行当前依赖,于是会无限调用自己,最后发生栈溢出。
为了解决这个问题,我们在trigger
函数里面增加一个守卫条件:trigger
触发执行的副作用函数和当前执行的函数如果是同一个,就不触发执行:
const trigger = (target, key) => {
const deps = bucket.get(target)?.get?.(key);
const depsToRun = new Set() // 这一句是避免无限循环
deps && deps.forEach(fn => {
if (fn !== activeEffect) { // `trigger`触发执行的副作用函数和当前执行的函数如果是同一个,就不触发执行
depsToRun.add(fn)
}
})
depsToRun.forEach((fn) => fn());
};
上面代码还是会有一个无法避免的问题,我们稍微交换一下副作用函数里面语句的顺序
effect(() => {
document.body.innerHTML = data.version
data.version++
})
那么页面上仍是data.version
前一次的值
下一篇我们继续响应式系统的调度执行,computed/watch
实现原理等。