JS的Event Loop 和 microTask

Vincent Ko發表於2018-08-05

面試和筆試題目中,經常會出現'promise','setTimeout'等函式混合出現時候的執行順序問題。 我們都知道這些非同步的方法會在當前任務執行結束之後呼叫,但為什麼'promise'會在'setTimeout'之前執行? 具體的實現原理是什麼?

有和我一樣正在為秋招offer奮鬥的小夥伴,歡迎到github獲取更多我的總結和踩過的坑,一起進步→→→→傳送門

問題的提出

上面問題的答案,都在文章《Tasks, microtasks, queues and schedules》講的非常透徹。 建議英文可以的同學直接看這篇文章,就不要看我這個“筆記”了。( 之所以叫筆記,因為大部分內容出自文章,但是又不是按字翻譯 )

以下的題目是我們刷題可以經常看到的一個常規題目:

console.log('script start');

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

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');
複製程式碼

幾乎每個前端er都可以毫不猶豫的給出答案:

script start
script end
promise1
promise2
setTimeout
複製程式碼

問題來了,為什麼promise的非同步執行會在setTimeout之前,甚至setTimeout設定的延時是0都不行。 還有在Vue中,我們常用的nextTick()函式原理中,說的microtasks是什麼東西? 一切的解釋都在開頭給的文章中。

ps: 再次再次宣告,這篇文章仍然是我記得筆記,原文比我寫的好得多,英文可以的小夥伴強烈推薦看原文。

js非同步實現原理

我們多多少少都應該聽說過event loop,js是單執行緒的,通過非同步它變得非常強大,而實現非同步主要就是通過將非同步的內容壓入tasks,當前任務執行結束之後,再執行tasks中的callback。

Tasks,是一個任務佇列,Js在執行同步任務的時候,只要遇到了非同步執行和函式,都會把這個內容壓入Tasks中,然後在當前同步任務完成後,再去Tasks中執行相應的回撥。 舉個例子,比如剛才程式碼中的setTimeout,當遇到這個函式,總會跟一個非同步執行的任務(callback),那麼這個時候,Tasks佇列裡,除了當前正在執行的script之外,會在後面壓入一個setTimeout callback, 而這個callback的呼叫時機,就是在當前同步任務完成之後,才會呼叫。這就是為什麼,'setTimeout' 會出現在'script end'之後了。

MicroTasks,說一些這個,這個和setTimeout不同,因為它是在當前Task完成後,就立即執行的,或者可以理解成,'microTasks總是在當前任務的最後執行'。 另外,還有一個非常重要的特性是: 如果當前JS stack如果為空的時候(比如我們繫結了click事件後,等待和監聽click時間的時候,JS stack就是空的),一會立即執行。 關於這一點,之後有個例子會具體說明,先往下看。

那麼MicroTasks佇列主要是promise和mutation observer 的回掉函式生成

用新的理論來解釋下

好了,剛才大概說了幾個概念,那麼一開始的例子,到底發生了什麼?

talk is cheap, show me a animation!!---我自己說的

下面的動畫說明對整個過程進行了說明:

原文中的動態演示

1、 程式執行 log: script start

  • Tasks: Run script
  • JS stack: script

2、 遇到setTimeout log: script start

  • Tasks: Run script | setTimeout callback
  • JS stack: script

3、 遇到Promise

  • Tasks: Run script | setTimeout callback
  • Microtasks: promise then
  • JS stack: script

4、 執行最後一行 log: script start | script end

  • Tasks: Run script | setTimeout callback
  • Microtasks: promise then
  • JS stack: script

4、 同步任務執行完畢,彈出相應的stack log: script start | script end

  • Tasks: Run script | setTimeout callback
  • Microtasks: promise then
  • JS stack:

5、 同步任務最後是microTasks,JS stack壓入callback log: script start | script end | promise1

  • Tasks: Run script | setTimeout callback
  • Microtasks: promise then | promise then
  • JS stack: promise1 calback 6、 promise返回新的promise,壓入microTasks,繼續執行 log: script start | script end | promise1 | promise2
  • Tasks: Run script | setTimeout callback
  • Microtasks: promise then
  • JS stack: promise2 calback

8、 第一個Tasks結束,彈出 log: script start | script end | promise1 | promise2

  • Tasks: setTimeout callback
  • Microtasks:
  • JS stack:

9、 下一個Tasks log: script start | script end | promise1 | promise2 | setTimeout

  • Tasks: setTimeout callback
  • Microtasks:
  • JS stack: setTimeout callback

好了,結束了,這就比之前的理解"promise比setTimeout快,非同步先執行promise,再執行setTimeout"就深刻的多。 因為promise所建立的回掉函式是壓入了mircroTasks佇列中,它仍然屬於當前的Task,而setTimeout則是相當於在Task序列中新增了新的任務

一個更復雜的例子

好了,有了剛才的認識和鋪墊,接下來通過一個更加複雜的例子來熟悉JS事件處理的一個過程。

現在有這樣一個頁面結構:

<div class="outer">
  <div class="inner"></div>
</div>

複製程式碼

JS的Event Loop 和 microTask
js程式碼如下,現在如果點選裡面的方塊,控制檯會輸出什麼呢?線上例項

// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});

// Here's a click listener…
function onClick() {
  console.log('click');

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

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

  outer.setAttribute('data-random', Math.random());
}

// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
複製程式碼

這裡先把正確答案公佈,按照之前的理論,正確答案應該是:

click
promise
mutate
click
promise
mutate
timeout
timeout
複製程式碼

當然,不同瀏覽器,對於event loop的實現會稍有不同,這個是chrome下列印出來的結果,具體的其他形式還是推薦大家看原文了。

下面分析下,為什麼是上面的順序呢?

程式碼分析

按照剛才的結論:

click事件顯然是一個Task,Mutation observer和Promise是在microTasks佇列中的,而setTimeout會被安排在Tasks之中。 因此

1、點選事件觸發

  • Tasks: Dispatch click
  • Microtasks:
  • JS stack:

2、觸發點選事件的函式,函式執行,壓入JS stack

  • Tasks: Dispatch click
  • Microtasks:
  • JS stack: onClick
  • Log: 'click'

3、遇到setTimeout,壓入Tasks佇列

  • Tasks: Dispatch click | setTimeout callBack
  • Microtasks:
  • JS stack: onClick
  • Log: 'click'

4、遇到promise,壓入Microtasks

  • Tasks: Dispatch click | setTimeout callBack
  • Microtasks: Promise.then
  • JS stack: onClick
  • Log: 'click'

5、遇到 outer.setAttribute,觸發mutation

  • Tasks: Dispatch click | setTimeout callBack
  • Microtasks: Promise.then | Mutation observers
  • JS stack: onClick
  • Log: 'click'

6、onclick函式執行完畢,出JS stack

  • Tasks: Dispatch click | setTimeout callBack
  • Microtasks: Promise.then | Mutation observers
  • JS stack:
  • Log: 'click'

7、這個時候,JS stack為空,執行Microtasks

  • Tasks: Dispatch click | setTimeout callBack
  • Microtasks: Promise.then | Mutation observers
  • JS stack: PromiseCallback
  • Log: 'click' 'promise'

8、microtasks順序執行

  • Tasks: Dispatch click | setTimeout callBack
  • Microtasks: Mutation observers
  • JS stack: Mutation callback
  • Log: 'click' 'promise' 'mutate'

接下來是重點,當microtasks為空,該執行下一個Tasks(setTimeout)了嗎?並沒有,因為js事件流中的冒泡被觸發,也就是在外面的一層Div也會觸發click函式,因此我們把剛才的步驟再走一遍。

過程省略,結果為 9、冒泡走一遍的結果為

  • Tasks: Dispatch click | setTimeout callBack | setTmeout callback(outer)
  • Microtasks: Mutation observers
  • JS stack: Mutation callback
  • Log: click promise mutate click promise mutate

10、 第一個Tasks完成,出棧

  • Tasks: setTimeout callBack | setTmeout callback(outer)
  • Microtasks:
  • JS stack: setTimeout callback
  • Log: click promise mutate click promise mutate timeout

11、 第二個Tasks完成,出棧

  • Tasks: setTmeout callback(outer)
  • Microtasks:
  • JS stack: setTimeout(outer) callback
  • Log: click promise mutate click promise mutate timeout timeout

結束了

所以這裡的重點是什麼? 是MicroTasks的執行時機: 見縫插針,它不一定就必須在Tasks的最後,只要JS stack為空,就可以執行 這條規則出處在

If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3

另一方面,ECMA也對此有過說明

Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty…
— ECMAScript: Jobs and Job Queues

但是對於其他瀏覽器(firefox safari ie)同樣的程式碼,得出的結果是不同的哦。關鍵在於,對與 jobmicroTasks之間的一個聯絡是很模糊的。 但是我們就按照Chrome的實現來理解吧。

最後一關

還是剛才那道題,只不過,我不用滑鼠點選了,而是直接執行函式

inner.click()
複製程式碼

如果這樣,結果會一樣嗎?

答案是:

click
click
promise
mutate
promise
timeout 
timeout
複製程式碼

What!!??我怎麼感覺我白學了? 不著急,看下這次的過程是這樣的,首先最大的不同在於,我們在函式最底部加了一個執行inner.click(),這樣子,這個函式執行的過程,都是同步序列裡的,所以這次的task的起點就在Run scripts:

1、不同與滑鼠點選,我們執行函式後,進入函式內部執行

  • Tasks: Run scripts
  • Microtasks:
  • JS stack: script | onClick
  • Log: click

2、遇到setTimeout和promise&mutation

  • Tasks: Run scripts | setTimeout callback
  • Microtasks: Promise.then | Mutation Observers
  • JS stack: script | onClick
  • Log: click

3、接下來關鍵,冒泡的時候,因為我們並沒有執行完當前的script,還在inner.click()這個函式執行之中,因此當onclick結束,開始冒泡時,script並沒有結束

  • Tasks: Run scripts | setTimeout callback
  • Microtasks: Promise.then | Mutation Observers
  • JS stack: script | onClick(這是冒泡的click,第一次click已經結束)
  • Log: click click

4、冒泡階段重複之前內容

  • Tasks: Run scripts | setTimeout callback |setTimeout callback(outer)
  • Microtasks: Promise.then | Mutation Observers |promise.then
  • JS stack: script | onClick(這是冒泡的click,第一次click已經結束)
  • Log: click click

注意第二次沒有增加mutation,因為已經有一個在渲染的了

5、inner.click()執行完畢,執行Microtasks

  • Tasks: Run scripts | setTimeout callback |setTimeout callback(outer)
  • Microtasks: Promise.then | Mutation Observers |promise.then
  • JS stack:
  • Log: click click promise

6、按理論執行

  • Tasks: Run scripts | setTimeout callback |setTimeout callback(outer)
  • Microtasks: Mutation Observers |promise.then
  • JS stack:
  • Log: click click promise mutate....

後面的就不解釋了,Microtasks依次出棧,接著Tasks順序執行。

總結

Jake老師的文章,對這個的解析和深入實在令人佩服,我也在面試中因把event loop解釋的較為詳盡而被面試官肯定,所以如果對非同步以及event loop有疑惑的,可以好好的消化下這個內容,一起進步!

相關文章