前言
在 Vue3响应式对象是如何实现的(1) 中,我们已经从功能上实现了一个响应式对象。如果仅仅满足于功能实现,我们就可以止步于此了。但在上篇中,我们仅考虑了最简单的情况,想要完成一个完整可用的响应式,需要我们继续对细节深入思考。在特定场景下,是否存在BUG?是否还能继续优化?
分支切换的优化
在上篇中,收集副作用函数是利用 get 自动收集。那么被 get 自动收集的副作用函数,是否有可能会产生多余的触发呢?或者说,我们其实进行了多余的收集呢?同样,还是从一个例子入手。
let activeEffect function effect(fn) { activeEffect = fn fn() } const objsMap = new WeakMap() const data = { text: 'hello vue', ok: true } // (1) const obj = new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newValue) { target[key] = newValue trigger(target, key) return true } }) function track(target, key) { if(!activeEffect) return let propsMap = objsMap.get(target) if(!propsMap) { objsMap.set(target, (propsMap = new Map())) } let fns = propsMap.get(key) if(!fns) { propsMap.set(key, (fns = new Set())) } fns.add(activeEffect) } function trigger(target, key) { const propsMap = objsMap.get(target) if(!propsMap) return const fns = propsMap.get(key) fns && fns.forEach(fn => fn()) } function fn() { document.body.innerText = obj.ok ? obj.text : 'ops...' // (2) console.log('Done!') } effect(fn)
这段代码中,我们做了(1)(2)两处更改。我们在(1)处给响应式对象新增加了一个 boolean 类型的属性 ok ,在(2)处我们利用 ok 的真值,来选择将谁赋值给 document.body.innerText 。现在,我们将 obj.ok 的值置为 false ,这就意味着, document.body.innerText 的值不再依赖于 obj.text ,而直接取字符串 'ops...' 。
此时,我们要能够注意到一件事,虽然 document.body.innerText 的值不再依赖于 obj.text 了,但由于 ok 的初值是 true ,也就意味着在 ok 的值没有改变时, document.body.innerText 的值依赖于 obj.text ,更进一步说,这个函数已经被 obj.text 当作自己的副作用函数收集了。这会导致什么呢?
我们更改了 obj.text 的值,这会触发副作用函数。但此时由于 ok 的值为 false ,界面上显示的内容没有发生任何改变。也就是说, 此时 修改 obj.text 触发的副作用函数的更新是不必要的。
这部分有些绕,让我们通过画图来尝试说明。当 ok 为 true 时,数据结构的状态如图所示:
从图中可以看到, obj.text 和 obj.ok 都收集了同一个副作用函数 fn 。这也解释了为什么即使我们将 obj.ok 的值为 false ,更改 obj.text 仍然会触发副作用函数 fn 。
我们希望的理想状况是,当 ok 为 false 时,副作用函数 fn 被从 obj.text 的副作用函数收集器中删除,数据结构的状态能改变为如下状态。
这就要求我们 能够在每次执行副作用函数前,将该副作用函数从相关的副作用函数收集器中删除 ,再重新建立联系。为了实现这一点,就要求我们记录哪些副作用函数收集器收集了该副作用函数。
let activeEffect function cleanup(effectFn) { // (3) for(let i = 0; i < effectFn.deps.length; i++) { const fns = effectFn.deps[i] fns.delete(effectFn) } effectFn.deps.length = 0 } function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn fn() } effectFn.deps = [] // (1) effectFn() } const objsMap = new WeakMap() const data = { text: 'hello vue', ok: true } const obj = new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newValue) { target[key] = newValue trigger(target, key) return true } }) function track(target, key) { if(!activeEffect) return let propsMap = objsMap.get(target) if(!propsMap) { objsMap.set(target, (propsMap = new Map())) } let fns = propsMap.get(key) if(!fns) { propsMap.set(key, (fns = new Set())) } fns.add(activeEffect) activeEffect.deps.push(fns) // (2) } function trigger(target, key) { const propsMap = objsMap.get(target) if(!propsMap) return const fns = propsMap.get(key) fns && fns.forEach(fn => fn()) } function fn() { document.body.innerText = obj.ok ? obj.text : 'ops...' console.log('Done!') } effect(fn)
在这段代码中,我们增加了3处改动。为了记录副作用函数被哪些副作用函数收集器收集,我们在(1)处给每个副作用函数挂载了一个 deps ,用于记录该副作用函数被谁收集。在(2)处,副作用函数被收集时,我们记录副作用函数收集器。在(3)处,我们新增了 cleanup 函数,从含有该副作用函数的副作用函数收集器中,删除该副作用函数。
看上去好像没啥问题了,但是运行代码会发现产生了死循环。问题出在哪呢?
以下面这段代码为例:
const set = new Set([1]) set.forEach(item => { set.delete(1) set.add(1) console.log('Done!') })
是的,这段代码会产生死循环。原因是ECMAScript对 Set.prototype.forEach 的规范中明确,使用 forEach 遍历 Set 时,如果有值被直接添加到该 Set 上,则 forEach 会再次访问该值。
const effectFn = () => { cleanup(effectFn) // (1) activeEffect = effectFn fn() // (2) }
同理,我们的代码中,当 effectFn 被执行时,(1)处的 cleanup 清除副作用函数,就相当于 set.delete ;而(2)处执行副作用函数 fn 时,会触发依赖收集,将副作用函数又加入到了副作用函数收集器中,相当于 set.add ,从而造成死循环。
解决的方法也很简单,我们只需要避免在原 Set 上直接进行遍历即可。
const set = new Set([1]) const otherSet = new Set(set) otherSet.forEach(item => { set.delete(1) set.add(1) console.log('Done!') })
在上例中,我们复制了 set 到 otherset 中, otherset 仅会执行 set.length 次。按照这个思路,修改我们的代码。
let activeEffect function cleanup(effectFn) { for(let i = 0; i < effectFn.deps.length; i++) { const fns = effectFn.deps[i] fns.delete(effectFn) } effectFn.deps.length = 0 } function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn fn() } effectFn.deps = [] effectFn() } const objsMap = new WeakMap() const data = { text: 'hello vue', ok: true } const obj = new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newValue) { target[key] = newValue trigger(target, key) return true } }) function track(target, key) { if(!activeEffect) return let propsMap = objsMap.get(target) if(!propsMap) { objsMap.set(target, (propsMap = new Map())) } let fns = propsMap.get(key) if(!fns) { propsMap.set(key, (fns = new Set())) } fns.add(activeEffect) activeEffect.deps.push(fns) } function trigger(target, key) { const propsMap = objsMap.get(target) if(!propsMap) return const fns = propsMap.get(key) const otherFns = new Set(fns) // (1) otherFns.forEach(fn => fn()) } function fn() { document.body.innerText = obj.ok ? obj.text : 'ops...' console.log('Done!') } effect(fn)
在(1)处我们新增了一个 otherFns ,复制了 fns 用来遍历。让我们再来看看结果。
①处,更改 obj.ok 的值为 false ,改变了页面的显示,没有导致死循环。②处,当 obj.ok 为 false 时,副作用函数没有执行。至此,我们完成了针对分支切换场景下的优化。
副作用函数嵌套产生的BUG
我们继续从功能角度考虑,前面我们的副作用函数还是不够复杂,实际应用中(如组件嵌套渲染),副作用函数是可以发生嵌套的。
我们举个简单的嵌套示例:
let t1, t2 effect(function effectFn1() { console.log('effectFn1') effect(function effectFn2() { console.log('effectFn2') t2 = obj.bar }) t1 = obj.foo })
这段代码中,我们将 effectFn2 嵌入了 effectFn1 中,将 obj.foo 赋值给 t1 , obj.bar 赋值给 t2 。从响应式的功能上看,如果我们修改 obj.foo 的值,应该会触发 effectFn1 的执行,且间接触发 effectFn2 执行。
修改 obj.foo 的值仅触发了 effectFn2 的更新,这与我们的预期不符。既然是 effect 这里出了问题,让我们再来过一遍 effect 部分的代码,看看能不能发现点什么。
let activeEffect // (1) function cleanup(effectFn) { for(let i = 0; i < effectFn.deps.length; i++) { const fns = effectFn.deps[i] fns.delete(effectFn) } effectFn.deps.length = 0 } function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn fn() // (2) } effectFn.deps = [] effectFn() }
仔细思考后,不难发现问题所在。我们在(1)处定义了一个全局变量 activeEffect 用于副作用函数注册,这意味着 同一时刻,我们仅能注册一个副作用函数 。在(2)处执行了 fn ,此时注意,在我们给出的副作用函数嵌套示例中, effectFn1 是先执行 effectFn2 ,再执行 t1 = obj.foo 。也就是说,此时 activeEffect 注册的副作用函数已经由 effectFn1 变为了 effectFn2 。因此,当执行到 t1 = obj.foo 时, track 收集的 activeEffect 已经是被 effectFn2 覆盖过的。所以,修改 obj.foo , trigger 触发的就是 effectFn2 了。
要解决这个问题也很简单,既然后出现的要先被收集,后进先出,用栈解决就好了。
let activeEffect const effectStack = [] // (1) function cleanup(effectFn) { for(let i = 0; i < effectFn.deps.length; i++) { const fns = effectFn.deps[i] fns.delete(effectFn) } effectFn.deps.length = 0 } function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) fn() // (2) effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } effectFn.deps = [] effectFn() }
这段代码中,我们在(1)处定义了一个栈 effectStack 。不管(2)处如何更改 activeEffect 的内容,都会被 effectStack[effectStack.length - 1] 回滚到原先正确的副作用函数上。
运行的结果和我们的预期一致,到此为止,我们已经完成了对嵌套副作用函数的处理。
自增/自减操作产生的BUG
这里还存在一个隐蔽的BUG,还和之前一样,我们修改 effect 。
effect(() => obj.foo++)
很简单的副作用函数,这会有什么问题呢?执行一下看看。
很不幸,栈溢出了。这个副作用函数仅包含一个 obj.foo++ ,所以可以确定,栈溢出就是由这个自增运算引起的。接下来的问题就是,这么简单的自增操作,怎么会引起栈溢出呢?为了更好的说明问题,让我们先来拆解问题。
effect(() => obj.foo = obj.foo + 1)
这段代码中 obj.foo = obj.foo + 1 就等价于 obj.foo++ 。这样拆开之后问题一下就清楚了。这里同时进行了 obj.foo 的 get 和 set 操作。先读取 obj.foo ,收集了副作用函数,再设置 obj.foo ,触发了副作用函数,而这个副作用函数中 obj.foo 又要被读取,如此往复,产生了死循环。为了验证这一点,我们打印执行的副作用函数。
上面的打印结果印证了我们的想法。造成这个BUG的主要原因是,当 get 和 set 操作同时存在时,我们收集和触发的都是同一个副作用函数。这里我们只需要添加一个守卫条件:当触发的副作用函数正在被执行时,该副作用函数则不必再被执行。
function trigger(target, key) { const propsMap = objsMap.get(target) if(!propsMap) return const fns = propsMap.get(key) const otherFns = new Set() fns && fns.forEach(fn => { if(fn !== activeEffect) { // (1) otherFns.add(fn) } }) otherFns.forEach(fn => fn()) }
如此一来,相同的副作用函数仅会被触发一次,避免了产生死循环。最后,我们验证一下即可。
到此这篇关于Vue3响应式对象是如何实现的的文章就介绍到这了,更多相关Vue3响应式对象内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!
查看更多关于Vue3响应式对象是如何实现的(2)的详细内容...