在前文《Vue响应式原理(6)-调度器实现》中,我们实现了调度器。在先前实现的响应式系统基础上加上调度器的能力,我们就能尝试实现Vue中一项重要能力--计算属性。
1. 计算属性的懒执行实现
说到计算属性,不得不提他的一项重要特性--懒执行,即传递给 computed
的函数不会立即执行,而是会在读取变量值的时候执行。在先前的 effect
副作用注册函数中,在第一次传入副作用函数 fn
时就会执行 fn
,因此我们需要对这一部分进行改造。首先,我们可以在 effctFn
的 options
属性对象中新增 lazy
属性。如下所示:
js
effect(() => {
console.log(obj.foo)
},
// options
{
lazy: true
})
相应的,我们要对 effect
函数进行修改,来帮助完成 lazy
属性的相应功能:
js
function effect(fn, options) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = [];
effectFn.options = options;
// options.lazy决定是否立即执行副作用函数
if (!options.lazy) {
effectFn();
}
return effectFn;
}
主要的修改在于增加了一条判断语句,只有在 options.lazy
为 false
的时候才会立即执行effectFn
,否则会将 effectFn
返回,由用户自行决定执行时机。假设我们将 options.lazy
置为 true
,此时 effect
函数返回值为 effectFn
。我们可以手动调用执行,如下列代码所示:
js
const effectFn = effect(() => {
console.log(obj.foo)
},
// options
{
lazy: true
})
effectFn()
我们还需考虑到,计算属性中会传入 getter
函数,其返回值会作为计算属性的值,而目前我们还没有将真正执行的副作用函数 fn
的值返回,因此需要对 effect
函数继续进行改造:
js
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
// fn执行结果存储到res变量
const res = fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
// 返回副作用函数执行结果
return res
};
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = [];
effectFn.options = options;
// options.lazy决定是否立即执行副作用函数
if (!options.lazy) {
effectFn();
}
return effectFn;
至此,我们已具备了 computed
属性的实现基础,可以来完成一个基础的计算属性:
js
const computed = (getter) => {
const effectFn = effect(getter, {
lazy:true
})
const obj = {
// 读取 value 时才会执行副作用函数,获取结果
get value() {
return effectFn()
}
}
return obj
}
可以看到在 computed
函数中,我们传递一个 getter
函数作为参数;在 computed
内部我们用 effect
函数对 getter
进行注册,getter
中读取的响应式对象属性会完成依赖收集过程,和getter
建立联系;另外我们构建了一个对象,其 value
属性为访问器属性,只有我们读取 value
值时才会执行 getter
函数并获取到函数结果值。
2. 计算属性的缓存实现
计算属性的另一个重要特性就是它具有缓存 能力,在其依赖的对象属性没有发生变化的情况下,计算属性不会重新执行 getter
获取值,而是直接将缓存值返回,降低性能开销。目前我们的实现尚不支持这一能力,需要对代码进一步改造:
js
const computed = (getter) => {
// 缓存变量
let value;
// dirty 标志,用来标识是否需要重新计算
let dirty = true
const effectFn = effect(getter, {
lazy:true
})
const obj = {
// 读取 value 时才会执行副作用函数,获取结果
get value() {
if (dirty) {
value = effectFn()
// 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
dirty = false
}
return value
}
}
return obj
}
在改进的 computed
函数中,我们设置了名为 value
的变量,其作用是用于对 effectFn
函数的返回值进行缓存;另外设置了 dirty
变量,对计算属性是否需要重新计算进行标记,当 dirty
值为 true
时则表明需要重新执行 effectFn
来获取到新的结果值,当 dirty
值为 false
时则始终返回缓存结果值 value
。
那么接下来我们需要思考的问题就是:在什么时机可以把 dirty
的值值为 true
,表明数据"脏"了?很显然,当 computed
的 getter
函数中的响应式对象属性值发生变化时,则表明计算属性需要重新计算,也就是说应该在响应式数据发生变化时将 dirty
置为 false
;而响应式数据发生变化时就是触发响应的时候,此时前文所介绍的调度器就可以发挥作用,我们可以在调度器中 dirty
值置为 false
。代码如下:
js
const effectFn = effect(getter, {
lazy:true,
scheduler() {
dirty = true;
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return effectFn()
}
}
return obj
看下面这段代码:
js
const data = {foo: 1, bar: 2};
const obj = new Proxy(data, {/* ... */});
const sumRes = computed(() => {
return obj.foo + obj.bar;
})
obj.foo++;
console.log(sumRes.value);
当执行 obj.foo++
时,会触发 effectFn
的调度器执行,将 dirty
标志置为 true
;在下一行打印 sumRes.value
时对 dirty
进行判断,dirty
为 true
则会重新触发 effectFn
执行获取到更新后的值并将 dirty
值重置为 false
。
3. effect嵌套计算属性问题
至此我们已基本实现了计算属性的缓存和懒执行特性,但是仍然存在一个问题,观察下面这段代码:
js
const sumRes = computed(() => {
return obj.foo + obj.bar;
})
effect(() => {
console.log(sumRes.value);
})
obj.foo++;
我们在 effect
中的箭头函数内部读取了计算属性 sumRes.value
,随后我们让 obj.foo
自增,按照预期,我们希望箭头函数会重新调用,但是实际上并没有发生。其实这种 effect
嵌套计算属性的情况在 Vue
中比较常见,当我们在模板中读取一个计算属性的值时,就会存在嵌套的问题。
其实稍加思考我们就能清楚问题产生的原因,我们在 computed
函数内传递的 getter
函数通过 effect
进行了副作用函数注册,getter
与 响应式变量 obj
的 foo
和 bar
属性建立了依赖联系,但是 obj
并未与外层的箭头函数建立依赖联系。因此当 obj.foo
自增时只会触发 getter
函数触发执行,而不会让外层的箭头函数触发执行。
当了解了问题产生的原因之后,解决方案也就相应产生了。既然响应式对象 obj
和外层的副作用函数未产生依赖联系,我们就需要手动完成依赖收集过程建立计算属性和外层副作用函数的联系,同时在计算属性内部的响应式对象发生变化时手动调用依赖触发函数。
那么应该在何处调用 track
函数建立联系呢,很显然应该在读取计算属性 sumRes.value
时建立内部响应式变量 obj
和外层副作用函数的联系;而 trigger
函数的调用则应在响应式对象属性变化,触发依赖的时候调用。这时候又需要用到我们前文所说的调度器了,因为调度器就是在依赖触发的时机进行调用,此时我们通过手动调用 trigger
函数来触发依赖。根据这个思路,我们可以对 computed
函数的代码进行改造如下:
js
const computed = (getter) => {
// 缓存变量
let value;
// dirty 标志,用来标识是否需要重新计算
let dirty = true
const effectFn = effect(getter, {
lazy:true
scheduler() {
if (!dirty) {
dirty = true
trigger(obj, 'value')
}
}
})
const obj = {
// 读取 value 时才会执行副作用函数,获取结果
get value() {
if (dirty) {
value = effectFn()
// 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
dirty = false
}
track(obj, 'value')
return value
}
}
return obj
}
当读取一个计算属性的 value
值时,我们手动调用 track
函数,将 computed
内部返回的 obj
对象和对象的 value
属性作为参数传递给 track
。这时,对于如下代码来说:
js
effect(function effectFn() {
console.log(sumRes.value);
})
在 effectFn
中读取 sumRes.value
,此时会调用 track
函数收集依赖,建立的依赖关系如下:
js
computed(obj)
└── value
└── effectFn
而当计算属性所依赖的响应式数据变化时,会执行调度器函数,在调度器函数内手动调用 trigger
函数触发响应,此时会执行与计算属性返回值 obj.value
建立依赖联系的 effectFn
函数,这样就做到了当计算属性内部响应式对象变化时,能触发外层副作用函数执行。