引言
microtask 這一名詞是 JS 中比較新的概念,幾乎所有人都是在學習 ES6 的 Promise 時才接觸這一新概念,我也不例外。當我剛開始學習 Promise 的時候,對其中回調函數的執行方式特別着迷,於是乎便看到了 microtask 這一個單詞,但是困難的是國內很少有關於這方面的文章,有一小部分人探討過不過對其中的原理和機制的講解也是十分晦澀難懂。直到我看到了 Jake Archibald 的文章,我才對 microtask 有了一個完整的認識,所以我便想把這篇文章翻譯過來,供大家學習和參考。
本篇文章絕大部分翻譯自 Jake Archibald 的文章 Tasks, microtasks, queues and schedules。有英文功底的同學建議閱讀原著,畢竟人家比我寫的好...
適合人群:有一定的 JavaScript 開發基礎,對 JavaScript Event Loop 有基本的認識,掌握 ES6 Promise 。
初識 Microtask
讓我們先來看一段代碼,猜猜它將會以何種順序輸出:
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'。但是不同的瀏覽器可能會出現不同的輸出順序。
Microsoft Edge, FireFox 40, iOS Safari 以及 Safari 8.0.8 將會在 'promise1' 和 'promise2' 之前輸出 'setTimeout'。但是奇怪的是,FireFox 39 和 Safari 8.0.7 卻又是按照正確的順序輸出。
為什么?
要理解上面代碼的輸出原理,你就需要了解 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)
Task 是嚴格按照時間順序壓棧和執行的,所以瀏覽器能夠使得 JavaScript 內部任務與 DOM 任務能夠有序的執行。當一個 task 執行結束后,在下一個 task 執行開始前,瀏覽器可以對頁面進行重新渲染。每一個 task 都是需要分配的,例如從用戶的點擊操作到一個點擊事件,渲染HTML文檔,同時還有上面例子中的 setTimeout。
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…
注意在 ES 當中稱 microtask 為 jobs。
為什么不同的瀏覽器表現不同?
通過上面的例子可以測試出,FireFox 和 Safari 能夠正確的執行 microtask 隊列,這一點可以通過 MutationObserver 的表現中看出,不過 Promise 被添加至事件隊列中的方式好像有些不同。 這一點也是能夠理解的,由於 jobs 和 microtasks 的關系以及概念目前還比較模糊,不過人們都普遍的期望他們都能夠在兩個事件監聽器之間執行。這里有 FireFox 和 Safari 的 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 上面,可隨意轉載(還是注明一下出處吧...)。