在前文《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 函数,这样就做到了当计算属性内部响应式对象变化时,能触发外层副作用函数执行。