淺談Javascript單執行緒和事件迴圈

DvorakChen發表於2022-06-06

單執行緒

Javascript 是單執行緒的,意味著不會有其他執行緒來競爭。為什麼是單執行緒呢?

假設 Javascript 是多執行緒的,有兩個執行緒,分別對同一個元素進行操作:

function changeValue() {
  const e = document.getElementById("ele1");
  if (e) {
    e.value = "VALUE";
  }
}

function deleteElement() {
  const e = document.getElementById("ele1");
  if (e) {
    e.remove();
  }
}

一個執行緒將執行 changeValue() 函式,如果元素存在就修改元素的值;一個執行緒將執行 deleteElement() 函式,如果元素存在就刪除元素。此時在多執行緒的條件下,兩個函式同時執行,執行緒 1 執行,判斷元素存在,準備執行修改值的程式碼 e.value = "VALUE";,此時執行緒 2 搶佔了 CPU,執行了 deleteElement() 函式,完整的執行結束,成功刪除了元素,CPU 的控制權回到了執行緒 1,執行緒 1 繼續執行剩下的程式碼,也就是將要執行的 e.value = "VALUE";,然而因為這個元素被執行緒 2 刪除了,獲取不到元素,修改元素的值失敗!

能夠發現,瀏覽器環境下,不管有幾個執行緒,都是共享同一個文件(Document),對 DOM 的頻繁操作,多執行緒將帶來極大的不穩定性。如果是單執行緒,則能夠保證對 DOM 的操作是極其穩定和可預見的。你永遠不用擔心有別的執行緒搶佔了資源,做了什麼操作而影響到原來的執行緒。

由於單執行緒,JS 一次只能處理一個任務,在該任務處理完成之前,其他任務必須等待。這一點非常重要,在理解下面的事件迴圈前,首先得明確這個概念。

事件迴圈

如你所見,因為瀏覽器執行 Javascript 是單執行緒,所以一次只能夠執行一個任務。那麼當出現多個要執行的任務,其他尚未執行的任務在什麼地方等待呢?

為了能夠讓任務有個可以等待執行的地方,瀏覽器就建立了一個佇列,所有的任務都在佇列裡等待,當要執行任務的時候,就從佇列的隊頭裡拿一個任務來執行,執行過程中,其他任務繼續等待。當任務執行完之後,再從佇列裡拿下一個任務來執行。

image.png

可是,除了開發者編寫的 Javascript 程式碼之外,還有很多事件發生,比如瀏覽器的點選事件,滑鼠移動事件,鍵盤事件,網路請求等。這些事件也需要執行,而且為了客戶體驗的流暢,需要儘快執行,以更新頁面。我們的佇列可能有很多工正在等待執行,如果把瀏覽器發生的事件排入佇列的隊尾,那麼在前面的任務執行完成之前,瀏覽器的頁面將一直堵塞住,在使用者看在,將是非常卡頓的。

image.png

為了應對這種問題,瀏覽器就多加了一個佇列,這個佇列中的任務,將被儘快執行。為了和前一個佇列做區分,前面一個佇列就叫巨集任務佇列吧,這個新加的佇列就叫微任務佇列吧。巨集任務佇列的任務叫巨集任務,微任務佇列裡的任務叫微任務。

巨集任務佇列的執行方式仍不變,還是一次拿一個巨集任務來執行。但是在執行完一個巨集任務後,就變了,不檢查巨集任務佇列是否為空,而是檢查微任務佇列是否為空! 如果微任務佇列不為空,就執行一個微任務,當前微任務執行完成後,繼續檢查微任務佇列是否為空,如果微任務佇列不為空,就再執行一個微任務,直到微任務佇列為空。當微任務佇列為空後,就渲染瀏覽器,回到巨集任務佇列執行,如此迴圈往復。

image.png

通過這種模型,瀏覽器將需要快速響應的 DOM 事件放入微任務佇列,以達到快速執行的目的。當微任務佇列執行完成後,便按需要重新渲染瀏覽器,使用者就會感覺自己的操作被迅速地響應了。

這種事件執行方式,稱為事件迴圈。瀏覽器中的事件和程式碼,就在事件迴圈模型下執行。

事件迴圈的應用

通過上圖的事件迴圈模型,我們得知瀏覽器渲染的順序,是在執行了一個巨集任務和剩下的所有微任務之後,那麼為了保證瀏覽器的渲染順暢,我們不宜讓每一個巨集任務的執行事件太長,也不能讓清空微任務佇列太耗時。一次事件迴圈中,只執行一個巨集任務,那麼,對耗時的巨集任務需要分解成儘可能小的巨集任務,微任務卻不同。由於微任務是清空整個微任務佇列,所以,在微任務裡不要生成新的微任務。畢竟微任務佇列的使命就是為了儘可能先處理微任務,然後重新渲染瀏覽器。

巨集任務佇列和微任務佇列這兩者,都是獨立於事件迴圈的,也就是說,在執行 Javascript 程式碼時,任務佇列的新增行為也在發生,即使現在正在清空微任務佇列。這是為了避免在執行程式碼時,發生的事件被忽略。如此可知,即使我們分解一個耗時任務,也不能因為微任務會被優先執行就選擇將它分解成多個微任務,這將阻塞瀏覽器重新渲染。更好的做法是分解成多個巨集任務,這樣執行一個分解後的巨集任務不會太耗時,可以儘快達到讓瀏覽器渲染。

在瀏覽器的渲染之前,會清空微任務佇列,所以,對瀏覽器 DOM 的修改更新,就適合放到微任務裡去執行。

瀏覽器渲染的次數大概是每秒 60 次,約等於 16ms 一次。在瀏覽器渲染頁面的時候,任何任務都無法再對頁面進行修改,這意味著,為了頁面的平滑順暢,我們的程式碼,單個巨集任務和當前微任務佇列裡所有微任務,都應該在 16ms 內執行完成。否則就會造成頁面卡頓。

使用程式碼來說明

我會用一些簡單卻有效的程式碼來說明事件迴圈如何影響頁面效果,以下的程式碼很少,建議你一起編寫,體驗一下。

先看下面的程式碼,我定義了一個 foo() 函式,它將一次性往元素中新增 5 萬個子元素,我將在頁面載入完成後立即執行它。

function foo() {
  const d = document.getElementById("container");
  for (let index = 0; index < 50000; index++) {
    const e = document.createElement("div");
    e.textContent = "NEW";
    d.appendChild(e);
  }
}

可見這是一個耗時的操作,如果你電腦很好,體驗不到卡頓的話,可以換成迴圈 50 萬次。

在一陣時間的卡頓後,頁面一次性出現了大量子元素。雖說新增元素的目的達到了,但是元素出現之前的卡頓卻不能忍受。根據事件迴圈,我們能夠知道,是因為執行了一個非常耗時的巨集任務,導致阻塞了頁面的渲染。用下面一張圖說明。

image.png

上面這張圖代表著本次事件迴圈的執行,一開始,瀏覽器就將 foo() 放進巨集任務佇列。從 0ms 開始,巨集任務佇列裡有任務,事件迴圈取出一個巨集任務,該巨集任務為 foo(),執行,新增 5 萬個子元素,執行非常耗時,需要 2000ms(假設的時間),foo() 執行完後,執行微任務,假設我們的清空微任務佇列需要執行 5ms,清空後,時間來到了 2005ms,這個時候才能開始重新渲染瀏覽器。經過了這一次事件迴圈,竟然耗時了 2015ms!

那麼,我們要改善體驗,期望是一個平滑的渲染效果。因為瀏覽器頁面的變化,只有在事件迴圈中重新渲染瀏覽器這一步才會發生變化,所以我們要做的就是,儘可能快地到事件迴圈中的渲染瀏覽器這一步。所以,我們要將這個 foo() 分解成多個巨集任務。

為什麼不能分解成微任務?因為微任務會在巨集任務完成後全部執行。假設我們將新增 5 萬 個元素分解成巨集任務新增 1000 個,微任務新增 49000 個,那麼事件迴圈還是必須執行完新增 1000 個元素的巨集任務後,執行新增 49000 個元素的微任務,才能渲染頁面。所以我們要分解成巨集任務。

假設我們分解成了 200 個巨集任務,每個巨集任務都新增 250 個元素,那麼,在事件迴圈執行的時候,任務佇列裡有 200 個巨集任務,取出一個執行,這個巨集任務只新增 250 個元素,耗時 10ms。當前巨集任務完成後,便清空微任務,耗時 5ms,時間來到了 15ms,就可以渲染瀏覽器了。這一次事件迴圈,在渲染瀏覽器前只耗時 15ms!

接著,渲染瀏覽器後,頁面上出現了 250 個元素,又開始事件迴圈,從巨集任務佇列裡拿出一個巨集任務執行。

image.png

如上圖所示,接連不斷的事件迴圈使瀏覽器渲染看起來平滑順暢。

接下來我們便改造我們的程式碼,讓它分解成多個巨集任務。

setTimeout()

setTimeout() 函式,用於將一個函式延遲執行,是我們的重點方法。

你應該很熟悉這個函式的用法了,setTimeout() 接收兩個引數,第一個是一個回撥函式,第二個是數字,用於指示延遲多少時間,以毫秒為單位(ms)。

這裡主要介紹的是第二個引數,很多人以為第二個引數是指延遲多少毫秒後執行傳進來的函式,但其實,它的真正含義是:延遲多少毫秒後進入巨集任務佇列

假設如下程式碼:

setTimeout(() => {
  console.log("execute setTimeout()");
}, 10);

下面我用一張圖說明這段程式碼的執行,圖中,上方代表時間軸,下方代表巨集任務佇列。

image.png

在 0ms 時,註冊 setTimeout 函式,第一個引數裡的方法將在 10ms 後加入巨集任務佇列,此時,巨集任務時沒有我們程式碼裡的任務的。

其他我們不知道的 JS 程式碼執行了 10 ms。

到了 10ms 後,setTimeout 到期,第一個引數裡的方法加入巨集任務佇列。

image.png

上圖中,10ms 到了,加入了巨集任務佇列。但是要注意,事件迴圈此時可能正在執行一個巨集任務,或者正在清空微任務佇列,或者正在渲染瀏覽器,所以不會馬上執行新增加的巨集任務,只有又一次迴圈到了執行巨集任務的時候,才會從巨集任務佇列中獲取巨集任務執行(JS 是單執行緒的)。假設這段時間耗時了 5ms,那麼如下圖。

image.png

如上圖所示,在 15ms 的時候,我們才從巨集任務佇列裡取出在 10ms 時放入巨集任務佇列的巨集任務,並執行。和我們的程式碼對比,儘管 setTimeout 的第二個引數是 10ms,卻在 15ms 才執行。

當理解了 setTimeout 的原理之後,便可以使用 setTimeout 將一個耗時的任務分解成多個巨集任務,以充分給予瀏覽器渲染。

我修改了 foo 函式,如下所示:

function foo() {
  const d = document.getElementById("container");
  const total = 50000;
  const size = 250;
  const chunk = total / size;
  let i = 0;
  setTimeout(function render() {
    for (let index = 0; index < size; index++) {
      const e = document.createElement("div");
      e.textContent = "NEW";
      d.appendChild(e);
    }
    i++;
    if (i < chunk) {
      setTimeout(render, 0);
    }
  }, 0);
}

foo 方法中,首先獲取了要新增子元素的元素,和定義了各種變數。total 表示一共有幾個元素要新增,因為我電腦效能差,所以是 5 萬,你可以修改成你喜歡的值;size 是指我們分解後每個巨集任務要新增幾次元素;chunk 是指分解後,一共有幾個巨集任務,通過簡單的計算得到;i 是用於標記執行到了第幾個巨集任務了。

接下來就是重點了,註冊了 setTimeout,在 0ms 後將傳入的 render 函式放進巨集任務佇列裡。然後這個 foo 函式就執行結束了,事件迴圈繼續往下執行,清空微任務佇列,渲染瀏覽器。等到下一個事件迴圈的時候,才會從巨集任務佇列裡拿出由 setTimeout 放入的 render 函式(如果是第一個的話)並執行。

image.png

如上圖所示,當前的事件迴圈正在執行 foo() 函式,此時 render() 在巨集任務佇列中等待。

image.png

假設這次事件迴圈需要的時間是 10ms,那麼到了 10ms 後,事件迴圈開始了新的一輪,從巨集任務佇列裡獲取一個新的巨集任務,獲取到了 render() 任務並執行。來看 render() 函式裡的程式碼:

function render() {
  for (let index = 0; index < size; index++) {
    const e = document.createElement("div");
    e.textContent = "NEW";
    d.appendChild(e);
  }
  i++;
  if (i < chunk) {
    setTimeout(render, 0);
  }
}

程式碼執行了 for 迴圈,新增 size 次數的子元素,在示例中 size 定義為了 250,新增 250 個子元素,數量不多,新增過程會非常快。在執行完 for 迴圈後,將外部的 i 變數加 1,我們將使用 i 判斷所有的子元素是否新增完畢,如果是則結束函式,如果不是,則再次通過 setTimeout 註冊一個 render() 函式,然後結束當前函式。

image.png

如上圖,在 15ms 的時候,render() 函式新增了 250 個子元素,然後使用 setTimeout 註冊了一個新的巨集任務,在 0ms 後進入巨集任務佇列。注意此時,儘管 render() 函式新增了 250 個子元素,但是事件迴圈還沒有到渲染瀏覽器這一步,所以頁面沒有出現 250 個新元素。

事件迴圈繼續執行:

image.png

到了 15ms,執行微任務佇列,假設需要執行 5ms。到了 20 ms,清空了微任務佇列,開始渲染瀏覽器,假設渲染需要 5ms,介面上出現了 250 個新元素。這次,只花費了 15ms,就讓頁面上渲染出了元素,而不是一開始那樣卡頓了 2000ms 後才頁面才渲染!

接下來的事件迴圈就是一直重複 10ms 開始到 25ms 的動作了,直到所有子元素都渲染完畢。

通過改造後的 foo() 函式,我們將卡頓的頁面優化成了觀感良好順暢的頁面。從新舊 foo() 函式的程式碼量來看,程式碼數量的多少跟頁面順暢與否沒有太大關係。重點是理解事件迴圈中發生的事。

思考:劣質的優化

如果我將 foo() 函式改寫成如下的形式,會怎麼樣,親自試一試,思考執行的事件迴圈和巨集任務佇列中發生了什麼。

function foo() {
  const d = document.getElementById("container");
  const size = 1000;
  const chunk = 50000 / size;
  for (let index = 0; index < chunk; index++) {
    setTimeout(() => {
      const e = document.createElement("div");
      e.textContent = "NEW";
      d.appendChild(e);
    }, 0);
  }
}

相關文章