嘿,日安!

Day 8 - Effect: 深入剖析巢狀 effect

17th September 2025
Front-end
Vue3
IT鐵人賽2025
從零到一打造 Vue3 響應式系統
Last updated:17th September 2025
6 Minutes
1179 Words

今天我們來探討一個棘手的邊界情況:巢狀 effect。

當一個effect內部又定義了另一個effect 時,我們的系統會怎麼運作呢?

1
import { ref, effect } from '../dist/reactivity.esm.js'
2
3
const count = ref(0)
4
5
const effect1 = effect(()=>{
6
const effect2 = effect(()=>{
7
console.log('內層的 Effect', count.value)
8
})
9
10
console.log('外部的 Effect', count.value)
11
})
12
13
setTimeout(()=>{
14
count.value = 1
15
}, 1000)

在這個情況我們預期內外層都有輸出,但是我們得到如下

1
console.log('內層的 Effect', 0)
2
console.log('外部的 Effect', 0)
3
console.log('內層的 Effect', 1)

官方不建議使用巢狀 effect,你可能會想:「既然官方不建議,我只要不這樣寫就好了。」

但是遇見這種「巢狀執行」的場景比想像中更常見。比方說,當一個 effect 依賴了一個 computed 屬性時,就會隱性觸發巢狀執行:

1
const count = ref(0);
2
// computed 內部會為計算函式建立一個 effect (我們先叫 effect B)
3
const double = computed(() => count.value * 2);
4
5
// 這是我們手動建立的 effect (我們稱之為 effect A)
6
effect(() => {
7
// 當 effect A 執行,並在這裡讀取 double.value 時...
8
// effect B 就必須先回傳計算結果。
9
// 這就形成了 effect A 內部觸發了 effect B 執行的巢狀情況。
10
console.log('The double value is:', double.value);
11
});

因此,為了處理這種隱性觸發問題,我們需要解決巢狀 effect 觸發。

問題解析

初始化頁面

default

  • 執行 effect1 (ReactiveEffect A):
    • activeSub 設為 A
    • 開始執行 effect1 的函式 fnA
    • 進入 fnA 內部,遇到 effect2 (ReactiveEffect B):
      1. activeSub 被覆蓋,更新為 B
      2. 開始執行 effect2 的函式 fnB
      3. fnB 中,讀取 count.value,觸發 getter
      4. 依賴收集: count 的依賴列表中,只收集了當前的 activeSub,也就是 B
      5. console.log 輸出 內層的 Effect 0
      6. fnB 執行完畢,activeSub 被清空 (undefined)。
  • 回到 effect1fnA 繼續執行:
    • 此時,程式讀取 count.value
    • 依賴收集失敗: 因為 activeSub 已經是 undefined,所以 A 無法被 count 收集。
    • console.log 輸出 外部的 Effect 0
  • 結果: count 的依賴鏈表上,只有 B (effect2),沒有 A (effect1)。

關鍵問題:執行外層匿名函式 fn時,activeSub 就被覆蓋、沒有進行收集依賴。

一秒後執行count.value = 1

default

由於依賴收集只有收集內層的 ReactiveEffect(也就是ReactiveEffect B),因此他不會執行 propagate,進行觸發更新。

default

核心思路

後來的 effect 覆蓋到前面的 effect,這個情況是不是跟函式的「堆疊(Stack)」有點像?

堆疊(Stack) 有兩個主要特性:

  1. 後進先出。
  2. 一維線性結構。

函式在層層呼叫時,就是被放入一個「呼叫堆疊」中,我們也可以利用這個特性來管理 activeSub

  • 在進入內層 effect 時,將外層的 effect暫存
  • 在內層結束後,再從堆疊中取出,並還原外層的 effect

要完成這個方法,可以透過一個暫存變數來模擬。

解決方法

實際做法

  1. 外層 effect 開始:activeSub = ReactiveEffect A
  2. 外層 effect 執行,遇到內層 effect
  3. 在內層 effect 執行之前:
    • 我們先檢查activeSub是不是有值
    • 假設有數值,我們可以先儲存起來
  4. 內層 effect 執行完成後
    • 不再設定activeSub = undefined
    • 而是將activeSub復原成執行之前的狀況

於是我們這樣寫

1
export let activeSub;
2
3
class ReactiveEffect {
4
constructor(public fn){
5
}
6
7
run(){
8
//先將當前的 Effect 儲存,用來處理巢狀邏輯
9
const prevSub = activeSub
10
activeSub = this
11
try{
12
return this.fn()
13
14
}finally{
15
// 執行完成後,恢復之前的 activeSub
12 collapsed lines
16
activeSub = prevSub
17
}
18
19
}
20
}
21
22
export function effect(fn){
23
24
const e = new ReactiveEffect(fn)
25
e.run()
26
27
}

他的運作模式是這樣:

default

此時你會發現,在觸發更新的時候,內層會多輸出一次:

default

觸發更新為何會多輸出一次?

初始狀態

  • 內層的 Effect 0
  • 外部的 Effect 0

各別輸出一次,這邊沒什麼問題。

初始化後的鏈表結構

default

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 (第二次)。

default

因為這樣,所以內層會執行兩次。

乍看之下內層 effect 多執行一次似乎沒什麼關係。

但思考一下,如果現在內層的 effect 執行的不是 console.log,而是更費資源的操作呢?

像是:

  • 網路請求
  • 複雜且大量的計算
  • DOM 的重新佈局

因此我們知道不必要的重複執行會導致效能浪費,甚至有可能引發無法預期的 Bug。

這也就是為什麼官方不推薦我們寫巢狀 effect

Article title:Day 8 - Effect: 深入剖析巢狀 effect
Article author:日安
Release time:17th September 2025