在理解计算属性之前,我们需要先来聊聊关于懒执行的 effect,即 lazy 的 effect。这是什么意思呢?举个例子,现在我们所实现的 effect 函数会立即执行传递给它的副作用函数,例如:
effect(() => {
console.log(obj.foo) //这个函数会立即执行
})
但在有些场景下,我们并不希望它立即执行,而是希望它在需要 的时候才执行,例如计算属性。这时我们可以通过在 options 中添加 lazy 属性来达到目的,如下
effect(
() => {
console.log(obj.foo)
},
{
lazy:true
}
)
lazy 选项和之前介绍的 scheduler 一样,它通过 options 选 项对象指定。有了它,我们就可以修改 effect 函数的实现逻辑了, 当 options.lazy 为 true 时,则不立即执行副作用函数:
function effect(fn, options = {}) {
const effectFn = () => {
-------
}
// 将 options 挂在到 effectFn 上
effectFn.options = options
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
if (!options.lazy) { //新增
effectFn()
}
return effectFn //新增
}
上述,我们就实现了副作用函数不立即执行的功能,什么时候可以执行?我们将effectFn返回出来,我们就可以手动执行副作用函数
const effectFn = (() => {
console.log(obj.foo)
},{lazy:true})
//手动执行副作用函数
effectFn()
如果只是手动执行,那并没什么意义,但我们把传递的effect函数看做一个getter,那这个getter函数可以返回任何值
const effectFn = effect(
()=> { obj.foo + obj.bar},
{lazy:true}
)
这样我们在手动执行副作用函数时,就能够拿到其返回值:
const effectFn = effect(
()=> { obj.foo + obj.bar},
{lazy:true}
)
// value是getter的返回值
const value = effectFn()
为了实现这个目标,我们需要再对 effect 函数做一些修改,如 以下代码所示:
function effect(fn, options = {}) {
const effectFn = () => {
const res = fn() //新增
]
return res
}
// 将 options 挂在到 effectFn 上
effectFn.options = options
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
if (!options.lazy) {
effectFn()
}
return effectFn
}
通过新增的代码可以看到,传递给 effect 函数的参数 fn 才是真正的副作用函数,而 effectFn 是我们包装后的副作用函数。为了通 过 effectFn 得到真正的副作用函数 fn 的执行结果,我们需要将其 保存到 res 变量中,然后将其作为 effectFn 函数的返回值。
现在我们已经能够实现懒执行的副作用函数,并且能够拿到副 用函数的执行结果了,接下来就可以实现计算属性了,如下所示:
function computed(getter) {
const effectFn = effect(getter, {
lazy: true,
})
const obj = {
get value() {
if (dirty) {
return effectFn()
}
}
}
return obj
}
首先我们定义一个 computed 函数,它接收一个 getter 函数作 为参数,我们把 getter 函数作为副作用函数,用它创建一个 lazy 的 effect。computed 函数的执行会返回一个对象,该对象的 value 属性是一个访问器属性,只有当读取 value 的值时,才会执行 effectFn 并将其结果作为返回值返回。
我们可以使用 computed 函数来创建一个计算属性:
const data = {foo:1,bar:2}
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value) // 3
可以看到它能够正确地工作。不过现在我们实现的计算属性只做 到了懒计算,也就是说,只有当你真正读取 sumRes.value 的值时, 它才会进行计算并得到值。但是还做不到对值进行缓存,即假如我们 多次访问 sumRes.value 的值,会导致 effectFn 进行多次计算, 即使 obj.foo 和 obj.bar 的值本身并没有变化:
为了解决这个问题,就需要我们再实现computed函数时,添加对值进行缓存的功能
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
})
const obj = {
// 只有脏才计算值,并将得到的缓存到value中
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
return obj
}
我们新增了两个变量 value 和 dirty,其中 value 用来缓存上 一次计算的值,而 dirty 是一个标识,代表是否需要重新计算。当我 们通过 sumRes.value 访问值时,只有当 dirty 为 true 时才会调 用 effectFn 重新计算值,否则直接使用上一次缓存在 value 中的 值。这样无论我们访问多少次 sumRes.value,都只会在第一次访问 时进行真正的计算,后续访问都会直接读取缓存的 value 值。
如果此时我们修改 obj.foo 或 obj.bar 的值,再访问 sumRes.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) //
这是因为,当第一次访问 sumRes.value 的值后,变量 dirty 会设置为 false,代表不需要计算。即使我们修改了 obj.foo 的 值,但只要 dirty 的值为 false,就不会重新计算,所以导致我们得 到了错误的值。
解决办法很简单,当 obj.foo 或 obj.bar 的值发生变化时,只 要 dirty 的值重置为 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(sumRes.value)
})
// 修改 obj.foo 的值
obj.foo++
如以上代码所示,sumRes 是一个计算属性,并且在另一个 effect 的副作用函数中读取了 sumRes.value 的值。如果此时修改 obj.foo 的值,我们期望副作用函数重新执行,就像我们在 Vue.js 的 模板中读取计算属性值的时候,一旦计算属性发生变化就会触发重新渲染一样。但是如果尝试运行上面这段代码,会发现修改 obj.foo 的 值并不会触发副作用函数的渲染,因此我们说这是一个缺陷。
分析问题的原因,我们发现,从本质上看这就是一个典型的 effect 嵌套。一个计算属性内部拥有自己的 effect,并且它是懒执 行的,只有当真正读取计算属性的值时才会执行。对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把 computed 内部 的 effect 收集为依赖。而当把计算属性用于另外一个 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(obj, 'value') //新增
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
track(obj, 'value') //新增
return value
}
}
如以上代码所示,当读取一个计算属性的 value 值时,我们手动 调用 track 函数,把计算属性返回的对象 obj 作为 target,同时作 为第一个参数传递给 track 函数。当计算属性所依赖的响应式数据变 化时,会执行调度器函数,在调度器函数内手动调用 trigger 函数触 发响应即可。这时,对于如下代码来说:
effect(function effectFn() {
console.log(sumRes.value)
})
它会建立这样的联系:
computed(obj)
└── value
└── effectFn