Vue3 响应式(3)-计算属性 computed

上文Vue3 响应式(2)-分支切换、嵌套、和无限递归解决了原有响应式系统的一些问题,实现了较为完善的数据绑定。本文在此基础上实现简单的计算属性功能 computed

调度器 Scheduler

在实现计算属性前,先介绍一下调度器 Scheduler,它将在实现计算属性和监听等特性时使用。

在原有的实现中,每次响应式数据发生变化时都会自动的调用副作用函数。但有时候我们可能希望控制副作用函数的调用时机,或者在调用前后做一些其他的事情,而不是让副作用函数直接执行。

通过之前的代码我们知道,副作用函数的执行是在 trigger 方法中的以下片段:

1
2
3
4
5
6
function trigger(target, key) {
// ...
effectToRun.forEach(fn => {
if (fn !== activeEffect) fn()
})
}

如果想要控制副作用函数的执行实际,则只需要传入一个回调函数,在上述代码片段中不直接执行 fn,而是把 fn 作为参数传给回调函数。具体如何执行副作用函数就可以通过传入不同的回调函数来决定了。这里可以通过给副作用函数绑定一个 scheduler 参数来实现,代码改动如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function effect(fn, options={}) {
const effectFn = () => {
//...
}
effectFn.depSets = []
effectFn.options = options
effectFn()
}

function trigger(target, key) {
// ...
effectToRun.forEach(fn => {
if (fn === activeEffect) return
if (fn.options.scheduler) {
// 如果设置了调度器,则将副作用函数的执行交给调度器
fn.options.scheduler(fn)
} else {
// 否则默认直接执行副作用函数
fn()
}
})
}

计算属性 computed

computed 的设计目标

我们希望设计这样的一个函数 computed,该函数接受一个函数作为参数 (记为 getter),并返回一个对象 (记作 obj),该对象的值由 getter 计算获得。每当 getter 中所依赖的数据发生变化时,obj.value 都能被重新计算获得更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
function computed(getter) {
// ...
return {
value: // computed by getter
}
}

const obj = computed(() => {
// ... depends on some value
return res
})

console.log(obj.value)

并且在使用 Vue 的过程中我们还会发现,当计算属性被定义后而没有被使用前,计算属性所依赖的变量变化是不会被触发计算的,即“懒”执行。因此我们总结设计的 computed 要达到以下目标:

  • 响应式,当所依赖的响应式数据变化时能够更新状态
  • 返回一个由 getter 计算获得的值
  • “懒”执行,只有 obj.value 被读取时才会触发计算
  • 缓存,当计算结果不变时,computed 能够使用缓存值以减少重复的计算

返回值的实现

首先对于响应式的要求,我们之前实现的 effect 函数完全可以达到目的,直接调用 effect(getter) 就可以把 getter 中的依赖与 getter 绑定起来,每当依赖发生变化,getter 就会被重新执行。但问题在于无法拿到 getter 返回的值,为了达到这一目的可以对原有的 effect 做如下改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function effect(fn, options={}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)

const res = fn() // 获取副作用函数的结果

effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]

return res // 返回副作用函数结果
}
effectFn.depSets = []
effectFn.options = options
effectFn()
return effectFn // 返回 effectFn
}

通过以上的改动,让 effectFn 函数从 effect 中返回,那么就可以从外部调用 effectFn 了,并且任何时候调用 effectFn 获得的返回值都是根据最新的依赖所计算的。

lazy 懒执行的实现

增加 options.lazy 来控制是否在调用 effect 时立即执行 effectFn,非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function effect(fn, options={}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)

const res = fn() // 获取副作用函数的结果

effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]

return res // 返回副作用函数结果
}
effectFn.depSets = []
effectFn.options = options
if (!options.lazy) {
effectFn() // 立即执行一次 effectFn 建立绑定
}
return effectFn // 返回 effectFn
}

有了 lazy: true 选项,当我们不调用 effect 返回的 effectFn 时,effectFn 永远不会执行,并且也不会发生数据的绑定。这样无论计算属性所依赖的值发生多少次变化都不会触发计算属性。

但是,这里的“懒执行”其实仍然有一个问题 (不够“懒”),我将在下面介绍并解决它。

包装 computed

有了以上基础,包装计算属性就顺理成章了。我们只需要在每次读取 obj.value 时去执行一下 effectFn 并把其返回值作为 obj.value 返回给用户就可以了。这样自然而然想到使用对象的 get 方法。

1
2
3
4
5
6
7
8
function computed(getter) {
const effectFn = effect(getter, { lazy: true });
return {
get value() {
return effectFn();
}
};
}

缓存的实现

当我们多次读取 obj.value 时,即使依赖没有发生变化,还是会反复调用 effectFn 中的副作用函数 fn 来重新计算获得相同的结果。这显然是对性能的浪费。因此需要对其实现缓存的功能。缓存的实现也非常简单,在 computed 中缓存计算结果并设置标记,而在依赖发生变化时重置标记。当有标记时重新计算更新缓存,否则直接返回缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function computed(getter) {
let dirty = true
let cache // 缓存计算结果
const effectFn = effect(getter, { lazy: true });
return {
get value() {
if (dirty) {
// 需要重新计算
cache = effectFn() // 更新缓存
dirty = false // 设置标记
}
return cache;
}
};
}

但以上的修改只完成了“设置标记”的工作,还应该在依赖发生变化即在 trigger 中调用 effectFn 时重置标记。此时调度器 Scheduler 就派上了用场:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function computed(getter) {
let dirty = true // 标记是否需要从新计算
let cache // 缓存计算结果
const effectFn = effect(getter, {
lazy: true,
scheduler: () => {
dirty = true
}
});
return {
get value() {
if (dirty) {
cache = effectFn()
dirty = false
}
return cache;
}
};
}

通过调度器就完成了“清除标记”的工作。同时它还解决了“懒执行”中的遗留问题:在原来的代码中,一旦计算属性的依赖发生,无论有没有读取计算属性的值,都会触发它所关联的副作用函数 (即 getter),这并不“懒”。但通过调度器,跳过了 triggerfn 的执行,仅仅设置了 dirty 标记。因此依赖发生变化时,并不会执行副作用函数,除非真正的读取计算属性的值,真正实现了“懒”执行。

最后一个问题

当我们使用 effect 包裹一个依赖于计算属性的副作用函数时,当然希望当计算属性发生变化时会触发副作用函数。这就像使用 Vue 时把计算属性渲染到模板中,当计算属性发生变化时更新模板中的值。例如:

1
2
3
4
5
6
7
8
const data = reactive({ foo: 1, bar: 2})
const sumRes = computed(() => data.foo + foo.bar)

effect(() => {
document.body.innerText = sumRes.value
})

data.foo = 100

当把 data.foo 的值更新为 100 时,我们希望更新页面文本的副作用函数会被调用,从而重新计算 sumRes.value 把最新的值 202 更新到页面。但实际上这并没有发生。这是因为在定义计算属性时只会将 data.foo, foo.bar 与包装 getter 的副作用函数关联,而不会与包裹计算属性的外层副作用函数产生关联。这和我们在上一篇文章中介绍的嵌套的 effect 是一样的,即内部的副作用函数里依赖的变量发生变化,只会触发内部副作用函数而不会触发外部副作用函数。但在计算属性的需求下,我们需要它来触发外部的副作用函数。

为了修复上面的问题,需要在 computed 的实现中通过手动调用 tracktrigger 添加关联:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function computed(getter) {
let dirty = true // 标记是否需要从新计算
let cache // 缓存计算结果
const effectFn = effect(getter, {
lazy: true,
scheduler: () => {
dirty = false
trigger(obj, 'value') // 2.发生变化时触发副作用函数
}
});
const obj = {
get value() {
if (dirty) {
cache = effectFn()
dirty = false
}
track(obj, 'value') // 1.读取值时添加关联
return cache;
}
};
return obj
}

当计算属性出现在 effect 内部时,此时 activeEffect 的值则为当前的副作用函数,因此读取计算属性的值时会调用 track 把计算属性的 value 与当前的副作用函数关联起来。自然地,当计算属性的值更新时,则会调用 trigger 把相关的副作用函数执行。

小结

本文主要介绍了调度器和计算属性的实现,其中计算属性涉及“懒”执行和缓存等问题。最终的代码实现如下。可以点击这里在线运行测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
console.log("code: Vue3_reactive_3_computed");

let activeEffect;
const effectStack = [];
const bucket = new WeakMap();

const data = reactive({ foo: 1, bar: 2 });
const sumRes = computed(() => data.foo + data.bar);

effect(() => {
document.body.innerText = sumRes.value;
});

setTimeout(() => {
data.foo = 100;
}, 2000);

function computed(getter) {
let dirty = true;
let cache;
const effectFn = effect(getter, {
lazy: true,
scheduler: () => {
dirty = true;
trigger(obj, "value");
}
});
const obj = {
get value() {
if (dirty) {
cache = effectFn();
dirty = false;
}
track(obj, "value");
return cache;
}
};
return obj;
}

function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key);
return Reflect.get(target, key);
},
set(target, key, value) {
const res = Reflect.set(target, key, value);
trigger(target, key);
return res;
}
});
}

function track(target, key) {
if (!activeEffect) return;
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
deps.add(activeEffect);
activeEffect.depSets.push(deps);
}

function trigger(target, key) {
let depsMap = bucket.get(target);
if (!depsMap) return;
let deps = depsMap.get(key);
if (!deps) return;
const depsToRun = new Set(deps);
// depsToRun.forEach(fn => fn())
depsToRun.forEach((fn) => {
if (fn === activeEffect) return;
if (fn.options.scheduler) {
// 如果设置了调度器,则将副作用函数的执行交给调度器
fn.options.scheduler(fn);
} else {
// 否则默认直接执行副作用函数
fn();
}
});
}

function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);

const res = fn(); // 获取副作用函数的结果

effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];

return res; // 返回副作用函数结果
};
effectFn.depSets = [];
effectFn.options = options;
if (!options.lazy) {
effectFn(); // 立即执行一次 effectFn 建立绑定
}
return effectFn; // 返回 effectFn
}

function cleanup(effectFn) {
effectFn.depSets.forEach((deps) => {
deps.delete(effectFn);
});
effectFn.depSets.length = 0;
}

参考资料

[1] vuejs/core: 🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.[EB/OL]. [2023-09-22]. https://github.com/vuejs/core.

[2] Vue.js - 渐进式 JavaScript 框架 | Vue.js[EB/OL]. [2023-09-22]. https://cn.vuejs.org/.

[3] 霍春阳. Vue.js设计与实现[M/OL]. 人民邮电出版社, 2022[2023-09-19]. https://book.douban.com/subject/35768338/.

[4] JavaScript | MDN[EB/OL]. 2023-04-10. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript.