Vue3源码学习4(中) | 响应系统的作用与实现

142 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第8天,点击查看活动详情

image.png

Vue源码学习4(中) | 响应系统的作用与实现

续上文,我们了解了响应式的基本实现,那么接下来,让我们继续来深入响应式这个大山!

4.4 分支切换 与 cleanup

看到这个问题,想必大家会有一个疑问:

  • 什么叫做 分支切换

明确分支切换的定义

话不多说,先看一段代码:

const data = { ok:true,text:'hello world' }
const obj = new Proxy(data,{/*...*/})

effect(function effectFn() {
    document.body.innerText = obj.ok ? obj.text : 'not'
})

effectFn 函数内部存在一个三元表达式,根据字段obj.ok 值得不同会执行不同的代码分支。当字段obj.ok的值发生变化时,代码执行的分支会跟着变化,这就是所谓的分支切换。

理想状态

分支切换可能会产生遗留的副作用函数。拿上面的这段代码来说,字段obj.ok的初始值为 true ,这时会读取字段obj.text的值,所以当 effect 函数执行时会触发字段obj.ok和字段obj.text这两个属性的读取操作,此时副作用函数effectFn与响应式数据之间建立的联系如下:

data 
  └─ ok
      └─ effectFn
  └─ text
      └─ effectFn

下图给出了更详细的描述。

image.png

但是上面的这种联系并不是理想状态下的联系,上面的这种表示:当副作用函数effectFn分别被字段data.ok和字段data.text所对应依赖集合收集,也就是说无论是读取哪个字段,都会触发副作用函数并执行。但我们理想状态下是当只触发字段obj.ok的读取操作时,副作用函数effect只会被obj.ok对应的依赖集合收集,而不会被字段obj.text所对应的依赖集合收集,如下图所示。

image.png

但是按照上文的代码实现中,我们还做不到这一点,也就是说会产生遗留的副作用函数。这个时候,大家可能会产生一个疑惑:

  • 遗留的副作用函数是什么?它对我们有什么样的影响?

嗷!这里咱就直接给出答案:遗留的副作用函数会导致不必要的更新,拿下面这段代码来说:

const data = { ok:true,text:'hello world' }
const obj = new Proxy(data,{ /*...*/ })

effect(function effectFn(){
    document.body.innerText = obj.ok ? obj.text : 'not'
})

obj.ok的初始值为true,当将其修改为false后:

obj.ok = false

这会触发更新,即副作用函数会重新执行。但是由于此时obj.ok的值为false,所以不会读取字段obj.text的值。换句话说,无论字段obj.text的值如何变化,document.body.innerText的值始终都是字符串'not'

所以此时最好的结果:无论obj的值怎么变,都不需要重新执行副作用函数。但是事与愿违,如果再次尝试修改obj.text的值:

obj.text = 'hello vue3'

可以发现,obj.text 视图上的值还是改变了,也就是说仍然会导致副作用函数重新执行,即使document.body.innerText的值不需要变化。

追求理想状态

其实解决这个问题的思路很简单,即每次副作用函数执行时,我们可以先把它从与之相关联的依赖集合中删除,如图所示。

image.png 当副作用函数执行完毕后,会重新建立联系,但是新的联系中不会包含遗留的副作用函数。所以,如果我们能做到每次副作用函数执行前,将其从相关的依赖集合中移除,那么问题就迎刃而解了。

实现理想状态

要将一个副作用函数从所有与之关联的依赖集合中移除,就需要明确知道哪些依赖集合中包含它,因此我们需要重新设计副作用函数,代码如下:

// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
    const effectFn () => {
    // 当effect 执行时,将其设置为当时激活的副作用函数
    activeEffect = effectFn
    fn()
    }
    // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
    effectFn.deps = []
    // 执行副作用函数
    effectFn()
}

effect内部我们定义了新的effectFn函数,并为其添加了effect.deps属性,该属性是一个数组,用来存储所有的包含当前副作用函数的依赖集合。

那么这个时候掘友们应该回想:

  • 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(activeEfect)
    // deps 就是一个与当前副作用函数存在联系的依赖集合
    // 将其添加到 activeEffect.deps 数组中
    activeEffect.deps.push(deps) // 新增
}

如上面的代码所示,在track函数中我们将当前执行的副作用函数activeEffect添加到依赖集合deps中,这就说明 deps 就是一个与当前副作用函数存在联系的依赖集合,于是我们也把它添加到activeEffect.deps数组中,这样就完成了对依赖集合的收集。如下图,就是这一步所建立的关系:

image.png 有了这个联系后,我们就可以在每次副作用函数执行时,根据effect.deps获取所有相关联的依赖集合,进而将副作用韩式从依赖集合中移除:

// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
    const effect = () => {
    // 调用 cleanup 函数完成清楚工作
    cleanup(effectFN) // 新增
    activeEffct = effectFn
    fn()
    }
    effrctFn.deps = []
    effectFn()
}

cleanup 函数的实现

function cleanup(effectFn) {
// 遍历 effectFn.deps 数组
for(let i = 0; i < effectFn.deps.length;i++) {
    // deps 是依赖集合
    const deps = effectFn.deps[i]
    // 将 effectFn 从依赖集合中移除
    deps.delete(effectFn)
    }
    // 最后需要重置 effectFn.deps 数组
    effectFn.deps.length = 0
}

cleanup函数接收副作用函数作为参数,遍历副作用函数effectFn.deps数组,该数组的每一项都是一个依赖集合,然后将该副作用函数从依赖集合中移除,最后重置effectFn.deps数组。

目前理想状态下的"弊端"

好!到目前为止,咱的响应系统已经可以避免副作用函数产生遗留了。但是如果你尝试运行代码,会发现目前的实现会导致无限循环执行,问题就出现在trigger函数中:

function trigger(target,key) {
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn()) // 就这这句玩意出了问题!
}

trigger函数内部,咱们遍历effect集合,它是一个Set集合,里面存储着副作用函数。当副作用函数执行时,会调用cleanup进行清除,实际上就是从effect集合中将当前执行的副作用函数剔除,但是副作用的执行会导致其重新被收集到集合中,而此时对于effect集合的遍历仍在进行,或者你可以看看下面的这行代码,你就知道我在说啥了

const set = new Set([1])

set.forEach(item => {
    set.delete(1)
    set.add(1)
    console.log('遍历中')
})

在上面这段代码中,咱创建了一个集合set,它里面有一个元素数字1,接着调用forEach遍历该集合。在遍历过程中,首先调用delete(1)删除数字1,紧接着调用add(1)将数字1加回,最后打印遍历中。如果我们在浏览器中执行这段代码,就会发现它会无限执行下去。

  • 为什么会有个问题呢?

语言规范中对此有明确的说明:在调用forEach遍历Set集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时forEach遍历没有结束,那么该值会重新被访问。 因此,上面的代码会无限被执行。解决办法很简单,我们可以构造另外一个Set集合并遍历它:

const set = new Set([1])

const newSet = new Set(set)
newSet.forEach(item => {
    set.delete(1)
    set.add(1)
    consolo.log('遍历中')
})

这样就不会无限执行了。回到trigger函数,我们需要同样的手段来避免无限执行:

function trigger(target,key) {
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const effects = depsMap.get(key)
    
    const effectsToRun = new Set(effects) // 新增
    effectsToRun.forEach(effectFn => effectFn()) // 新增
    effects && effects.forEach(fn => fn()) // 删除

如以上代码所示,我们新构造了effectsToRun集合并遍历它,代替直接遍历effect集合,从而避免了无限执行。

4.5 嵌套的effecteffect

提出嵌套,问题随之来临

effect是可以发生嵌套的,例如:

effect(function effectFn1() {
    effect(function effectFn2() { /*...*/ })
})

似乎这种嵌套的在Vue.js中随处可见,比如“组件嵌套组件”。但实际上,按照前文的介绍与实现来看,我们所实现的响应系统并不支持effect嵌套(毕竟只声明了一个全局变量去接)。具体的看代码:

// 原始数据
const data = { foo:true,bar:true }
// 代理对象
const obj = new proxy(data,{ /*...*/ })

// 全局变量
let temp1,temp2
// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
    console.log('effectFn1 执行')
    
    effect(function effectFn2() {
        console.log('effectFn2 执行')
        // 在 effectFn2 中读取 obj.bar 属性
        temp2 = obj.bar
    })
    // 在 effectFn1 中读取 obj.foo 属性
    temp1 = obj.foo
})

effectFn2中读取了字段obj.bar,在effectFn1中读取了字段obj.foo,并且effectFn2的执行先于obj.foo的读取操作。在理想状态下,我们希望副作用函数与对象属性之间的联系如下:

data
  └─ foo
         └─ effectFn1
  └─ bar
         └─ effectFn2

在这种情况下,我们希望当修改obj.foo时会触发effectFn1执行。由于effectFn2嵌套在effect里,所以会间接触发effectFn2执行,而当修改obj.bar时,只会触发effectFn2执行。但是事与愿违,当尝试修改obj.foo时,会发现输出为:

effectFn1 执行
effectFn2 执行
effectFn2 执行

一共打印三次,前两次分别是副作用函数effectFn1effectFn2执行的打印结果,到这一步是正常的,问题出现在第三行打印。我们修改了字段obj.foo的值,发现effectFn1并没有重新执行,反而使得effectFn2重新执行了,这显然不是我们想要的。

发现问题

  • 那么出现问题了,问题在哪呢?

观察下面的代码:

// 用一个全局变量存储被注册的副作用函数
let activeEffect
function effect(fn) {
    const effect = () => {
    // 调用 cleanup 函数完成清楚工作
    cleanup(effectFN) // 新增
    // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
    activeEffct = effectFn
    fn()
    }
    // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
    effrctFn.deps = []
    // 执行副作用函数
    effectFn()
}

全局变量activeEffect来存储通过effect函数注册的副作用函数,这意味着同一时刻activeEffect所存储的副作用函数只能有一个。当副作用函数发生嵌套时,内层副作用函数的执行会覆盖activeEffect的值,并且永远不会恢复到原来的值。这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层副作用函数中读取的,它们收集到的副作用函数也会是内层副作用函数,这!就是问题所在!

解决问题

为了解决这个问题,我们需要一个副作用函数栈effectStack,在副作用执行时,将当前的副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让activeEffect指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现相互影响的情况,代码如下:

// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 栈
const effectStack = [] 
function effect(fn) {
    const effect = () => {
    // 调用 cleanup 函数完成清楚工作
    cleanup(effectFN) 
    // 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
    activeEffct = effectFn
    // 在调用副作用函数之前将当前副作用函数压入栈中
    effectStack.push(effectFn)
    fn()
    // 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
    }
    // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
    effrctFn.deps = []
    // 执行副作用函数
    effectFn()
}

如上面的代码所示,咱定义了effectStack数组,用它来模拟,activeEffect没有变化,它仍然指向当前在执行的副作用函数。不同的是,当前执行的副作用函数会被压入栈顶,这样当副作用函数发生嵌套时,栈底存储的就是外层副作用函数,而栈顶存储的则是内层副作用函数

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

过程如下面图像所示。

image.png

这样一来,响应式数据就只会收集直接读取其值的副作用函数作为依赖,从而避免发生错乱。

待续

响应式是 Vue.js 中重要的一部分,它也真的是座大山,如果看到这里而且看懂的掘友的话,我相信我们可以成为很好的朋友。