在上一篇文章中,我們提到將透過「緩存」的機制來解決 computed
在訪問時重複執行的問題。
在 Vue 3 的原始碼裡,computed
是靠一個「髒值標記(dirty flag)」來判斷需不需要重新計算的。
Computed 緩存解決方案
核心邏輯
在 computed 中記錄髒標記:當髒標記是 true,才需要進行更新;當髒標記是 false,則表示需要進行緩存。
1class ComputedRefImpl implements Dependency, Sub {2 ...3 ...4 tracking = false5
6 // 計算屬性是否需要重新計算,如果為 true,則重新計算7 dirty = true8
9 ...10 ...11 get value() {12 if(this.dirty){13 this.update()14 }15 ...17 collapsed lines
16 ...17 }18
19 update(){20 ...21 ...22 try {23
24 this._value = this.fn()25 // 調用 update 更新後,將 dirty 更改為 false26 this.dirty = false27 } finally {28 endTrack(this)29 setActiveSub(prevSub)30 }31 }32}
再回去看,現在已經有進行緩存,只執行兩次,可是我們又發現了另一個問題,如果你把 index.html
設定為以下:
1<body>2 <div id="app"></div>3 <script type="module">4 // import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'5 import { ref, computed, effect } from '../dist/reactivity.esm.js'6
7 const count = ref(0)8
9 const c = computed(() => {10 console.log('computed')11 return count.value + 112 })13
14 // effect(() => {15 // console.log(c.value)7 collapsed lines
16 // })17
18 console.log(c.value)19 count.value = 120
21 </script>22</body>
你會發現 count.value
數值變更之後,他還是訪問 computed
,但是依賴 computed
的數值被變更時,我們當下不一定會訪問 computed
。
查看一下官方程式碼,count.value
數值變更後,computed
沒有被訪問。
但是我們的版本,他又再訪問一次computed
:
遇到這個狀況我們可以怎麼做?我們可以做髒標記,等下次computed
被 effect 訪問再執行更新。
1...2...3export function processComputedUpdate(sub) {4 // 有 sub.subs(effect 鏈表的頭節點),再進行更新5 if(sub.subs){6 sub.update()7 propagate(sub.subs)8 }9}10
11export function propagate(subs) {12 let link = subs13 let queuedEffect = []14
19 collapsed lines
15 while (link) {16 const sub = link.sub17
18 if(!sub.tracking){19 if ('update' in sub) {20 // 被 effect 進行訪問,計算屬性需要重新計算21 sub.dirty = true22 processComputedUpdate(sub)23 } else {24 queuedEffect.push(sub)25 }26 }27 link = link.nextSub28 }29
30 queuedEffect.forEach(effect => effect.notify())31}32...33...
這樣就可以解決緩存的問題。可是我們又發現了新的問題。
Effect 重複執行問題
1<body>2 <div id="app"></div>3 <script type="module">4 // import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'5 import { ref, computed, effect } from '../dist/reactivity.esm.js'6
7 const count = ref(0)8
9 const c = computed(() => {10 console.log('computed')11 return count.value * 012 })13
14 effect(() => {15 console.log(c.value)8 collapsed lines
16 })17
18 setTimeout(() => {19 count.value = 120 }, 1000)21
22 </script>23</body>
現在我們發現,computed 執行兩次,這個沒什麼問題,有問題是 effect 的數值沒有改動,但是它也執行兩次,如果數值沒變,只要執行一次就好了。
回顧我們在 Ref 實作,當觸發更新時,也是新值和舊值不相同的時候,才會觸發更新,在這邊我們也用相同作法。
1import { hasChanged } from '@vue/shared'2...3...4class ComputedRefImpl implements Dependency, Sub {5 ...6 ...7 update(){8 ...9 ...10 try {11 // 更新前的值12 const oldValue = this._value13 // 更新的值14 this._value = this.fn()8 collapsed lines
15 this.dirty = false16 return hasChanged(oldValue, this._value)17 } finally {18 endTrack(this)19 setActiveSub(prevSub)20 }21 }22}
先將更新前的值保存起來,用hasChanged
判斷數值是否改變,再從 update 函式返回值判斷:
1export function processComputedUpdate(sub) {2 // update 返回值如果是 true3 // 表示數值不同,effect 執行4 if(sub.subs && sub.update()){5 propagate(sub.subs)6 }7}
得到期望結果,computed
執行兩次、effect
執行一次。
感覺我們解決了這個問題,但其實發現這個只是非常片面的解決方案,因為 effect 它在訪問相同依賴的時候,會重複觸發。
Effect 訪問相同依賴重複觸發問題
1<body>2 <div id="app"></div>3 <script type="module">4 // import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'5 import { ref, computed, effect } from '../dist/reactivity.esm.js'6
7 const count = ref(0)8
9 effect(() => {10 console.count('effect')11 console.log(count.value)12 count.value13 })14
15 setTimeout(() => {5 collapsed lines
16 count.value = 117 }, 1000)18
19 </script>20</body>
可以看到這樣它觸發了三次,你如果查看一下 count:
1 effect(() => {2 console.count('effect')3 console.log(count.value)4 count.value5})6console.log(count)
會發現它收集了相同依賴收集兩次,這時候該怎麼解決?
原始碼在link
函式裡面,每次建立關聯關係前都會去遍歷鏈表,確認是不是有建立過關聯關係。
方法一:在 link 函式判斷是否有建立過關聯關係
1export function link(dep, sub) {2 /**3 * 復用節點4 * sub.depsTail 是 undefined,並且有 sub.deps 頭節點,表示要復用5 */6 const currentDep = sub.depsTail7 const nextDep = currentDep === undefined ? sub.deps : currentDep.nextDep8 // 如果 nextDep.dep 等於我當前要收集的 dep9 if (nextDep && nextDep.dep === dep) {10 sub.depsTail = nextDep // 移動指針11 return12 }13
14 /**15 * 如果 dep 和 sub 建立過關聯關係,就直接返回。15 collapsed lines
16 */17
18 let existingLink = sub.deps;19 while (existingLink) {20 // 如果在鏈表中找到了與當前 dep 相同的依賴項21 if (existingLink.dep === dep) {22 // 表示這個關聯已經建立過了,直接返回,不做任何事23 return;24 }25 // 移動到下一個依賴項節點26 existingLink = existingLink.nextDep;27 }28 ...29 ...30}
- 在
link
函式一開始,就先進行檢查。 - 從
sub.deps
(訂閱者的依賴鏈表頭部) 開始進行遍歷。- 沿著
nextDep
指標移動,檢查每一個鏈表節點 (Link
)。 - 在每一個節點上,判斷
link.dep
是否與我們正要連結的dep
是同一個。
- 沿著
- 如果是,代表已經建立過關聯關係,我們就可以直接
return
。 - 如果遍歷完整個鏈表都沒有找到,那才繼續執行後面新增鏈表節點的邏輯。
這邊注意,需要寫在復用節點的邏輯後面:若檢查建立過依賴關係時提前退出,depsTail 標記會一直保持是
undefined
,依賴會被錯誤清理。
方法二:重構髒標記
這邊換另一個比較簡易一點的方法,我們不管他們是不是有建立過關聯關係,重點是我們只要讓 effect 函式執行一次就可以了,這樣我們只要調整髒標記處理。
1export class ReactiveEffect {2
3 ...4 ...5 dirty = true // 是否需要重新計算6 ...
我們先在 effect 函式,加一個髒標記。
1export function propagate(subs) {2 ...3 ...4
5 // 不在執行中的才加入隊列 以及 他是髒標記是 false 才執行6 if(!sub.tracking && !sub.dirty){7 // 開始執行,髒標記設定為初始值8 sub.dirty = true9 if ('update' in sub) {10 processComputedUpdate(sub)11 } else {12 queuedEffect.push(sub)13 }14 }11 collapsed lines
15 ...16 ...17}18
19export function endTrack(sub) {20 sub.tracking = false // 執行結束,取消標記21 const depsTail = sub.depsTail22 sub.dirty = false // fn 執行結束,追蹤完23...24...25}
並且在觸發更新時,增加髒標記的判斷。
如果有多個依賴同時觸發這個 effect
,它也只會被加入佇列一次。因為一旦 dirty
變成 true
,下一次的 !sub.dirty
判斷就會是 false
跳過 if
區塊。
在 endTrack
中,sub.dirty
被設為 false
。這代表 effect
剛剛成功執行完畢,它的狀態是「乾淨的」,不需要再次執行。
1..2..3update(){4 ...5 ...6 try {7 // 更新前的值8 const oldValue = this._value9 // 更新的值10 this._value = this.fn()11 this.dirty = false// 刪除髒標記初始化12 return hasChanged(oldValue, this._value)13 } finally {14 endTrack(this)5 collapsed lines
15 setActiveSub(prevSub)16 }17 }18 ..19 ..
清除在 computed.ts
的髒標記初始化,因為我們已經在 endTrack
函式,統一處理初始化。
髒標記的判斷與運作流程
- 初始化:一個
effect
在執行完畢後,dirty
標記會被設為false
,表示「這是最新狀態,不需要執行」。 - 觸發更新時:當依賴項目變更,
propagate
函式會檢查effect
是否為dirty: false
。 - 加入佇列前:只有當
dirty
為false
時,才會將馬上設定為true
,然後再將effect
加入待執行佇列。 - 防止重複:這個「先將設定為
true
再入列」的機制,可以保證在同一個事件迴圈中,縱使有多個依賴項目觸發同一個effect
,它也只會被加入佇列一次,避免了不必要的重複執行。