一分钟早读系列:推演vue3响应式(二)effect进阶篇

378 阅读6分钟

vue中最出名的就是响应式变化,仅通过改变数据的方式即可改变视图,那么vue是如何做到的呢?

我们正常使用vue3中,通常是使用ref(str|num)reactive(obj),其中ref本质上也是调用reactive,在reactive中对入参进行了拦截处理,这里面的核心是effect的收集,执行能力。

为了通俗易懂,作者将拆分几个章节,按照顺序讲解响应式。本文的内容是基于上一个章节的代码,继续一步步推演effect源码。

先温习一下上一个章节的代码

// 用一个全局变量存储副作用函数
let activeEffect
// effect函数用于注册副作用
function effect(fn) {
    // 储存副作用函数
    activeEffect = fn
    // 执行函数
    fn()
}
// 要响应的对象
const obj = new Proxy(data, {
    get(target, key) {
        // 将target和key传入函数中用以收集副作用函数
        track(target, key)
        return target[key]
    },
    set(target, key, newVal) {
        // 设置属性值
        target[key] = newVal
        // 根据target和key取出对应的全部副作用函数并执行
        trigger(target, key)
        // 必须写这句,代表设置成功
        return true
    }
})
// 追踪变化
function track(target, key) {
    if (!activeEffect) {
        return target[key]
    }
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
        depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)
}
// 触发变化
function trigger(target, key) {
    const depsMap = targetMap.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach((fn => fn())
} 

看现在上去没什么问题,但有一个缺陷:缺少销毁能力。何为销毁能力,用于什么场景呢?下面举一个小例子

const data = {
    flag: true,
    text: 'hello world'
}
const obj = new Proxy(data, {...}) // 此处省略,和上述代码一样
effect(function effectFn() {
    document.body.innerText = obj.flag ? obj.text : 'noop'
})

可以看到effectbody的文本变成了一个三元表达式,我们仔细分析这段代码,不难发现会同时读取obj.flagobj.text,意味着会走两遍obj的get,以及里面的track收集方法。将会构建如下依赖关系图

targetMap: WeakMap<data, Map{
    flag: Set[effectFn],
    text: Set[effectFn]
}>

粗看之下确实没问题,obj.flagobj.text任意一个发生变化都要重新执行effectFn。接下来我们将obj.flag设为false,bodyinnerText会恒定为'noop',理论上来讲此时obj.text的变化,已经与innerText无关了,但事实是:

obj.text = 'hello Vue'

会重新执行

document.body.innerText = obj.flag ? obj.text : 'noop'

body的内容原本就是noop,又被重置成了noop。明明就不需要变化,却依旧执行了函数,这不符合我们的要求,所以我们要建立一个销毁机制:每次执行effectFn这个副作用函数前(不了解副作用函数的请移步上一章)都清空与之相关的依赖收集,等下次读取触发track收集的时候再重新收集。也就是这样:

// 首次收集
targetMap: WeakMap<data, Map{
    flag: Set[effectFn],
    text: Set[effectFn]
}>
// set触发执行副作用函数,执行前清空
targetMap: WeakMap<data, Map{
    flag: Set[],
    text: Set[]
}>
// flag变为false
targetMap: WeakMap<data, Map{
    flag: Set[effectFn],
    text: Set[]
}>

这样一来obj.text再发生变化,就不会执行额外的副作用函数了。

要想销毁相关的全部依赖,就得先获取到相关的依赖。这里首先要建立一个储存的位置,这个位置要既能在effect执行使用,也能在track收集时使用。有一个全局变量符合这个要求————没错,就是activeEffect!这个变量用于储存当前正在处理的副作用函数。我们期望可以在activeEffect上能读取到该副作用函数相关的全部依赖。那么我们可以进行如下改造

function effect(fn) {
    // 新增副作用容器
    const effectFn = () => {
        // 储存副作用函数
        activeEffect = effectFn
        // 执行副作用函数
        fn()
    }
    // 在副作用函数上新增一个静态属性,用以收集副作用函数相关的全部依赖
    effectFn.deps = []
    // 执行副作用容器
    effectFn()
}

如上所示,我们在effectFn(也就是activeEffect)上开辟了个静态属性deps。既然有了储存位置,接下来我们就要向内储存东西了

// 追踪变化
function track(target, key) {
    if (!activeEffect) {
        return target[key]
    }
    let depsMap = targetMap.get(target)
    if (!depsMap) {
        targetMap.set(target, (depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if (!deps) {
        depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)
    // 新增代码 向activeEffect.deps填充依赖集合
    activeEffect.deps.push(deps)
}

可以看到track做的事情也很简单,将与activeEffect关联的key的依赖集合推入effectFn.deps,现在回想一下我们的目的:每次执行完effectFn之后,都清除所有与之相关的依赖关系。我们现在已经做到了有几个属性关联effectFn,就收集几个依赖集合,这个集合里面包含了该属性关联的所有副作用函数。那么我们要做的事情也很简单:遍历全部依赖集合,在其中删除effectFn

function effect(fn) {
    // 新增副作用容器
    const effectFn = () => {
        // 清除副作用函数相关的依赖
        cleanup(effectFn)
        // 储存副作用函数
        activeEffect = effectFn
        // 执行副作用函数
        fn()
    }
    // 用以收集副作用函数相关的全部依赖
    effectFn.deps = []
    // 执行副作用容器
    effectFn()
}

function cleanup (effectFn) {
    for(let i = 0;i < effectFn.deps.length;i++){
        const deps = effectFn.deps[i]
        // deps为其中一个依赖集合,这里要将effectFn从依赖集合中删除
        deps.delete(effectFn)
    }
    // 手动将长度设为0
    effectFn.deps.lendth = 0
}

然而执行的时候会发现代码被阻塞掉,原因出在trigger中

function trigger(target, key) {
    const depsMap = targetMap.get(target)
    if (!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach((fn => fn()) // 问题出在这一行
} 

我们来捋一下执行顺序:

// 1、修改值
data.text = 'hello'
// 2、触发setter 以下为setter部分代码
trigger(target, key)
// 3、执行trigger 以下为为trigger部分代码
effects.forEach((fn => fn())
// 4、执行fn 也就是effectFn 以下为为effectFn部分代码
cleanup(effectFn)
fn() // 执行effect传入的函数 这里很关键!
// 5、执行cleanup 将effectFn从所有与其相关的依赖集合中删除
for(let i = 0;i < effectFn.deps.length;i++){
    const deps = effectFn.deps[i]
    deps.delete(effectFn)
}

让我们一步步拆解。第1步修改一下值来触发setter,setter内部代码执行了trigger,到达第2步。接下来到达了trigger内部,内部遍历执行修改值绑定的副作用函数集合。进入执行阶段,也就是第4步,执行函数内部有两步比较关键:1、cleanup清除依赖 2、执行副作用函数。我们这里先看cleanup,进入第5步

注意第5步的effectFn.deps,第3步的effects.forEach((fn => fn())中的effects就包含在effectFn.deps中。也就是说第5步正在删除第3步forEach遍历的数组里的项。

再回过头看第4步的第二项,执行副作用函数fn。执行时会触发getter,内部收集副作用函数并推入集合当中,也就是向effects中推入函数。所以我们本质上是在对一个正在遍历的数组动态的添加项,导致这个数组永远遍历不完。

解决的办法也很简单,我们遍历原数组的副本,保证原数组的操作不会影响到此次遍历即可

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

可以看到我们新建了个集合去遍历,这样我们的代码执行起来就不会有问题了

本章节主要讲述了effect的边界处理之一:cleanup,主要功能为避免执行无用的副作用函数。再源码中对应关系为effect.run

image.png

image.png

以上就是本章的全部内容了,effect还有很多的边界处理情况,我们会在下一个章节中继续推演