為啥同樣的邏輯在不同前端框架中效果不同
大家好,我卡頌。
前端框架中經常有「將多個自變量變化觸發的更新合并為一次執行」的批處理場景,框架的類型不同,批處理的時機也不同。
比如如下Svelte代碼,點擊H1后執行onClick回調函數,觸發三次更新。由于批處理,三次更新會合并為一次。
接著分別以同步、微任務、宏任務的形式打印渲染結果:
- <script>
- let count = 0;
- let dom;
- const onClick = () => {
- // 三次更新合并為一次
- count++;
- count++;
- count++;
- console.log("同步結果:", dom.innerText);
- Promise.resolve().then(() => {
- console.log("微任務結果:", dom.innerText);
- });
- setTimeout(() => {
- console.log("宏任務結果:", dom.innerText);
- });
- }
- </script>
- <h1 bind:this={dom} on:click={onClick}>{count}</h1>
同樣的邏輯用不同框架實現,打印結果如下:
- Vue3:同步結果:0 微任務結果:3 宏任務結果:3
- Svelte:同步結果:0 微任務結果:3 宏任務結果:3
- Legacy React:同步結果:0 微任務結果:3 宏任務結果:3
- Concurrent React:同步結果:0 微任務結果:0 宏任務結果:3
4種實現的Demo地址:React[1]Vue3[2]Svelte[3]
本質原因在于:有的框架使用宏任務實現批處理,有的框架使用微任務實現批處理。
本文接下來會講解宏任務、微任務的起源,以及他們與批處理的關系。
如何調度任務
先放上完整流程圖,方便有個整體印象:
事件循環流程圖
默認情況下,瀏覽器(以Chrome為例)中每個Tab頁對應一個渲染進程,渲染進程包含主線程、合成線程、IO線程等多個線程。
主線程的工作非常繁忙,要處理DOM、計算樣式、處理布局、處理事件響應、執行JS等。
這里有兩個問題需要解決:
- 這些任務不僅來自線程內部,也可能來自外部,如何調度這些任務?
- 主線程在工作過程中,新任務如何參與調度?
第一個問題的答案是:「消息隊列」
所有參與調度的任務會加入任務隊列中。根據隊列「先進先出」的特性,最早入隊的任務會被最先處理。用偽代碼描述如下:
- // 從任務隊列中取出任務
- const task = taskQueue.takeTask();
- // 執行任務
- processTask(task);
其他進程通過IPC將任務發送給渲染進程的IO線程,IO線程再將任務發送給主線程的任務隊列,比如:
- 鼠標點擊后,瀏覽器進程通過IPC將“點擊事件”發送給IO線程,IO線程將其發送給任務隊列
- 資源加載完成后,網絡進程通過IPC將“加載完成事件”發送給IO線程,IO線程將其發送給任務隊列
如何調度新任務
第二個問題的答案是:「事件循環」
主線程會在循環語句中執行任務。隨著循環一直進行下去,新加入的任務會插入隊列末尾,老任務會被取出執行。用偽代碼描述如下:
- // 退出事件循環的標識
- let keepRunning = true;
- // 主線程
- function MainThread() {
- // 循環執行任務
- while(true) {
- // 從任務隊列中取出任務
- const task = taskQueue.takeTask();
- // 執行任務
- processTask(task);
- if (!keepRunning) {
- break;
- }
- }
- }
延遲任務
除了任務隊列,瀏覽器還根據WHATWG標準,實現了延遲隊列,用于存放需要被延遲執行的任務(如setTimeout),偽代碼如下:
- function MainThread() {
- while(true) {
- const task = taskQueue.takeTask();
- processTask(task);
- //執行延遲隊列中的任務
- processDelayTask()
- if (!keepRunning) {
- break;
- }
- }
- }
當本輪循環任務執行完后(即執行完processTask后),會執行processDelayTask檢查是否有延遲任務到期,如果有任務過期則執行他。
介于processDelayTask的執行時機在processTask之后,所以當任務的執行時間比較長,可能會導致延遲任務無法按期執行。考慮如下代碼:
- function sayHello() { console.log('hello') }
- function test() {
- setTimeout(sayHello, 0);
- for (let i = 0; i < 5000; i++) {
- console.log(i);
- }
- }
- test()
即使將延遲任務sayHello的延遲時間設為0,也需要等待test所在任務執行完后才能執行,所以sayHello最終的延遲時間是大于設定時間的。
宏任務與微任務
加入任務隊列的新任務需要等待隊列中其他任務都執行完后才能執行,這對于「突發情況下需要優先執行的任務」是不利的。
為了解決時效性問題,任務隊列中的任務被稱為宏任務,在宏任務執行過程中可以產生微任務,保存在該任務執行上下文中的微任務隊列中。
即流程圖中右邊的部分:
事件循環流程圖
在宏任務執行結束前會遍歷其微任務隊列,將該宏任務執行過程中產生的微任務批量執行。
MutationObserver
微任務是如何解決時效性問題同時又兼顧性能呢?
考慮用于監控DOM變化的微任務API —— MutationObserver。
當同一個宏任務中發生多次DOM變化,會產生多個MutationObserver微任務,其執行時機是該宏任務執行結束前,相比于作為新的宏任務進入隊列等待執行,保證了時效性。
同時,由于微任務隊列內的微任務被批量執行,相比于每次DOM變化都同步執行回調,性能更佳。
總結
框架中批處理的實現本質和MutationObserver非常類似。利用了宏任務、微任務異步執行的特性,將更新打包后執行。
只不過不同框架由于更新粒度不同,比如Vue3、Svelte更新粒度很細,所以使用微任務實現批處理。
React更新粒度很粗,但內部實現復雜,即有宏任務場景也有微任務的場景。
參考資料
[1]React:
https://codesandbox.io/s/react-concurrent-mode-demo-forked-t8mil?file=/src/index.js[2]Vue3:
https://codesandbox.io/s/crazy-rosalind-wqj0c?file=/src/App.vue[3]Svelte:
https://svelte.dev/repl/1e4e4e44b9ca4e0ebba98ef314cfda54?version=3.44.1