今天我們來探討一個棘手的邊界情況:巢狀 effect。
當一個effect
內部又定義了另一個effect
時,我們的系統會怎麼運作呢?
1import { ref, effect } from '../dist/reactivity.esm.js'2
3const count = ref(0)4
5const effect1 = effect(()=>{6 const effect2 = effect(()=>{7 console.log('內層的 Effect', count.value)8 })9
10 console.log('外部的 Effect', count.value)11})12
13setTimeout(()=>{14 count.value = 115}, 1000)
在這個情況我們預期內外層都有輸出,但是我們得到如下
1console.log('內層的 Effect', 0)2console.log('外部的 Effect', 0)3console.log('內層的 Effect', 1)
官方不建議使用巢狀 effect
,你可能會想:「既然官方不建議,我只要不這樣寫就好了。」
但是遇見這種「巢狀執行」的場景比想像中更常見。比方說,當一個 effect
依賴了一個 computed
屬性時,就會隱性觸發巢狀執行:
1const count = ref(0);2// computed 內部會為計算函式建立一個 effect (我們先叫 effect B)3const double = computed(() => count.value * 2);4
5// 這是我們手動建立的 effect (我們稱之為 effect A)6effect(() => {7// 當 effect A 執行,並在這裡讀取 double.value 時...8// effect B 就必須先回傳計算結果。9// 這就形成了 effect A 內部觸發了 effect B 執行的巢狀情況。10console.log('The double value is:', double.value);11});
因此,為了處理這種隱性觸發問題,我們需要解決巢狀 effect
觸發。
問題解析
初始化頁面
- 執行
effect1
(ReactiveEffect A
):activeSub
設為A
。- 開始執行
effect1
的函式fnA
。 - 進入
fnA
內部,遇到effect2
(ReactiveEffect B
):activeSub
被覆蓋,更新為B
。- 開始執行
effect2
的函式fnB
。 - 在
fnB
中,讀取count.value
,觸發getter
。 - 依賴收集:
count
的依賴列表中,只收集了當前的activeSub
,也就是B
。 console.log
輸出內層的 Effect 0
。fnB
執行完畢,activeSub
被清空 (undefined
)。
- 回到
effect1
的fnA
繼續執行:- 此時,程式讀取
count.value
。 - 依賴收集失敗: 因為
activeSub
已經是undefined
,所以A
無法被count
收集。 console.log
輸出外部的 Effect 0
。
- 此時,程式讀取
- 結果:
count
的依賴鏈表上,只有B
(effect2
),沒有A
(effect1
)。
關鍵問題:執行外層匿名函式 fn
時,activeSub
就被覆蓋、沒有進行收集依賴。
一秒後執行count.value = 1
由於依賴收集只有收集內層的 ReactiveEffect(也就是ReactiveEffect B),因此他不會執行 propagate
,進行觸發更新。
核心思路
後來的 effect
覆蓋到前面的 effect
,這個情況是不是跟函式的「堆疊(Stack)」有點像?
堆疊(Stack) 有兩個主要特性:
- 後進先出。
- 一維線性結構。
函式在層層呼叫時,就是被放入一個「呼叫堆疊」中,我們也可以利用這個特性來管理 activeSub
。
- 在進入內層
effect
時,將外層的effect
暫存 - 在內層結束後,再從堆疊中取出,並還原外層的
effect
。
要完成這個方法,可以透過一個暫存變數來模擬。
解決方法
實際做法
- 外層
effect
開始:activeSub = ReactiveEffect A
- 外層
effect
執行,遇到內層effect
- 在內層
effect
執行之前:- 我們先檢查
activeSub
是不是有值 - 假設有數值,我們可以先儲存起來
- 我們先檢查
- 內層
effect
執行完成後- 不再設定
activeSub = undefined
- 而是將
activeSub
復原成執行之前的狀況
- 不再設定
於是我們這樣寫
1export let activeSub;2
3class ReactiveEffect {4 constructor(public fn){5 }6
7 run(){8 //先將當前的 Effect 儲存,用來處理巢狀邏輯9 const prevSub = activeSub10 activeSub = this11 try{12 return this.fn()13
14 }finally{15 // 執行完成後,恢復之前的 activeSub12 collapsed lines
16 activeSub = prevSub17 }18
19 }20}21
22export function effect(fn){23
24 const e = new ReactiveEffect(fn)25 e.run()26
27}
他的運作模式是這樣:
此時你會發現,在觸發更新的時候,內層會多輸出一次:
觸發更新為何會多輸出一次?
初始狀態
內層的 Effect 0
外部的 Effect 0
各別輸出一次,這邊沒什麼問題。
初始化後的鏈表結構
setTimeout 觸發更新後
count.value = 1
觸發 setter,執行propagate
。propagate
遍歷依賴鏈表。- 執行
B.run() (effect2)
:console.log
輸出內層的 Effect 1 (第一次)
。
- 執行
A.run() (effect1)
console.log
輸出外部的 Effect 1
。- 在
A
的函式內部,會重新建立並執行一個全新的內層effect
。 - 執行這個新的內層
effect.run()
:console.log
輸出內層的 Effect 1
(第二次)。
因為這樣,所以內層會執行兩次。
乍看之下內層 effect
多執行一次似乎沒什麼關係。
但思考一下,如果現在內層的 effect
執行的不是 console.log
,而是更費資源的操作呢?
像是:
- 網路請求
- 複雜且大量的計算
- DOM 的重新佈局
因此我們知道不必要的重複執行會導致效能浪費,甚至有可能引發無法預期的 Bug。
這也就是為什麼官方不推薦我們寫巢狀 effect
。