前言
计算属性一直是 Vuejs 被认为设计最为巧妙的特性之一
它允许开发者封装复杂的表达式,返回计算后的结果,实现数据分层,提高视图更新时的性能与代码可读性
import { ref, computed } from "vue";
const number = ref(1);
const addOne = computed(() => number.value + 1);
const addTwo = computed(() => addOne.value + 2);
const multiplyThree = computed(() => addTwo.value * 2);
可以说计算属性是 Vuejs 版本的“纯函数”
- 没有副作用:计算属性为一个或多个表达式的封装,不会造成依赖以外的数据修改(实际也可以在 getter 里发送请求、改变 DOM,但是不推荐)
- 无法被“修改”:只要计算属性的依赖不变,计算属性的值就不会改变,即使重复访问计算属性,也不会多次触发计算
本文避开晦涩的源码,用简单的代码实现计算属性,帮助大家更多从设计层面理解计算属性
effect
分析计算属性前,必须要了解 effect
effect 在官方文档中没有记载,但它撑起了响应式系统的半壁江山,可以从官网中找到一个类似的 watchEffect
import { ref, watchEffect } from "vue";
const number = ref(1);
watchEffect(() => console.log(number.value)); // -> logs 1
number.value++; // -> logs 2
watchEffect 的第一个参数是函数,当 watchEffect 执行时会立即执行该函数,同时响应地跟踪它的依赖关系,并在依赖关系发生变化时重新运行它
上述例子中,当响应式对象 number 更新时,console.log 会重新执行
如果把 watchEffect 换成 effect,上述代码同样成立
import { ref, effect } from "vue";
const number = ref(1);
effect(() => console.log(number.value)); // -> logs 1
number.value++; // -> logs 2
因此得出一个初步结论: effect 和 watchEffect 功能相似,接收一个函数并立即运行,同时响应地跟踪它的依赖关系
function effect(fn) {
fn();
return fn;
}
实际在 fn 执行时,还会跟踪响应式对象的依赖关系,本文跳过了这部分实现,将重点放在计算属性本身
了解了 effect 的基本作用,回头看计算属性的参数,是不是与 effect 有些许相似?
import { ref, effect, computed } from "vue";
const number = ref(1);
effect(() => console.log(number.value)); // -> logs 1
const addOne = computed(() => number.value + 1);
number.value++; // -> logs 2
console.log(addOne.value); // -> logs 3
两者都接收一个函数作为参数,当响应式对象 number 更新时,console.log 会重新执行,计算属性的值也会更新,两者区别在于
- 计算属性的返回值是计算的结果,只有访问计算属性时才执行函数
- effect 一般不需要声明返回值,并且会立即执行函数
事实上,计算属性就是 Vuejs 基于 effect 扩展而生的能力
除了继承自 effect 本身的依赖追踪能力,计算属性还有两个特点
- 延迟计算
- 缓存结果
延迟计算
计算属性初始化时不会触发计算,只有在访问时才触发计算
import { ref, computed } from "vue";
const number = ref(1);
const addOne = computed(() => {
console.log("run computed");
return number.value + 1;
});
number.value++;
// console.log(addOne.value);
改造一下案例,在计算属性的 getter 中添加一行 console.log ,以此判断是否触发了计算
接着注释最后一行,查看控制台输出
没有任何日志,意味着没有触发计算。随后去掉注释
控制台打印了 run computed,以此验证计算属性只有在访问时才触发计算。这一点很容易理解,没有必要给未使用的变量定义值,节省不必要的计算
为了实现延迟计算的能力,需要改造下上一章节实现的 effect,首先避免函数立即执行,添加一个 lazy 选项
function effect(fn, options = {}) {
if (!options.lazy) {
fn();
}
return fn;
}
当 options.lazy = true 时,允许手动控制 effect 的执行时机,延迟计算就完成了一半
<template>
<button @click="addOne">{{ number }}</button>
</template>
<script setup>
import { ref, effect } from "vue";
const number = ref(1);
const addOne = effect(
() => {
number.value = number.value + 1;
},
{ lazy: true }
);
</script>
接着实现计算属性本身,由于计算属性的返回值就是 getter 的返回值,且只有在访问计算属性时才触发重新计算,很容易联想到通过访问器属性实现
function computed(getter) {
const effectFn = effect(getter, { lazy: true });
const computedValue = {
get value() {
return effectFn();
},
};
return computedValue;
}
访问 value 属性 -> 触发访问器属性 -> 手动触发 effect -> 执行 getter
至此实现了完整的延迟计算能力
缓存结果
计算属性在第一次触发计算后会缓存计算结果
如果依赖不变,多次访问计算属性始终返回第一次计算的缓存
import { ref, computed } from "vue";
const number = ref(1);
const addOne = computed(() => {
console.log("run computed");
return number.value + 1;
});
number.value++;
console.log(addOne.value);
console.log(addOne.value);
第一次访问计算属性时,控制台打印了 run computed,第二次访问时控制台没有打印日志,却得到同样的结果
证明从第二次访问开始不会触发计算,使用的都是第一次的缓存。对大量数据(例如表格,feed 流)更新时,合理运用计算属性可以有效避免计算资源浪费
实现缓存能力非常简单,利用闭包存储缓存
function computed(getter) {
+ let dirty = true
+ let value
const effectFn = effect(getter, { lazy: true });
const computedValue = {
get value() {
+ if(dirty){
+ value = effectFn()
+ dirty = false
+ }
return value
},
};
return computedValue;
}
添加一个 dirty 的标志,默认 true,当第一次访问计算属性时进行计算,拿到结果后缓存起来,并将 dirty 变成 false
后续访问计算属性时,由于 dirty 为 false,跳过计算返回缓存即可
到这里只能算完成一半。当计算属性的依赖改变时,还需要清空缓存重新计算
我们知道,依赖改变会重新触发 effect 的执行,但 effect 在初始化时就已经被定义,无法修改,那么有办法介入 effect 执行的过程,将计算属性中的 dirty 变成 true 呢?
我们进一步改造 effect 的实现
function effect(fn, options = {}) {
if (!options.lazy) {
fn();
}
+ if (options.scheduler) {
+ fn.scheduler = options.scheduler;
+ }
return fn;
}
传入一个 scheduler (调度器)配置项,一旦 effect 初始化时声明了 scheduler 且 effect 的依赖改变时,则将 fn 执行的时机、次数以及方式完全交给 scheduler 处理
具体依赖更新时是如何触发 effect 的,涉及到 Vuejs 响应式原理的更新逻辑,由于篇幅有限本文不做深入探讨,这里假设 effect 能够识别触发的类型,分别执行 fn、scheduler
这样当计算属性的依赖改变时,让 scheduler 代替 fn 执行,停止使用计算属性缓存,并使下次访问计算属性时,重新触发计算
function computed(getter) {
let dirty = true;
let value;
const effectFn = effect(getter, {
lazy: true,
+ scheduler(fn) {
+ if (!dirty) {
+ dirty = true;
+ }
+ },
});
const computedValue = {
get value() {
if (dirty) {
value = effectFn();
dirty = false;
}
return value;
},
};
return computedValue;
}
通知更新
实现了缓存功能后,剩下最后一个问题
前面说道,计算属性拥有延迟计算的特点,即使计算属性的依赖更新了,但只有访问计算属性时才会触发重新计算
import { ref, computed, effect } from "vue";
const number = ref(1);
const addOne = computed(() => number.value + 1);
effect(() => console.log(addOne.value)); // -> logs 2
number.value++; // -> logs 3
上述例子中,当 number.value++ 时,effect 会重新执行,依次打印 2, 3。但如果换成上一章节实现的计算属性
import { ref, effect } from "vue";
function computed(getter) {
let dirty = true;
let value;
const effectFn = effect(getter, {
lazy: true,
scheduler(fn) {
dirty = true;
},
});
const computedValue = {
get value() {
if (dirty) {
value = effectFn();
dirty = false;
}
return value;
},
};
return computedValue;
}
const number = ref(1);
const addOne = computed(() => number.value + 1);
effect(() => console.log(addOne.value)); // -> logs 2
number.value++;
控制台没有打印数字 3,换句话说当 number.value++ 时,并没有使 effect 重新执行
解决方法是将计算属性与 effect 建立“联系”,当 effect 执行时,一旦访问了计算属性,计算属性需要保存此时的 effect。另一方面当计算属性的依赖更新时,需要取出 effect 并重新执行
+ let activeEffect;
function effect(fn, options = {}) {
+ activeEffect = fn;
if (!options.lazy) {
fn();
}
if (options.scheduler) {
fn.scheduler = options.scheduler;
}
return fn;
}
function computed(getter) {
let dirty = true;
let value;
+ let deps = new Set()
const effectFn = effect(getter, {
lazy: true,
scheduler(fn) {
if (!dirty) {
dirty = true;
+ deps.forEach(effect => effect());
}
},
});
const computedValue = {
get value() {
if (dirty) {
value = effectFn();
dirty = false;
}
+ deps.add(effect);
return value;
},
};
return computedValue;
}
通过 activeEffect 变量记录当前正在运行的 effect
运行 effect 过程中,一旦访问了计算属性,就将其存储在访问的计算属性内部的 deps 中,由于可能会有多个 effects 访问同一个计算属性,因此 deps 是一个数组
当计算属性的依赖更新时,触发 scheduler,取出 deps 并依次执行,让 effets 重新访问计算属性并执行新一轮的计算
总结
计算属性有两个特点
- 延迟计算:初始化时不会触发计算,只有在访问时才触发计算
- 缓存结果:依赖不变,多次访问计算属性始终返回第一次计算的缓存
Vuejs 通过访问器属性实现延迟计算,当访问计算属性的 value 时再触发计算
Vuejs 通过闭包实现缓存结果,当依赖更新时,通过特殊的 scheduler 清空缓存并通知存储的 effects 重新访问计算属性
附上一个可用的计算属性案例(依赖标准的 effect 实现)
import { ref, effect } from "vue";
const effectStack = [];
const customEffect = (fn, options) => {
effectStack.push(fn);
const res = effect(fn, options);
effectStack.pop();
return res;
};
function computed(getter) {
let dirty = true;
let value;
let deps = new Set();
const effectFn = customEffect(getter, {
lazy: true,
scheduler(fn) {
if (!dirty) {
dirty = true;
deps.forEach((effect) => effect());
}
},
});
const computedValue = {
get value() {
if (dirty) {
value = effectFn();
dirty = false;
}
const activeEffect = effectStack[effectStack.length - 1];
if (activeEffect) {
deps.add(activeEffect);
}
return value;
},
};
return computedValue;
}
const number = ref(1);
const addOne = computed(() => number.value + 1);
customEffect(() => {
console.log(addOne.value);
}); // -> logs 2
number.value++; // -> logs 3