今天我們來探討一個棘手的邊界情況:巢狀 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。