[譯] 深刻理解 JavaScript 事件循環(二)— task and microtask

引言

  microtask 這一名詞是 JS 中比較新的概念,幾乎全部人都是在學習 ES6 的 Promise 時才接觸這一新概念,我也不例外。當我剛開始學習 Promise 的時候,對其中回調函數的執行方式特別着迷,因而乎便看到了 microtask 這一個單詞,可是困難的是國內不多有關於這方面的文章,有一小部分人探討過不過對其中的原理和機制的講解也是十分晦澀難懂。直到我看到了 Jake Archibald 的文章,我纔對 microtask 有了一個完整的認識,因此我便想把這篇文章翻譯過來,供你們學習和參考。html

  本篇文章絕大部分翻譯自 Jake Archibald 的文章 Tasks, microtasks, queues and schedules。有英文功底的同窗建議閱讀原著,畢竟人家比我寫的好...git

  適合人羣:有必定的 JavaScript 開發基礎,對 JavaScript Event Loop 有基本的認識,掌握 ES6 Promise 。github

初識 Microtask

  讓咱們先來看一段代碼,猜猜它將會以何種順序輸出:web

 1 console.log('script start');
 2 
 3 setTimeout(function() {
 4   console.log('setTimeout');
 5 }, 0);
 6 
 7 Promise.resolve().then(function() {
 8   console.log('promise1');
 9 }).then(function() {
10   console.log('promise2');
11 });
12 
13 console.log('script end');

  你能夠在這裏查看輸出結果:編程

   正確的答案是:'script start''script end''promise1''promise2''setTimeout'。可是不一樣的瀏覽器可能會出現不一樣的輸出順序。api

  Microsoft Edge, FireFox 40, iOS Safari 以及 Safari 8.0.8 將會在 'promise1''promise2' 以前輸出 'setTimeout'。可是奇怪的是,FireFox 39 和 Safari 8.0.7 卻又是按照正確的順序輸出。promise

 爲何?

  要理解上面代碼的輸出原理,你就須要瞭解 JavaScript 的 event loop 是如何處理 tasks 以及 microtasks,當你第一次看到這一堆概念的時候,相信你也是和我同樣的一頭霧水,別急,讓咱們先深呼吸一下,而後開始咱們的 microtask 之旅。瀏覽器

  每個「線程」都有一個獨立的 event loop,每個 web worker 也有一個獨立的 event loop,因此它能夠獨立的運行。若是不是這樣的話,那麼全部的窗口都將共享一個 event loop,即便它們能夠同步的通訊。event loop 將會持續不斷的,有序的執行隊列中的任務(tasks)。每個 event loop 都有着衆多不一樣的任務來源(task source),這些 task source 可以保證其中的 task 可以有序的執行(參見標準 Indexed Database API 2.0)。不過,在每一輪事件循環結束以後,瀏覽器能夠自行選擇將哪個 source 當中的 task 加入到執行隊列當中。這樣也就使得了瀏覽器能夠優先選擇那些敏感性的任務,例如用戶的的輸入。(看完這段話,估計大部分人都暈了,別急... be patient)app

  Task 是嚴格按照時間順序壓棧和執行的,因此瀏覽器可以使得 JavaScript 內部任務與 DOM 任務可以有序的執行。當一個 task 執行結束後,在下一個 task 執行開始前,瀏覽器能夠對頁面進行從新渲染。每個 task 都是須要分配的,例如從用戶的點擊操做到一個點擊事件,渲染HTML文檔,同時還有上面例子中的 setTimeoutdom

  setTimeout 的工做原理相信你們應該都知道,其中的延遲並非徹底精確的,這是由於 setTimeout 它會在延遲時間結束後分配一個新的 task 至 event loop 中,而不是當即執行,因此 setTimeout 的回調函數會等待前面的 task 都執行結束後再運行。這就是爲何 'setTimeout' 會輸出在 'script end' 以後,由於 'script end' 是第一個 task 的其中一部分,而 'setTimeout' 則是一個新的 task。這裏咱們先解釋了 event loop 的基本原理,接下來咱們會經過這個來說解 microtask 的工做原理。

  Microtask 一般來講就是須要在當前 task 執行結束後當即執行的任務,例如須要對一系列的任務作出迴應,或者是須要異步的執行任務而又不須要分配一個新的 task,這樣即可以減少一點性能的開銷。microtask 任務隊列是一個與 task 任務隊列相互獨立的隊列,microtask 任務將會在每個 task 任務執行結束以後執行。每個 task 中產生的 microtask 都將會添加到 microtask 隊列中,microtask 中產生的 microtask 將會添加至當前隊列的尾部,而且 microtask 會按序的處理完隊列中的全部任務。microtask 類型的任務目前包括了 MutationObserver 以及 Promise 的回調函數。

  每當一個 Promise 被決議(或是被拒絕),便會將其回調函數添加至 microtask 任務隊列中做爲一個新的 microtask 。這也保證了 Promise 能夠異步的執行。因此當咱們調用 .then(resolve, reject) 的時候,會當即生成一個新的 microtask 添加至隊列中,這就是爲何上面的 'promise1' 和 'promise2' 會輸出在 'script end' 以後,由於 microtask 任務隊列中的任務必須等待當前 task 執行結束後再執行,而 'promise1''promise2' 輸出在 'setTimeout' 以前,這是由於 'setTimeout' 是一個新的 task,而 microtask 執行在當前 task 結束以後,下一個 task 開始以前。

  下面這個 demo 將會逐步的分析 event loop 的運做方式:

   經過以上的 demo 相信你們對 microtask 的運做方式有了瞭解了吧,不得不說我十分佩服 Jake Archibald ,人家本身一個字一個字的碼了一個事件輪循器出來。做爲一位膜拜者,我也一個字一個字的碼了一個出來!...詳情可參見引言中貼出的文章。

 瀏覽器的兼容性

  有一些瀏覽器會輸出:'script start''script end''setTimeout''promise1''promise2'。這些瀏覽器將會在 'setTimeout' 以後輸出 Promise 的回調函數,這看起來像是這類瀏覽器不支持 microtask 而將 Promise 的回調函數做爲一個新的 task 來執行。

  不過這一點也是能夠理解的,由於 Promise 是來自於 ECMAScript 而不是 HTML。ES 當中有一個 「jobs」 的概念,它和 microtask 很類似,不過他們之間的關係目前尚未一個明確的定義。不過,廣泛的共識都認爲,Promise 的回調函數是應該做爲一個 microtask 來運行的。

  若是說把 Promise 當作一個新的 task 來執行的話,這將會形成一些性能上的問題,由於 Promise 的回調函數可能會被延遲執行,由於在每個 task 執行結束後瀏覽器可能會進行一些渲染工做。因爲做爲一個 task 將會和其餘任務來源(task source)相互影響,這也會形成一些不肯定性,同時這也將打破一些與其餘 API 的交互,這樣一來便會形成一系列的問題。

  Edge 瀏覽器目前已經修復了這個問題(an Edge ticket),WebKit 彷佛始終是標準的,Safari 終究也會修復這個問題,在 FireFox 43 中這個問題也已被修復。

 如何判斷 task 和 microtask

  直接測試輸出是個很好的辦法,看看輸出的順序是更像 Promise 仍是更像 setTimeout,趨向於 Promise 的則是 microtask,趨向於 setTimeout 的則是 task。

  還有一種明確的方式是查看標準。例如,timer-initialisation-steps 標準的第 16 步指出 「Queue the task task」。(注意原文中指出的是 14 步,正確是應該是 16 步。)而 queue-a-mutation-record 標準的第 5 步指出 「Queue a mutation observer compound microtask」。

  同時須要注意的是,在 ES 當中稱 microtask 爲 「jobs」。好比 ES6標準 8.4節當中的 「EnqueueJob」 意思指添加一個 microtask。

  如今,讓咱們來一個更復雜的例子...

 進階 microtask

  在此以前,你須要瞭解 MutationObserver 的使用方法

1 <div class="outer">
2   <div class="inner"></div>
3 </div>
 1 var outer = document.querySelector('.outer');
 2 var inner = document.querySelector('.inner');
 3 
 4 // 給 outer 添加一個觀察者
 5 new MutationObserver(function() {
 6   console.log('mutate');
 7 }).observe(outer, {
 8   attributes: true
 9 });
10 
11 // click 回調函數
12 function onClick() {
13   console.log('click');
14 
15   setTimeout(function() {
16     console.log('timeout');
17   }, 0);
18 
19   Promise.resolve().then(function() {
20     console.log('promise');
21   });
22 
23   outer.setAttribute('data-random', Math.random());
24 }
25 
26 inner.addEventListener('click', onClick);
27 outer.addEventListener('click', onClick);

  先試着猜猜看程序將會如何輸出,你能夠在這裏查看輸出結果:

   猜對了嗎?不過在這裏不一樣的瀏覽器可能會有不一樣的結果。

Chrome FireFox Safari Edge
click click   click click
promise mutate mutate click
mutate click click mutate
click mutate mutate timeout
promise timeout promise promise
mutate promise promise timeout
timeout promise timeout promise
timeout timeout timeout  

 誰是正確答案?

   click 的回調函數是一個 task,而 Promise 和 MutationObserver 是一個 microtask,setTimeout 是一個 task,因此讓咱們一步一步的來:

   經過以上 demo 咱們能夠看出,Chrome 給出的是正確答案,這裏有一點與以前 demo 不一樣之處在於,這裏的 task 是一個回調函數而不是當前執行的腳本,因此咱們能夠得出結論:用戶操做的回調函數也是一個 task ,而且只要一個 task 執行結束且 JS stack 爲空時,這時便檢查 microtask ,若是不爲空,則執行 microtask 隊列。咱們能夠參見 HTML 標準:

If the stack of script settings objects is now empty, perform a microtask checkpoint

— HTML: Cleaning up after a callback step 3

 

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

  注意在 ES 當中稱 microtask 爲 jobs。

 爲何不一樣的瀏覽器表現不一樣?

  經過上面的例子能夠測試出,FireFox 和 Safari 可以正確的執行 microtask 隊列,這一點能夠經過 MutationObserver 的表現中看出,不過 Promise 被添加至事件隊列中的方式好像有些不一樣。 這一點也是可以理解的,因爲 jobs 和 microtasks 的關係以及概念目前還比較模糊,不過人們都廣泛的指望他們都可以在兩個事件監聽器之間執行。這裏有 FireFoxSafari 的 BUG 記錄。(目前 Safari 已經修復了這一 BUG)

  在 Edge 中咱們能夠明顯的看出其壓入 Promise 的方式是錯誤的,同時其執行 microtask 隊列的方式也不正確,它沒有在兩個事件監聽器之間執行,反而是在全部的事件監聽器以後執行,因此纔會只輸出了一次 mutate 。Edge bug ticket (目前已修復)

 駕馭 microtask

  到了這裏,相信你們已經習得了 microtask 的運行機制了吧,不過咱們用以上的例子再作一點點小變化,好比咱們運行一個:

1 inner.click();

  看看會發生什麼?

   一樣,這裏不一樣的瀏覽器表現也是不同的:

Chrome FireFox Safari  Edge 
click click   click click
click click click click
promise mutate mutate mutate
mutate timeout promise timeout
promise promise promise promise
timeout promise timeout timeout
timeout timeout timeout promise

  奇怪的是,在 Chrome 的個別版本里可能會獲得不一樣的結果,究竟誰是正確答案?讓咱們一步一步的分析:

   從上面 demo 能夠看出,正確的答案應該是:'click''click''promise''mutate''promise''timeout''timeout'。因此看來 Chrome 給出的是正確答案。

  在前一個 demo 中,microtask 將會在兩個 click 時間監聽器之間運行,可是在這個 demo 中,因爲咱們調用 .click() ,使得事件監聽器的回調函數和當前運行的腳本同步執行而不是異步,因此當前腳本的執行棧會一直壓在 JS 執行棧 當中。因此在這個 demo 中 microtask 不會在每個 click 事件以後執行,而是在兩個 click 事件執行完成以後執行。因此在這裏咱們能夠再次的對 microtask 的檢查點進行定義:當執行棧(JS Stack)爲空時,執行一次 microtask 檢查點。這也確保了不管是一個 task 仍是一個 microtask 在執行完畢以後都會生成一個 microtask 檢查點,也保證了 microtask 隊列可以一次性執行完畢。 

 總結

  關於 microtask 的講解就到此結束了,同窗們有沒有一種漸入佳境的感受呢?如今咱們來對 microtask 進行一下總結:

  •   microtask 和 task 同樣嚴格按照時間前後順序執行。
  •   microtask 類型的任務包括 Promise callback 和 Mutation callback。
  •    當 JS 執行棧爲空時,便生成一個 microtask 檢查點。

  JS 的 Event Loop 一直以來都是一個比較重要的部分,雖然在學完了事後一會兒感受不出有什麼具體的卵用...可是,一旦 Event Loop 的運行機制印入了你的腦海裏以後,對你的編程能力和程序設計能力的提升是幫助很大的。關於 Event Loop 的知識不多有相關的書籍有寫到,一是由於這一塊比較晦澀難懂,短期內沒法領略其精髓,二是由於具體能力提高不明顯,不如認識幾個 API 來的快,可是這倒是咱們編程的內力,他能在潛意識中左右着咱們編程時思考問題的方式。

  本文的 demo 都放在了 jsfiddle 上面,可隨意轉載(仍是註明一下出處吧...)。

相關文章
相關標籤/搜索