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 : } }const obj = computed (() => { 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
函数从 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 () } return 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
),这并不“懒”。但通过调度器,跳过了
trigger
中 fn
的执行,仅仅设置了
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
的实现中通过手动调用
track
和 trigger
添加关联:
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' ) } }); const obj = { get value () { if (dirty) { cache = effectFn () dirty = false } track (obj, 'value' ) 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 ) => { 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 (); } return 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 .