今天我們來探討一個棘手的邊界情況:巢狀 effect。
當一個effect
內部又定義了另一個effect
時,我們的系統會怎麼運作呢?
import { ref, effect } from '../dist/reactivity.esm.js'
const count = ref(0)
const effect1 = effect(()=>{
const effect2 = effect(()=>{
console.log('內層的 Effect', count.value)
})
console.log('外部的 Effect', count.value)
})
setTimeout(()=>{
count.value = 1
}, 1000)
在這個情況我們預期內外層都有輸出,但是我們得到如下
console.log('內層的 Effect', 0)
console.log('外部的 Effect', 0)
console.log('內層的 Effect', 1)
官方不建議使用巢狀 effect
,你可能會想:「既然官方不建議,我只要不這樣寫就好了。」
但是遇見這種「巢狀執行」的場景比想像中更常見。比方說,當一個 effect
依賴了一個 computed
屬性時,就會隱性觸發巢狀執行:
const count = ref(0);
// computed 內部會為計算函式建立一個 effect (我們先叫 effect B)
const double = computed(() => count.value * 2);
// 這是我們手動建立的 effect (我們稱之為 effect A)
effect(() => {
// 當 effect A 執行,並在這裡讀取 double.value 時...
// effect B 就必須先回傳計算結果。
// 這就形成了 effect A 內部觸發了 effect B 執行的巢狀情況。
console.log('The double value is:', double.value);
});
因此,為了處理這種隱性觸發問題,我們需要解決巢狀 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
復原成執行之前的狀況於是我們這樣寫
export let activeSub;
class ReactiveEffect {
constructor(public fn){
}
run(){
//先將當前的 Effect 儲存,用來處理巢狀邏輯
const prevSub = activeSub
activeSub = this
try{
return this.fn()
}finally{
// 執行完成後,恢復之前的 activeSub
activeSub = prevSub
}
}
}
export function effect(fn){
const e = new ReactiveEffect(fn)
e.run()
}
他的運作模式是這樣:
此時你會發現,在觸發更新的時候,內層會多輸出一次:
內層的 Effect 0
外部的 Effect 0
各別輸出一次,這邊沒什麼問題。
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
,而是更費資源的操作呢?
像是:
因此我們知道不必要的重複執行會導致效能浪費,甚至有可能引發無法預期的 Bug。
這也就是為什麼官方不推薦我們寫巢狀 effect
。
同步更新技術部落格《嘿!日安》