什麼是 Glitching avoidance?#
當今已經有很多基於 Reactivity 機制監聽和處理數據變化的 Web 前端庫了,在響應式系統裡會有一個需要注意的問題,話不多說,我們看一下以下這段代碼:
const var1 = ref(1)
const var2 = computed(() => var1.value * 2)
const var3 = computed(() => var1.value + var2.value)
// 分別用兩個不同的 effect 函數執行,查看控制台輸出
effect(() => {
console.log('@vue/reactiviy effect: var3 =', var3.value)
})
watchEffect(() => {
console.log('@vue/runtime-core watchEffect: var3 = ', var3.value)
})
// 然後我們更改一下 var1
var1.value = 2
我給上面這段代碼做了一個線上的 Playground:
https://stackblitz.com/edit/typescript-y4kx5e?file=index.ts
我們看一下輸出:
@vue/reactiviy effect: var3 = 3
@vue/reactiviy effect: var3 = 4
@vue/reactiviy effect: var3 = 6
@vue/runtime-core watchEffect: var3 = 3
@vue/runtime-core watchEffect: var3 = 6
由於我們知道 effect
和 watchEffect
都會首先執行一次來獲取所運行函數中相關的依賴,那麼可以得出結論:effect
執行了 2 次,watchEffect
只執行了 1 次
為什麼 effect
多了 1 次呢?
這是因為對於 var3
來說,它有 2 個依賴 var1
和 var2
,這倆依賴中任意一個變化,effect
的回調都會重新執行一次,而 var2
也會因為 var1
的變化而更新。
但是我們來看一下這兩次運行時 var1
和 var2
實際上的值:
var1
= 2,var2
= 2 (舊值) 這一次運行是 var1 剛更新觸發的var1
= 2,var2
= 4 (新值) 這一次運行是 var2 被更新觸發的
顯然,這個 var2
還是舊值的情況是不符合預期的。這種中間態的過程,就被稱為 “閃爍”(Glitching)
有沒有想起寫頁面時某個元素受初始狀態控制,狀態會在開始時立刻更新一次,結果看到了閃爍?
但是我們看到如果使用 watchEffect
就沒有這個問題了。
watchEffect
相比 effect
多做了些什麼呢?
源碼調試之旅#
調試前你需要準備...#
對優秀的庫代碼進行源碼調試,最好的方式就是找到它的單元測試部分,然後安裝好環境就可以愉快地進入測試場打斷點玩耍了!
可以看到我打開了 Vue 倉庫裡 runtime-core
的 apiWatch.spec.ts
添加了一個單元測試。
左邊的測試 Panel 是怎麼擁有的?它竟然還支持根據測試 Case 的標題進行搜索!太棒了 Amazing!其實用的就是 Vitest 的官方插件 ZixuanChen.vitest-explorer
由於我們需要調試查看的是 var1.value = 2
這個 “值被更新” 之後的一系列副作用被觸發的過程,因此需要先在相應位置打好斷點。(如上圖所示)
當你打好斷點之後,可以在左側這裡點擊 啟動調試:
第 1 站・觸發副作用#
當走入調試伊始,我們就看到它即將觸發訂閱了 var1
的副作用:
運行到這裡,我們先刹個車停下來分析分析:
我們這裡觸發的是所有 “訂閱了 var1
” 的副作用,即 var2
和 var3
這兩個 computed 的計算函數。那我們要怎麼確定這一輪 for 循環裡的是哪一個 effect 呢?我們可以先看看 ReactiveEffect
的定義:
ReactiveEffect
有一個 public
的屬性 fn
,這實際上就是相應的副作用函數,我們在調試控制台把 effect.fn
打印出來就可以看到這個函數的原始字符串,根據內容也就能判定屬於誰了:
可以看到這是 var3
的計算函數,意味著我們現在正要觸發這個計算函數重新執行,為 var3
更新其值。
分析差不多就先到這裡了!🚄 繼續開車!
正式走入 triggerEffect
觸發時,由於我們觸發的是 var3
這個 computed,而這裡源碼中寫道:若一個副作用有 scheduler
則需要優先調用它。
走進去後我們發現:Computed 在創建時就為自己所綁定的副作用添加了這樣一個 scheduler
:
這個調度器所做的是:標記當前計算屬性已經 “髒了”😜,然後因為計算屬性本身也是響應式變量,也可能被別的地方訂閱,因此 “重新計算” 這個更新過程也會觸發它的副作用。(見上圖代碼 47 行)
毫無疑問,我們的單元測試代碼裡,訂閱了 var3
的地方只有 watchEffect
裡的那個函數。那么我們繼續跟隨調度器執行 triggerRefValue
,會又重新走很多剛才講過的步驟,因此不再贅述。
而一個關鍵的 “檢查站” 就是 triggerEffects
的兩個 for 循環那裡,因為你可以在那時看到 數組 形式的副作用列表。
這幅圖裡信息比較多,我們總結一下:
- 調試源碼時,一定要學會看左側的函數調用棧幀。因為在很多複雜的庫執行時,可能有部分邏輯會遞歸、重複走到,而此時需要知道自己在哪裡、當前執行的是為了做什麼,不要迷失方向。當前我們仍然在
var1.value = 2
這個 “set 過程觸發副作用” 的同步任務上。 - 我們再次打印了
effect.fn
想確認是不是我們寫在單測watchEffect
裡的函數,結果發現是這麼大一坨?這是什麼?大致看一下,好像是被一個帶錯誤處理的call...
給包裝了一層。我們要帶著這個疑問繼續往下走。 - 走入了第 2 個 for 循環,意味著這次的
effect
不是 computed。其實也算側面證明了它就是watchEffect
裡那個函數。
第 2 站・了解 watchEffect#
進入 triggerEffect
我們再一次發現 這個副作用有 scheduler,走入調度器我們看到進入了一個叫 doWatch
的函數:
到這裡我們要進行本趟旅程的第 2 次刹車啦!這個函數挺複雜的,看上去有很多邏輯,不過不用害怕。
🫵 閱讀源碼時,我們需要的是抓住主幹。我在上圖中折疊了對我們本次研究不重要的部分,截圖直到當前調試正在執行的那一行,我們主要需要關注的就是 310 行開始的這個 job
。
在 345 - 347 這部分,源碼的註釋明確指明了 watchEffect
會走這條路徑。
除此之外,為了控制圖片長度,我還要特別展開上圖的 230 行。因為 209 - 252 這一部分 if-else
語句所判斷的 source 顧名思義就是 watch 的目標,也就是我們傳入 watchEffect
的那個函數,因此走入的一定是 isFunction
這個分支:
展開後你會眼前一亮 👀🌟:
這個 getter
中的代碼好像很眼熟?這不就是我們剛才打印 effect.fn
時不知道從何而來的那段代碼嗎!
在 doWatch
函數的 367 行(緊接著上述 “doWatch 函數縱覽” 一圖之下)創建了一個副作用,可以看到構造函數的兩個參數分別是剛才的 getter
和調試執行到的、高亮出的這一行中創建的 scheduler
:
而 ReactiveEffect 的構造函數 2 個參數分別代表著:
- 副作用函數本身
- 對副作用的調度器
是不是到這裡還是感覺一團糨糊?我們得到了這麼多信息卻好像卻好像還沒法理解這和 watchEffect
有什麼關係,反而可能你心裡的疑問越來越多... 別急,深呼吸,讓我們看看這個複雜的 doWatch
和 watchEffect
有什麼關聯:
原來 doWatch
就是對 watchEffect
的實現!只是在 apiWatch.ts
這個文件裡,doWatch
不只用於這裡,還有我們熟知的 watch
這個 API 也是用它實現,區分只是 watchEffect
不需要回調 cb
(見上圖,第二個參數傳的是 null
)
所以理一理其實不難得出結論:我們打印的那個 effect.fn
之所以不是我們傳入函數本身,是因為在 doWatch
這裡被包裹了一些額外的處理,疑問解決!我們可以繼續啟程了~🏄🏻♂️
第 3 站・柳暗花明#
現在我們大概能猜到,傳入 watchEffect
的副作用函數似乎是 queueJob
推入了一個隊列?那麼我們走進去看看:
細讀這段源碼後,可以得出以下結論:若隊列為空、或者隊列不包含當前要添加的 job
,則推入隊列。
然後我們繼續看將要執行的 queueFlush()
:
這裡有一堆不知道哪裡定義出來的變量,還有上面的 queue
,往文件上面翻了翻,可以看到它們都是直接定義在頂層的:
為了研究過程更加專注核心目標,我們不用一個一個去探究清楚這每一個變量的作用都是什么,還是那句話,以主幹為線索,不然就要脫軌啦 😱!
調試當前執行到的 105 行是個很有趣的操作,它將一個名為 flushJobs
的函數放到了個 Promise.then
中。
熟悉事件循環原理的同學們應該能夠明白,這是將這個函數放入了 微任務隊列。在當前一個 Tick 中每個宏任務完成後,會開始執行微任務隊列中的任務。如果你對這一部分知識還有疑問,可以先來這裡補下基礎:https://zh.javascript.info/event-loop。Vue 這樣設計的目的可能是為了讓監聽對象所引發的副作用不會阻塞主渲染過程。
flushJobs
大家可以自行查閱 Vue 源碼,其內容也沒什麼好說的,就是將隊列裡的函數順序執行罷了,這裡不再贅述。
到這裡我們可以說:“var1
變化引發 var3
需要重新計算” 這個副作用已經被推入了隊列,但在當前用戶代碼的同步過程中還沒有執行。接下去我們看的應該就是 “var1
變化引發 var2
需要重新計算” 了。
之後的過程又重複了我們剛才所見過的步驟:Computed 被標記 “髒了”、觸發自身副作用。var2
也被 var3
依賴,因此往下走又會觸發到 var3
的 scheduler
:
但由於 var3
已經被標記過 “髒了”,所以沒有再觸發 var3
的相關副作用函數。然後繼續執行你將會看到函數棧幀開始上退,即結束了 var1
更新為 2 所引發的這一輪副作用觸發過程。
為了在測試模擬瀏覽器事件循環,即當前 Tick 宏任務結束到下一 Tick 宏任務這之間的過程(也就是為了執行微任務隊列),Vue 的單測中使用了大量的 await nextTick()
,那我們這裡也依葫蘆畫瓢:
走入微任務隊列的執行時,可以看到函數棧幀是有一個 Promise.then
隔開的,同時你也已經沒有辦法再去查看之前同步任務中各棧幀內的變量值:
此時我們的隊列中只有一個 job
,因此加上最開始的一次,watchEffect
中的副作用一共只執行了 2 次而不是 3 次。
所以結論是:Vue 避免副作用響應造成值閃爍的方式是添加調度器,將副作用函數收集成一個隊列,而執行放到微任務階段。
Something more ...#
其實 “閃爍避免” 是 push-based 的響應式數據系統都會面臨和需要解決的問題。如果你對這部分響應式數據系統更深入的知識有興趣,這裡有幾篇文獻供你參考:
- A Survey on Reactive Programming - https://blog.acolyer.org/2015/12/08/a-survey-on-reactive-programming
- 響應式編程的背壓介紹 —— Pull 與 Push 模式的結合 - https://juejin.cn/post/7138241196744736799
計算屬性的這種延遲的 Effect + Lazy Computed 時,就實現了 Glitch Avoidance。為什麼兩者結合就能實現 Glitch Avoidance 呢?
這是因為前端響應式框架中,只會存在 2 種對響應式數據的監聽:
- Reactions,副作⽤執⾏。
- Derivations,衍⽣新的響應式數據 。
其中 Reactions 之間不能互相依賴,當 Reactions 延遲執⾏時,所有 Derivations 的都已被標記為 “需要更新”。重新計算 Derivations 值時,只需要沿著依賴樹,先將上游的最新值計算完畢後,就總是能拿到最新上游值,實現 Glitch Avoidance。
計算屬性的 lazy 執⾏,正是借鑒了 pull 模型,即可以理解為值是在最終需要時 “拉取” 下來的。
回到我們的例子中:實際上 var3 = 6
、var2 = 4
是在 watchEffect
的副作用函數第二次執行時因下圖中的 self.effect.run()
才重新被計算的,我們來看 ComputedRefImpl 對於 get value()
的實現:
以上就是關於 Glitching Avoidance 的全部內容了。感謝你能看到這裡,如果感覺有收穫,不妨動動小手點個讚吧!