Vue响应式原理(3)-断开副作用函数与响应式数据联系

58 阅读5分钟

1. 分支切换过程中的问题

基于先前编写的响应式系统,我们来考虑一种分支切换的情况如下:

const data = { status: true, value: 'yes' }
const obj = new Proxy(data, { /* ... */ })
effect(function effectFn() {
    document.body.innerText = obj.status ? obj.value : 'no'
})

effectFn 函数中的三元表达式会根据字段objstatus属性值变化会执行不同的代码分支。

obj.status的值为true时,副作用函数effectFn中读取了obj.statusobj.value,因此会完成依赖收集过程,将effectFn分别收集到objstatusvalue属性对应的deps集合中。此时副作用函数 effectFn 与响应式数据之间建立的联系如下:

data
    └── status 
        └── effectFn
    └── value
        └── effectFn

obj.status的值为false时,副作用函数effectFn中只读取了obj.status,因此依赖收集过程只会将effectFn收集到objstatus属性对应的deps集合中。此时副作用函数 effectFn 与响应式数据之间建立的联系如下:

data
    └── status 
        └── effectFn

当我们在obj.statustrue的情况下,建立了相应的依赖关系,而当我们将obj.status置为false时,期望的运行过程是effectFn重新执行并对原有的依赖关系进行更新,具体来说就是断开obj.valueeffectFn之间的联系,当我们对obj.value进行变更时不会触发effectFn的重新执行。但是当我们执行下面这段代码时会发现并未如我们预期所执行,obj.value的变更依旧触发了effectFn的重新执行。

const data = { status: true, value: 'yes' }
const obj = new Proxy(data, { /* ... */ })
effect(function effectFn() {
    console.log('effectFn exec')
    document.body.innerText = obj.status ? obj.value : 'no'
})
obj.status = false
obj.value = 'change' // effectFn再次触发执行打印'effectFn exec',不符合预期

产生以上现象的原因也很明显,因为我们在先前的依赖收集过程中只有将副作用函数收集到deps中,而在副作用函数重新触发时,并未清空先前所建立的依赖关系并重新建立依赖关系,导致上述例子中obj.value和effectFn之间的依赖关系依然存在。

2. 断开副作用函数与响应式数据之间的联系

容易想到的一个解决方案就是每次副作用函数执行前,将其从所有相关联的依赖集合中移除,在副作用函数执行时重新建立副作用函数与响应式数据之间的依赖关系,那么问题就迎刃而解了。

要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它。因此我们之前设计的注册副作用函数需要进一步优化,如下面的代码所示。在 effect 内部我们定义了新的effectFn函数,并为其添加了 effectFn.deps 属性,该属性是一个数组,用来存储所有包含当前副作用函数的依赖集合。

// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
    const effectFn = () => {
        // 当 effectFn 执行时,将其设置为当前激活的副作用函数
        activeEffect = effectFn
        fn()
    }

    // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
    effectFn.deps = []
    // 执行副作用函数
    effectFn()
}

需要注意的是,这里对副作用函数的注册方式进行了一定程度的改造,将真正的副作用函数fn以变量名为effectFn的匿名函数形式进行了包装,在匿名函数中将activeEffect指向effectFn本身,随后执行fn。这样在依赖收集中我们收集到的activeEffect就是effectFn这个匿名函数。

那么在什么时候完成在effectFn.deps中添加相应依赖集合呢,很显然应该在track函数中完成这个操作,如下面的代码所示:

function track(target, key) {
    // 没有 activeEffect,直接 return
    if (!activeEffect) return
    
    let 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()))
    }
    
    // 把当前激活的副作用函数添加到依赖集合 deps 中
    deps.add(activeEffect)
    // deps 就是一个与当前副作用函数存在联系的依赖集合 将其添加到 activeEffect.deps 数组中
    activeEffect.deps.push(deps) // 新增
}

此处存在两个deps可能容易混淆,一个是响应式对象某一属性对应的依赖集合deps(以deps_1表示),用于存储副作用函数;另一个是副作用函数上的deps(以deps_2表示),用于存储所有收集了该函数的依赖集合。在执行完track函数后,在对象属性对应的依赖集合deps_1中会收集到当前的副作用函数effectFn,同时在当前副作用函数effectFn自身的deps_2中,会反向收集当前的依赖集合deps_1,如下图所示。

1.png

完成副作用函数deps收集后,我们就可以在每次副作用函数执行时,根据effectFn.deps获取所有相关联的依赖集合,进而将副作用函数从依赖集合中移除。

// 用一个全局变量存储被注册的副作用函数
let activeEffect

function effect(fn) {
    const effectFn = () => {
        // 调用 cleanup 函数完成清除工作
        cleanup(effectFn) // 新增
        activeEffect = effectFn
        fn()
    }
    effectFn.deps = []
    effectFn()
}

function cleanup(effectFn) {
    for (let i = 0; i < effectFn.deps.length; i++) {
        effectFn.deps[i].delete(effectFn)
    }
    // 需要重置 effectFn.deps 数组
    effectFn.deps.length = 0
}

cleanup函数中我们需要完成两部分清除功能,其一是将effectFn.deps中所有依赖集合dep中的effectFn删除,其二是将effectFndeps清空,即长度置为0。这样就断开了副作用函数和依赖集合之间的双向连接。

至此,我们的响应系统已经可以解决了副作用函数遗留的问题,在每次副作用函数执行前完成了依赖关系的重置。但不幸的是,上述修改会带来新的问题。在我们运行下列代码时,会发现产生了死循环,在后续我们会对这一问题进行解决。

effect(function effectFn() {
  console.log("exec");
  var abc = obj.status ? obj.value : "no";
  console.log(abc);
});
obj.status = false;
obj.value = "change";