今天我們要在保持既有鏈表架構不變的前提下,來實作 computed 的惰性計算 + 快取(dirty 旗標)與調度邏輯。
範例演示
1<!DOCTYPE html>2<html lang="en">3
4<head>5 <meta charset="UTF-8" />6 <title>Document</title>7 <style>8 body {9 padding: 150px;10 }11 </style>12</head>13
14<body>15 <div id="app"></div>22 collapsed lines
16 <script type="module">17 import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'18 // import { ref, computed, effect } from '../dist/reactivity.esm.js'19
20 const count = ref(0)21
22 const c = computed(() => {23 return count.value + 124 })25
26 effect(() => {27 console.log(c.value)28 })29
30 setTimeout(() => {31 console.log(count.value)32 }, 1000)33
34 </script>35</body>36
37</html>
先看官方程式碼的效果:
可以看到控制台,它會先輸出1
,再輸出2
,中間的 computed 只在需要時重算(惰性)。
執行順序是:
初始化
- 初始化變數:
count
、c
- 初始化
effect
,立刻執行console.log(c.value)
- 收集 computed 依賴,觸發計算函式
() => count.value + 1
。 - 讀取了
count.value
,函式回傳0 + 1
,結果是1
,輸出1
。
一秒之後
count.value = 1
被執行。- Vue 偵測到
count
的值從0
變成了1
。 - 當
count.value
被修改時,它會通知所有訂閱它的對象,這邊包含c
。 c
接收到通知後,重新計算自己的值,並接著通知所有訂閱c
的對象(也就是effect
),最終觸發effect
的重新執行。effect
收到通知,於是自動重新執行它內部的函式:() => console.log(c.value)
。- effect 它再次讀取
c.value
。 - 重新執行計算函式
() => count.value + 1
。 - 此時,
count.value
的值已經是1
。 c
計算出的新值為1 + 1 = 2
,輸出2
。
- effect 它再次讀取
那我們可以看這個過程中,computed 在這當中扮演的角色如下圖。
設計核心
首先computed
具有雙重角色:
- 訂閱者 (Sub),它會收集其執行函式(getter)中所訪問到的所有響應式依賴。
- **依賴項 (Dep),**當
effect
訪問computed
的.value
時,computed
會將這個effect
收集起來,建立關聯。 - computed 接收有可能是函式,也有可能是一個物件。
- 判斷是否為函式,函式有 getter
- 判斷是否為物件,物件有傳入的 getter 和 setter
怎麼樣是一個 Sub?怎麼樣是一個 Dep?可以查看之前定義的 interface。
1/**2 * 依賴項3 */4export interface Dependency {5 // 訂閱者鏈表頭節點6 subs: Link | undefined7 // 訂閱者鏈表尾節點8 subsTail: Link | undefined9}10/**11 * 訂閱者12 */13export interface Sub {14 // 訂閱者鏈表頭節點15 deps: Link | undefined5 collapsed lines
16 // 訂閱者鏈表尾節點17 depsTail: Link | undefined18 // 是否正在收集依賴19 tracking: boolean20}
Sub
- 有
deps
頭節點 - 有
depsTail
尾節點 - 有是否正在收集依賴的標記
Dep
- 有
subs
頭節點 - 有
subsTail
尾節點 - 一定是響應式,會是 ref 或是 reactive
實作
我們先在 @vue/shared
,新增一個函式判斷式。
1export function isFunction(value) {2 return typeof value === 'function'3}
由於 computed 接收有可能是函式,也有可能是一個物件,所以我們新增一個 computed.ts
,導出一個 computed 函式,來判斷它是物件還是函式。
- 傳入是函式:
- 表示只有 getter(computed 唯讀)
- 傳入是物件:
- 表示有 getter 跟 setter
1export function computed(getterOptions) {2 let getter3 let setter4 if (isFunction(getterOptions)) {5 getter = getterOptions6 } else {7 getter = getterOptions.get8 setter = getterOptions.set9 }10
11 // ComputedRefImpl 是 computed 實際的響應式實作類別,再將 getter 跟 setter 傳入12 return new ComputedRefImpl(getter, setter)13}
接著讓我們再來實作 ComputedRefImpl
類別,把 Dep 和 Sub 所需要的屬性加入:
1class ComputedRefImpl implements Dependency, Sub {2 // computed 是 ref,所以他會有這個標誌,通過 isRef 也回傳 true3 [ReactiveFlags.IS_REF] = true4
5 // 保存 fn 返回值6 _value7
8 // 如果是 Dep,要關聯 Subs,觸發更新要通知執行 fn9 subs: Link10 subsTail: Link11
12 // 如果是 Sub,要知道哪些 Dep 被收集13 deps: Link14 depsTail: Link15 tracking = false22 collapsed lines
16
17 constructor(18 public fn, //getter,但原始碼是fn,為了保持跟 effect 一致19 private setter20 ) { }21 get value() {22 this.update()23 return this._value24 }25 set value(newValue) {26 // 如果他有傳入 setter,表示是物件傳入27 if (this.setter) {28 this.setter(newValue)29 } else {30 console.warn('computed is readonly')31 }32 }33
34 update(){35 this._value = this.fn()36 }37}
我們執行這段程式碼,表面上看,它似乎能正確計算出結果:
但其實目前 get value()
每次讀取都直接 update()
,都沒有導入快取/dirty 與,在多次讀值或多個 effect 下會一直重複計算。
我們剛剛提到 computed 他有雙重角色,那麼我們要如何讓 computed
同時做 Dep
和 Sub
的角色呢?
回顧我們先前的邏輯,就可以知道:
當 Computed 作為 Dep
我們先在 get value()
裡建立與當前 activeSub 的關聯(link(this, activeSub)
),同時改成只有在 dirty 時才 update,避免每次讀值都重算。
1class ComputedRefImpl implements Dependency, Sub {2 ...3 ...4 get value() {5 this.update()6 if(activeSub){7 link(this,activeSub)8 }9 console.log('computed',this)10 return this._value11 }12 ...13 ...14}
接下來 console 看看是不是正常收集到 Fn
看來有正確儲存 fn ,表示我們建立好關聯關係。
我們現在已經完成下方紅色區塊連結的地方:
當 Computed 作為 Sub
我們需要在 fn 執行期間,收集訪問的響應式,因此我們看一下之前寫的 effect 的邏輯。
computed 的 getter
執行時仍需收集依賴。沿用先前的 setActiveSub
/ startTrack
/ endTrack
機制,不需要改寫 effect 架構。
我們只在 ComputedRefImpl.update()
內部包一層收集區段就好。
1export function setActiveSub(sub) {2 activeSub = sub3}4
5export class ReactiveEffect {6...7run() {8 const prevSub = activeSub9 setActiveSub(this)10 startTrack(this)11
12 try {13
14 return this.fn()15
8 collapsed lines
16 } finally {17 endTrack(this)18 setActiveSub(prevSub)19 }20 }21...22...23}
我們透過 setActiveSub
來重新賦值給 activeSub
變數,再引入 computed.ts
1import { activeSub, setActiveSub } from './effect'2...3...4update(){5
6 // 為了在 fn 執行期間,收集訪問的響應式7 const prevSub = activeSub8 setActiveSub(this)9 startTrack(this)10
11 try {12 this._value = this.fn()13
14 } finally {15 endTrack(this)6 collapsed lines
16 setActiveSub(prevSub)17 console.log(this)18 }19 }20...21...
在 console 控制台上,我們可以看到 dep 也被成功儲存。
這樣看來,下方紅色圈起來的地方也已經完成。
報錯
但你應該還會發現有一個錯誤。
原因是 Ref
在 setTimeout 觸發更新會執行 setter
1...2...3set value(newValue) {4 if(hasChanged(newValue, this._value)){5 this._value = isObject(newValue) ? reactive(newValue) : newValue6 triggerRef(this)7 }8}9...
然而執行到propagate
函式
1export function propagate(subs) {2 let link = subs3 let queuedEffect = []4
5 while (link) {6 const sub = link.sub7
8 // 只有不在執行中的才加入隊列9 if(!sub.tracking){10 queuedEffect.push(sub)11 }12 link = link.nextSub13 }14
15 queuedEffect.forEach(effect =>effect.notify())1 collapsed line
16}
propagate
函式預期所有 sub
都有一個 run()
方法,但我們的 ComputedRefImpl
類別沒有這個方法。
我們目前已經分別完成兩部份的鏈表,分別是:
- 讓
computed
成為count
的訂閱者 (Sub) - 讓
computed
成為effect
的依賴項目 (Dep)
現在,我們需要將這兩段依賴鏈路串接起來,形成完整的更新流程。
解決問題
執行觸發更新時:
- ref 觸發更新
- 通過 Sub 找到 computed
- computed 執行更新
- computed 再通過 computed 本身的 sub 鏈表
- 找到所有的 sub 重新執行
因此我們現在要做的就是:
- 處理 computed 更新
- 讓 computed 通過 sub 鏈表,通知其他 sub 更新。
還記得我們原本在 computed 怎麼執行更新?
之前我們在 ComputedRefImpl
中已經定義了 update
方法,可以用它來更新 computed 的值。
1export function processComputedUpdate(sub) {2 // 通知 computed 更新3 sub.update()4 // 通知 sub 鏈表的其他 sub 更新5 propagate(sub.subs)6}7
8export function propagate(subs) {9 let link = subs10 let queuedEffect = []11
12 while (link) {13 const sub = link.sub14
15 if(!sub.tracking){12 collapsed lines
16 // 如果 link.sub有 update 方法,表是傳入的是 computed17 if('update' in sub){18 processComputedUpdate(sub)19 }else{20 queuedEffect.push(sub)21 }22 }23 link = link.nextSub24 }25
26 queuedEffect.forEach(effect =>effect.notify())27}
所以我們可以透過傳入的 sub 是否有 update
方法來判斷他是不是 computed,如果傳入的是 computed,那除了觸發更新函式之外,還需要通知 sub 鏈表上的所有 sub 更新。
我們執行這段程式碼,表面上看,它似乎能正確計算出結果:
但如果 index.html
你這樣寫:
1const count = ref(0)2 const c = computed(() => {3 console.count('computed')4 return count.value + 15})6
7effect(() => {8 console.log(c.value)9})10
11setTimeout(() => {12 count.value = 113}, 1000)
你會發現它其實是觸發三次。
如果用官方的範例,發現它其實執行兩次而已。
這個問題的根源在於 get value()
的實作:每次訪問 .value
都會直接觸發 update()
方法,因此完全沒有實現緩存。
1 get value() {2 this.update()3 ...4 ...5 }
今天我們加上快取與 dirty
,並以 notify()
充當調度器:上游變更只標髒、下游讀取才重算。下篇我們再補上更進一步的同一 tick 多次讀值只算一次、以及多層 computed 鏈的範例,確認效能與語意
computed
完整程式碼:
1import { ReactiveFlags } from './ref'2import { Dependency, Sub, Link, link, startTrack, endTrack } from './system'3import { isFunction } from '@vue/shared'4import { activeSub, setActiveSub } from './effect'5
6class ComputedRefImpl implements Dependency, Sub {7 // computed 是 ref,所以他會有這個標誌,通過 isRef 也回傳 true8 [ReactiveFlags.IS_REF] = true9 // 保存 fn 返回值10 _value11 // 如果是 Dep,要關聯 Subs,觸發更新要通知執行 fn12 subs: Link13 subsTail: Link14
15 // 如果是 Sub,要知道哪些 Dep 被收集57 collapsed lines
16 deps: Link17 depsTail: Link18 tracking = false19 constructor(20 public fn, //getter,源碼是fn,保持跟 effect 一致21 private setter22 ) { }23 get value() {24 this.update()25
26 if(activeSub){27 link(this,activeSub)28 }29 return this._value30 }31 set value(newValue) {32 if (this.setter) {33 this.setter(newValue)34 } else {35 console.warn('computed is readonly')36 }37 }38
39 update(){40 /**41 * 收集依賴42 * 為了在 fn 執行期間,收集訪問的響應式43 */44
45 const prevSub = activeSub46 setActiveSub(this)47 startTrack(this)48
49 try {50
51 this._value = this.fn()52
53 } finally {54 endTrack(this)55 setActiveSub(prevSub)56 }57 }58}59
60export function computed(getterOptions) {61 let getter62 let setter63 if (isFunction(getterOptions)) {64 getter = getterOptions65 } else {66 // 傳入是物件,物件有 get 和 set67 getter = getterOptions.get68 setter = getterOptions.set69 }70
71 return new ComputedRefImpl(getter, setter)72}