為啥同樣的邏輯在不同前端框架中效果不同

卡頌發表於2021-11-17

大家好,我卡頌。

前端框架中經常有將多個自變數變化觸發的更新合併為一次執行的批處理場景,框架的型別不同,批處理的時機也不同。

比如如下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
Vue3
Svelte

本質原因在於:有的框架使用巨集任務實現批處理,有的框架使用微任務實現批處理。

本文接下來會講解巨集任務微任務的起源,以及他們與批處理的關係。

歡迎加入人類高質量前端框架群,帶飛

如何排程任務

先放上完整流程圖,方便有個整體印象:

事件迴圈流程圖

預設情況下,瀏覽器(以Chrome為例)中每個Tab頁對應一個渲染程式,渲染程式包含主執行緒、合成執行緒、IO執行緒等多個執行緒。

主執行緒的工作非常繁忙,要處理DOM、計算樣式、處理佈局、處理事件響應、執行JS等。

這裡有兩個問題需要解決:

  1. 這些任務不僅來自執行緒內部,也可能來自外部,如何排程這些任務?
  2. 主執行緒在工作過程中,新任務如何參與排程?

第一個問題的答案是:訊息佇列

所有參與排程的任務會加入任務佇列中。根據佇列先進先出的特性,最早入隊的任務會被最先處理。用虛擬碼描述如下:

// 從任務佇列中取出任務
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非常類似。利用了巨集任務微任務非同步執行的特性,將更新打包後執行。

只不過不同框架由於更新粒度不同,比如Vue3Svelte更新粒度很細,所以使用微任務實現批處理。

React更新粒度比較粗,但內部實現比較複雜,即有巨集任務的場景也有微任務的場景。

相關文章