Javascript單線程和事件循環

一、單線程

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是單線程,所以一次隻能夠執行一個任務。那麼當出現多個要執行的任務,其他尚未執行的任務在什麼地方等待呢?

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

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

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

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

通過這種模型,瀏覽器將需要快速響應的 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 萬次。

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

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

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

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

假設我們分解成瞭 200 個宏任務,每個宏任務都添加 250 個元素,那麼,在事件循環執行的時候,任務隊列裡有 200 個宏任務,取出一個執行,這個宏任務隻添加 250 個元素,耗時 10ms。當前宏任務完成後,便清空微任務,耗時 5ms,時間來到瞭 15ms,就可以渲染瀏覽器瞭。這一次事件循環,在渲染瀏覽器前隻耗時 15ms!

接著,渲染瀏覽器後,頁面上出現瞭 250 個元素,又開始事件循環,從宏任務隊列裡拿出一個宏任務執行。

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

接下來我們便改造我們的代碼,讓它分解成多個宏任務。

五、setTimeout()

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

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

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

假設如下代碼:

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

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

在 0ms 時,註冊setTimeout函數,第一個參數裡的方法將在 10ms 後加入宏任務隊列,此時,宏任務時沒有我們代碼裡的任務的。

其他我們不知道的 JS 代碼執行瞭 10 ms。

到瞭 10ms 後,setTimeout到期,第一個參數裡的方法加入宏任務隊列。

上圖中,10ms 到瞭,加入瞭宏任務隊列。但是要註意,事件循環此時可能正在執行一個宏任務,或者正在清空微任務隊列,或者正在渲染瀏覽器,所以不會馬上執行新增加的宏任務,隻有又一次循環到瞭執行宏任務的時候,才會從宏任務隊列中獲取宏任務執行(JS 是單線程的)。假設這段時間耗時瞭 5ms,那麼如下圖。

如上圖所示,在 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函數(如果是第一個的話)並執行。

如上圖所示,當前的事件循環正在執行foo()函數,此時render()在宏任務隊列中等待。

假設這次事件循環需要的時間是 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()函數,然後結束當前函數。

如上圖,在 15ms 的時候,render()函數添加瞭 250 個子元素,然後使用setTimeout註冊瞭一個新的宏任務,在 0ms 後進入宏任務隊列。註意此時,盡管render()函數添加瞭 250 個子元素,但是事件循環還沒有到渲染瀏覽器這一步,所以頁面沒有出現 250 個新元素。

事件循環繼續執行:

到瞭 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);
  }
}

到此這篇關於Javascript單線程和事件循環的文章就介紹到這瞭,更多相關JS單線程 內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: