Vue3源码学习5 | computed

310 阅读8分钟

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

Vue3源码学习5 | computed

十月份开始认真认真学习源码,这篇将为十月画上句号。

计算属性 computed 与 lazy

(特别说明:这篇学习历程有点连续上面几篇响应系统的话题,看到不了解的请自行移步之前那几篇,嗷嗷嗷!)

前文介绍了effect函数,它用来注册副作用函数,同时它也允许指定一些选项参数options,例如指定scheduler调度器来控制副作用函数的执行时机和方式;也介绍了用来追踪和收集依赖的track函数,以及用来触发副作用函数重新执行的trigger函数

实际上,综合这些内容,我们就可以实现Vue.js中一个非常重要并且非常有特色的能力 —— 计算属性

初识 lazy

在深入讲解计算属性之前,咱需要先来聊聊关于懒执行的effect,即lazy的effect。

  • 这是什么意思呢?

场景引入

现在咱所实现的effect函数会立即执行传递给它的副作用函数,例如:

effect(
    // 这个函数会立即执行
    () => {
        console.log(obj.foo)
    }
)

但在有些场景下,我们并不希望它立即执行,而是希望它在需要的时候才执行,例如计算属性。这是咱可以通过options中添加lazy属性来达到目的,如下面的代码:

effect(
    // 指定了 lazy 选项,这个函数不会立即执行
    () => {
        console.log(obj.foo)
    },
    // options
    {
    lazy:true
    }
)

走进代码

lazy选项和之前介绍的scheduler一样,它通过options选项对象指定。有了它,我们就可以修改effect函数的实现逻辑了,当options.lazy为true 时,则不立即执行副作用函数:

function effect(fn,options = {}) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn
        effectStack.push(effectFn)
        fn()
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
    }
    effectFn.options = options
    effectFn.deps = []
    // 只有非 lazy 的时候,才执行
    if(!options.lazy) {
        // 执行副作用函数
        effectFn()
    }
    // 将副作用函数作为返回值返回
    return effectFn
}

通过这个判断,就实现了让副作用不立即执行的功能。但是问题来了:

  • 副作用函数应该什么时候执行呢?

通过上面的代码可以看到,咱将副作用函数effectFn作为effect函数的返回值,这就意味着当调用effect函数时,通过其返回值能够拿到对应的副作用函数,这样咱就能手动执行该副作用函数了:

const effect = effect(() => {
    console.log(obj.foo)
},{
    {lazy:trur}
})

//手动执行副作用函数
effectFn()

如果仅仅只是能手动执行副作用函数,其意义不大。但如果我们把传递给effect函数看做成一个getter那么这个getter函数可以返回任何值,例如:

const effectFn = effect(
    // getter 返回obj.foo 与 obj.bar 的和
    () => obj.foo + obj.bar,
    { lazy:true }
)

这样咱在手动执行副作用函数时,就能够拿到其返回值:

const effectFn = effect(
    // getter 返回obj.foo 与 obj.bar 的和
    () => obj.foo + obj.bar,
    { lazy:true }
)
const value = effect()

为了实现这个目标,我们需要再对effect函数做一些修改,如以下代码:

function effect(fn,options = {}) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn
        effectStack.push(effectFn)
        // 将 fn 的执行结果存储到 res 中
        const res = fn()
        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
        // 将 res 作为 effectFn 的返回值
        return res
    }
    effectFn.options = options
    effectFn.deps = []
    if(!options.lazy) {
        effectFn()
    }
    return effectFn
}

通过上面的代码可以看到,传递给effect函数的参数fn才是真正的副作用函数,而effectFn是咱包装后的副作用函数。为了通过effectFn得到真正的副作用函数Fn的执行结果,我们需要将其保存到res变量中,然后将其作为effectFn函数的返回值。

computed降临

咳咳,其实如果上面看的不是很明白,直接看这里也不是不行,上面只是跟你讲述了一些原理)

基本实现

现在我们已经能够实现懒执行的副作用函数,并且能够拿到副作用函数的执行结果了,接下来就可以实现计算属性了,如下所示:

function computed(getter) {
    // 把 getter 作为副作用函数,创建一个 lazy 的effect
    const effectFn = effect(getter,{
        lazy:true
    })
    
    const obj = {
        // 当读取 value 时才执行 effectFn
        get value(){
            return effectFn()
        }
    }
    return obj
}

首先我们定义了一个computed函数,它接收一个getter函数作为参数,我们把getter函数作为副作用函数,用它创建一个lazy的effect。computed函数的执行会返回一个对象,该对象的value属性是一个访问器属性,只有当读取value的值时,才会执行effectFn并将其结果作为返回值返回。

可以使用computed函数来创建一个计算属性:

const data = { foo:1,bar:2 }
const obj = new Proxy(data,{ /*...*/ })

const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value)

精益求精

诶!可以看出基本需求已经实现了,不过现在我们实现的计算属性只做到了懒计算,也就是说,只有你真正读取了sumRes.value的值时,它才会进行计算并得到值。但是还做不到对值进行缓存

也就是说,在上面的代码中,如果多次访问sumRes.value的值,每次访问都会调用effectFn重新计算。

为了解决这个问题,就需要我们在实现 computed 函数时,添加对值进行缓存的功能,如下面代码:

function computed(getter) {
    // value 用来缓存上一次计算的值
    let value
    // dirty 标志,用来标识是否需要重新计算值,为 true 则意味着"脏",需要计算
    let dirty = true
    
    const effectFn = effect(getter,{
        lazy:true
    })
    
    const obj = {
        get value() {
            // 只有"脏"时才计算值,并将得到的值缓存到 value 中
            if(dirty) {
                value = effectFn()
                // 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
                dirty = false
            }
            return value
        }
    }
    return obj
}

新增了两个变量 valuedirty:

  • value 用来缓存上一次计算的值。
  • dirth 一个标识,代表是否需要重新计算。

当我们通过sumRes.value访问值时,只有当dirty为true时,才不会调用effectFn重新计算值,否则直接使用上一次缓存在value中的值。这样无论我们访问多少次sumRes.value,都只会在第一次访问时进行真正的计算,后续访问都会直接读取缓存的value值。

精益求精再求精

相信聪明的你已经看到问题所在了,如果此时我们修改 obj.foo 或 obj.bar 的值,再访问 sunRes.value 会发现访问到的值没有发生变化:

const data = { foo:1,bar:2 }
const obj = new Proxy(data,{ /*...*/ })

const sumRes = computed(() => obj.foo + obj.bar)

console.log(sumRes.value) // 3
console.log(sumRes.value) // 3

// 修改 obj.foo
obj.foo++
// 再次访问,得到的仍然是3,但预期结果应该是 4
console.log(sumRes.value) // 3

这是因为,当第一次访问 sumRes.value 的值后,变量 dirty 会设置为 false,代表不需要计算。即使我们修改了obj.foo的值,但只要dirty的值为false,就不会重新计算,所以导致我们得到了错误的值。

解决办法很简单,当obj.foo或obj.bar的值发生变化时,只要dirth的值重置为true就可以了。

  • 那应该怎么做呢?

这时就用上了上一节介绍的 scheduler 选项,如下面代码所示:

function computed(getter) {
    let value
    let dirty = true
    
    const effectFn = effect(getter,{
        lazy:true,
        // 添加调度器,在调度器中将 dirty 重置为 true
        scheduler() {
            dirty = true
        }
    })
    
    const obj = {
        get value() {
            if(dirty) {
                value = effectFn()
                dirty = false
            }
            return value
        }
    }
    return obj
}

咱为effect添加了scheduler调度器函数,它会在getter函数中所依赖的响应式数据变化是执行,这样我们在scheduler函数内部将dirty重置为true,当下次访问sumRes.value时,就会重新调用effectFn计算值,这样就能够得到预期的结果了。

精益求精再求精又求精

现在,咱设计的计算属性已经趋于完美了,但还是有一个缺陷,它体现在当我们在另外一个effect中读取计算属性的值时:

const sumRes = computed(() => obj.foo + obj.bar)

effect(() => {
    // 在该副作用函数中读取 sumRes.value
    console.log(sunRes.value)
})

// 修改 obj.foo 的值
obj.foo++

上面的代码所示,sumRes 是一个计算属性,如果尝试运行上面这段代码,会发现修改 obj.foo 的值并不会触发副作用函数的渲染。

分析原因

从本质上来看就是一个典型的 effect 嵌套,一个计算属性内部拥有自己的effect,并且它是懒执行的,只有当真正读取计算属性的值时才会执行。对于计算属性getter函数来说,它里面访问的响应式数据只会把 computed 内部effrct收集成依赖。而当把计算属性用于另外一个effect时,就会发生effect嵌套,外层的effect不会被内层effect中的响应式数据收集。

解决办法

当读取计算属性的值时,我们可以手动调用track函数进行追踪;当计算属性依赖的响应式数据发生变化时,我们可以手动调用trigger函数触发响应:

function computed(getter) {
    let value
    let dirty = true
    
    const effectFn = effect(getter,{
        lazy:true,
        scheduler() {
            if(!dirty) {
                dirty = true
                // 当计算属性依赖的响应式数据发生变化时,手动调用 trigger 函数触发响应
                trigger(obj.'value')
            }
        }
    })
    
    const obj = {
        get value() {
            if(dirty) {
                value = effectFn()
                dirty = false
            }
            return value
        }
        // 当读取 value 时,手动调用 track 函数进行追踪
    }
    return obj
}

如以上代码所示,当读取一个计算属性的value值时,我们手动调用track函数,把计算属性返回的对象obj作为target,同时作为第一个参数传递给 track 函数。

当计算属性所依赖的响应式数据变化时,会执行调度器函数,在调度器函数内手动调用trigg函数触发响应即可。 这时对于如下代码来说:

effect(function effectFn() {
    console.log(sunRes.value)
})

它会建立这样的联系:

computed(obj)
     └─ value
             └─ effectFn

期待 watch