持续创作,加速成长!这是我参与「掘金日新计划 · 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
}
新增了两个变量 value 和 dirty:
- 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