系列文章的前置文章
依稀记得不久前在某音上刷到一个前端连麦面试的直播,一上来问的就是:说说computed的实现原理吧,连麦的同学并没有回答上来,回答的是使用场景,直播间的弹幕说一年经验的人不问这些,主播回答的意思是不问这个问什么,现在不是行情好的时候了,你能随便找到工作的话就可以不了解。
原来这就是强者的世界吗,当时就给还在边查文档边写Vue3(当然,现在也是边查边写)的我镇住了,两年打工经验的我也完全没听过这题目呀🧎♂️。记得21年的时候问Vue原理的时候基本都是你知道Vue的响应式原理吗,然后回答一个Object.defineProperty
就可以了,再深一点是问key的作用问到diff算法(当然指的并不是大厂)。
拉回正题,上一节中我们初步学习了如何实现响应式系统的可调度性,这一节我们来学习与这个特性息息相关的也很常用的computed。
懒执行的副作用函数
目前为止我们实现的effect
函数是会立即执行的(前面的文章都贴在文章开头了,感兴趣的话可以先看一下前面的文章🙇♂️),如下面的代码:
javascript
effect(() => {
console.log(proxyObj.foo)
})
💡倘若我们想让这个字段和副作用函数关联的过程 不立刻执行该怎么办呢?我们可以在调用时传递options ,里面写上lazy: true
,意思就是不要立刻执行,并把副作用函数返回回来,由我来控制什么时候执行:
javascript
effect(() => {
console.log(proxyObj.foo)
}, {
// 新增
lazy: true
})
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn) // 新增
activeEffectFn = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn)
// 接受fn函数的返回值
fn()
// 结束之后,弹出副作用函数
effectStack.pop()
// 把activeEffectFn改为栈顶的函数
activeEffectFn = effectStack[effectStack.length - 1]
}
effectFn.deps = []
// 将 options 挂载到 effectFn 上
effectFn.options = options // 新增
if (!options.lazy) {
effectFn()
}
// 把副作用函数返回
return effectFn
}
由此我们就可以手动来执行这个副作用函数了:
javascript
const effectFn = effect(() => {
console.log(proxyObj.foo)
}, {
lazy: true
})
effectFn()
computed的初步实现
只是手动控制副作用函数执行用处并不大,但倘若我们传递给effect
的函数会return一个值,如下:
javascript
const effectFn = effect(() => {
return proxy.foo + proxy.bar
}, {
lazy: true
})
然后我们在effectFn
调用这个函数时获取到返回值,并将他作为effectFn
的返回值,那么我们就可以在调用时拿到计算后的值,有点绕,上代码:
diff
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn) // 新增
activeEffectFn = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn)
- fn()
+ // 接受fn函数的返回值
+ const res = fn()
// 结束之后,弹出副作用函数
effectStack.pop()
// 把activeEffectFn改为栈顶的函数
activeEffectFn = effectStack[effectStack.length - 1]
+ // 将返回值res作为effectFn的返回值
+ return res
}
effectFn.deps = []
// 将 options 挂载到 effectFn 上
effectFn.options = options // 新增
if (!options.lazy) {
effectFn()
}
// 把副作用函数返回
return effectFn
}
这样就可以拿到计算结果了:
javascript
const effectFn = effect(() => {
return proxy.foo + proxy.bar
}, {
lazy: true
})
const res = effectFn()
这基本上就是computed 的雏形。接下来我们尝试实现computed,我们知道computed是一个函数,在调用computed时,我们会得到一个对象,使用的时候我们会用.value
来获取他的值,包装一下如下:
javascript
function computed(getter) {
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value() {
// 在实际获取value值时才执行副作用函数
return effectFn()
}
}
return obj
}
具体运行一下:
javascript
let obj = {
// 添加两个变量
param1: 1,
param2: 1
}
const sum = computed(() => {
console.log('set');
return proxyObj.param1 + proxyObj.param2
})
console.log(sum.value)
可以看到打印出来了2:
不过目前我们只实现了计算属性的懒执行,也就是在实际获取value值时才执行副作用函数。
计算属性还有一个很重要的特性:可缓存性 ,在依赖的值没有改变的情况下,副作用函数无需重复执行,但是现在如果我们多次获取打印sum.value
会发现每一次获取都去执行了一遍副作用函数:
javascript
console.log(sum.value)
console.log(sum.value)
computed实现缓存
实现计算属性的可缓存性,我们需要考虑下面几个方面:
- 创建一个标志变量
dirty
,用于标识是否可以从缓存里取值; - 创建一个存储值的变量
value
,在标志变量dirty
意为不可从缓存里取值时重新运行副作用函数并更新值,同时把标志变量dirty
置为可以从缓存里取值; - 在计算属性依赖的值产生变化后将标志变量
dirty
置为不可从缓存里取值
实现的代码如下:
javascript
function computed(getter) {
let value // 用来缓存上一次的值
let dirty = true // 用来表示是否要重新计算值,dirty表示"脏",需要重新计算值
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将dirty重置为true,新增
scheduler() {
dirty = true
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
// 将dirty置为false,下一次获取值时直接返回缓存的value值, 新增
dirty = false
}
return value
}
}
return obj
}
其中在计算属性依赖的值产生变化后将标志变量置为不可从缓存里取值 这一步就用到了上一篇文章中我们讲到的调度器,同时也避免了修改依赖的值之后副作用函数运行。看下运行效果:
computed返回值的依赖绑定和触发
目前的实现里还有一个小问题,看下面的代码例子:
javascript
effect(function effectFn2() {
console.log(sum.value);
})
proxyObj.param1++
在这个例子中,effectFn2
中会打印sum.value
,我们期望在修改计算属性sum
依赖的值后,能够触发执行effectFn2
,但实际上只会触发一次打印,我们希望再打印一次3:
我们梳理一下这个effect的执行到底发生了什么,在执行到const res = fn()
时,实际上就是执行effectFn2
,需要打印sum.value
,也就是getsum.value
,由此触发了computed
内部的get value()
,将proxy.param1
和proxy.param2
绑定到了他们对应的getter上,然后返回了计算后的值赋值到res
上,然后就到下一步了。
然后是修改值proxyObj.param1++
的这一步,执行到调度器时也只是把dirty
的值修改成了true。
我们会发现,在整个过程中既没有绑定也没有触发effectFn2
的过程:
- 没有将
sum.value
和effectFn2
关联起来, - 在修改计算属性关联的值后也不会触发
effectFn2
。
所以解决方法也很明显了,我们需要在获取value
值时手动绑定,在修改计算属性依赖值的调度器时触发value
值的副作用函数,如下:
diff
function computed(getter) {
let value // 用来缓存上一次的值
let dirty = true // 用来表示是否要重新计算值,dirty表示"脏",需要重新计算值
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将dirty重置为true
scheduler() {
dirty = true
+ trigger(obj, 'value')
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
// 将dirty置为false,下一次获取值时直接返回缓存的value值
dirty = false
}
+ track(obj, 'value')
return value
}
}
return obj
}
再执行一下看看效果,打印了两次,后一次打印也是更新过后的值。
小结
不知道回答出来这些够不够跟面试官打的时候过两招了,下一节我们再看看watch的基本实现,这个应该也是可能会被问到的吧(昏厥)。有什么写的不对的地方或者什么疑问可以留言,希望文章有帮助到你~
参考书籍
《Vue.js设计与实现》------------霍春阳