JS 總結之事件迴圈

Karon_發表於2018-12-27

眾所周知,JavaScript 為了避免複雜,被設計成了單執行緒。

⛅️ 任務

單執行緒意味著所有任務都需要按順序執行,如果某個任務執行非常耗時,執行緒就會被阻斷,後面的任務需要等上一個任務執行完畢才會進行。而大多數非常耗時的任務是網路請求,CPU 是閒著的,所以為了資源的充分運用,便有了非同步的概念。

非同步便是把這些非常耗時的任務放到一邊,其他任務先進行,等處理完其它不需要等待的任務再回頭來計算剛剛被放一邊的任務。這樣就不會阻斷執行緒啦。

就像上面講述的,後面的任務需要等上一個任務執行完畢才會進行,叫同步任務;把這些非常耗時的任務放到一邊,其他任務先進行,叫非同步任務

那麼問題來了,執行非同步任務後會發生什麼

☁️ 任務佇列

在 stack 之外存在一個任務佇列

當非同步任務執行完成後,會將一個回撥函式(回撥函式是在編寫非同步任務時指定的,用來處理非同步的結果)推入任務佇列,這些回撥函式根據類放入到 tasksmicrotasks 中,最先被推入的函式先被推入 stack 執行,是先進先出的資料結構。由於有定時器這類功能, stack 一般要檢查時間後,某些任務才會被執行。

? 事件迴圈

一旦 stack 沒任務了,JavaScript 引擎就會去讀取任務佇列,這個過程會迴圈不斷,被叫做事件迴圈。

? setTimeout、setInterval

上文講的定時功能,依靠 setTimeout、setInterval 提供的定時功能,區別在於 setTimeout 在指定時間後執行一次,而 setInterval 則重複執行。

setTimeout 在任務佇列尾部新增了一個事件,在設定的時間後執行。但實際沒有這麼理想,當任務佇列前面的任務非常耗時,回撥函式不一定在設定的時間執行。

所以常見的寫法 setTimeout(fn, 0),是指定某個任務在 stack 最早可得的空閒時間執行,也就是說,儘可能早得執行。

(注意:HTML5 標準規定了 setTimeout 的第二個引數的最小值(最短間隔),不得低於 4 毫秒,如果低於這個值,就會自動增加。)

⛈ task 與 microtask

先看一個例子:

console.log(1)

setTimeout(() => {
  console.log(2)
}, 0)

Promise.resolve()
  .then(() => {
    console.log(3)
  })
  .then(() => {
    console.log(4)
  })

console.log(5)
複製程式碼

列印出來為:1,5,3,4,2。why? ☃️

? 初探

從上文知道,每個執行緒都有自己的事件迴圈,都是獨立執行的。事件迴圈裡面有 task 佇列 和 mircotask 佇列,佇列裡面都按順序存放著不同的待執行任務,這些任務從不同源劃分的。

tasks 包含生成 dom 物件、解析 HTML、執行主執行緒 js 程式碼、更改當前 URL 還有其他的一些事件如頁面載入、輸入、網路事件和定時器事件。從瀏覽器的角度來看,tasks 代表一些離散的獨立的工作。當執行完一個 task 後,瀏覽器可以繼續其他的工作如頁面重渲染和垃圾回收。

microtasks 則是完成一些更新應用程式狀態的較小任務,如處理 promise 的回撥和 DOM 的修改,這些任務在瀏覽器重渲染前執行。Microtask 應該以非同步的方式儘快執行,其開銷比執行一個新的 macrotask 要小。Microtasks 使得我們可以在 UI 重渲染之前執行某些任務,從而避免了不必要的 UI 渲染,這些渲染可能導致顯示的應用程式狀態不一致。

事件迴圈持續不斷執行,按順序執行 task 佇列,如例子中的 setTimeout, 在 tasks 之間,瀏覽器可以更新渲染。只要 stack 為空,mircotask 佇列就會處理,或者在每個 task 的末尾處理。在處理 mircotask 佇列期間,新新增的 microtask 新增到佇列的末尾並且也會被執行,如上文的 Promise then callback。

大概順序就是:

第一輪:檢查 task 佇列 -> 檢查 microtask 佇列 -> 檢查是否需要渲染更新 下 1 至 n 輪:...

☘ 源

一般來說,task 和 microtask 都有哪些:

task:

  • DOM 操作任務:以非阻塞方式插入文件
  • 使用者互動任務:滑鼠鍵盤事件、使用者輸入事件
  • 網路任務
  • IndexDB 資料庫操作等 I/O
  • setTimeout / setInterval
  • history.back
  • setImmediate(涉及 node,不在這裡討論,但歸納在這)

microtask:

  • Promise.then
  • MutationObserver
  • Object.observe
  • process.nextTick(涉及 node,不在這裡討論,但歸納在這)

Jake Archibald 大大 說:setImmediate is task-queuing, whereas nextTick is before other pending work such as I/O, so it's closer to microtasks.

? 小試牛刀

嘗試分析一下上面的例子:

  • Promise then 的回撥被分到了 microtask 佇列中
  • 當列印完 5 後,當前 script 已經執行完畢,開始按順序執行 microtask 佇列中的回撥,列印了 3
  • 接著遇到了下一個 Promise then 的回撥,也會被執行,列印 4,至此,microtask 佇列已空,開始下一輪 task
  • 執行下一個 task,列印 2

所以列印了 1,5,3,4,2

? 執行時機

Tasks 按照順序執行,瀏覽器可能在它們的間隔渲染檢視。

Microtasks 也是按順序執行的,執行的順序,在下面兩種情況下執行:

1. 在 task 執行完之後執行。

來看一個例子:

var outer = document.querySelector('.outer')
var inner = document.querySelector('.inner')

function onClick() {
  console.log('click')

  setTimeout(function() {
    console.log('timeout')
  }, 0)

  Promise.resolve().then(function() {
    console.log('promise')
  })
}

inner.addEventListener('click', onClick)
outer.addEventListener('click', onClick)
複製程式碼

線上檢視:Edit on CodeSandBox

截圖

microtasks

當點選 inner 後,console 列印:click,promise,click,promise,timeout,timeout。

執行過程:(用文字描述看不清楚,畫了個圖來一步一步根據)

觸發 inner 點選之後:

loop1

觸發 outer 點選之後:

loop2

2. 當 stack 為空的時候,便執行完 microtask 佇列裡面的任務。

可以在規範 html 規範: Cleaning up after a callback step 中找到:

If the JavaScript execution context stack is now empty, perform a microtask checkpoint.

我們把上面的例子改一下:

var outer = document.querySelector('.outer')
var inner = document.querySelector('.inner')

function onClick() {
  console.log('click')

  setTimeout(function() {
    console.log('timeout')
  }, 0)

  Promise.resolve().then(function() {
    console.log('promise')
  })
}

inner.addEventListener('click', onClick)
outer.addEventListener('click', onClick)

inner.click()
複製程式碼

加上 inner.click() 這句,情況變得不一樣,線上檢視:Edit on CodeSandBox

截圖

microtasks2

當點選 inner 後,console 列印:click,click,promise,promise,timeout,timeout。

執行過程:(還是畫圖)

觸發 inner 點選之後:

loop3

觸發 outer 點選之後:

loop4

這個例子與上一個不同,當執行完第 6 步,並沒有檢查 microtask 佇列,因為 stack 並沒為空,script 還在 stack 中。這也說明,上面的規則確保了 microtasks 不打斷當前程式碼執行。

聯絡Tasks, microtasks, queues and schedules 文中的解釋:

... The above rule ensures microtasks don't interrupt JavaScript that's mid-execution. This means we don't process the microtask queue between listener callbacks, they're processed after both listeners.

⛅️ 總結

  1. 事件迴圈持續不斷執行;
  2. 事件迴圈包含 task 佇列和 microtask 佇列;
  3. task 佇列和 microtask 佇列都是按照佇列內順訊執行的,即先進先出;
  4. tasks 之間(執行完 microtasks 之後),瀏覽器可以更新渲染;
  5. microtasks 不會打斷當前程式碼執行;
  6. 在 task 執行完之後執行,或者當 stack 為空時,檢查 microtask 佇列並執行其中的任務;
  7. 新新增的 microtask 新增到佇列的末尾並且也會被執行;
  8. 事件迴圈同一時間內只執行一個任務;
  9. 任務一直執行到完成,不能被其他任務搶斷。

? 參考

相關文章