咱們都知道 setTimeout 和 Promise 並不在一個異步隊列中,前者屬於宏任務(MacroTask),然後者屬於微任務(MicroTask)。html
不少文章在介紹宏任務和微任務的差別時,每每用一個相似於 ++i++++
同樣的題目讓你們猜想不一樣任務的執行前後。這麼作雖然能夠精確的理解宏任務和微任務的執行時序,但卻讓人對於它們之間真正的差別摸不着頭腦。c++
更重要的是,咱們徹底不該該依賴這個微小的時序差別進行開發(正如同在 c++ 中不該該依賴未定義行爲同樣)。雖然宏任務和微任務的定義是存在於標準中的,可是不一樣的運行環境並不必定可以精準的遵循標準,並且某些場景下的 Promise
是各類千奇百怪的 polyfill。git
不管是宏任務仍是微任務,首先都是異步任務。在 JavaScript 中的異步是靠事件循環來實現的,拿你們最多見的 setTimeout 爲例。web
// 同步代碼 let count = 1; setTimeout(() => { // 異步 count = 2; }, 0); // 同步 count = 3; 複製代碼
一個異步任務會被丟到事件循環的隊列中,而這部分代碼會在接下來同步執行的代碼後面才執行(這個時序老是可靠的)。每次事件循環中,瀏覽器會執行隊列中的任務,而後進入下一個事件循環。編程
當瀏覽器須要作一些渲染工做時,會等待這一幀的渲染工做完成,再進入下一個事件循環vim
那麼,爲何已經有了這麼一個機制,爲何又要有所謂的微任務呢,難道只是爲了讓你們猜想不一樣異步任務的執行時序麼?promise
咱們來看一個 async function
的例子瀏覽器
const asyncTick = () => Promise.resolve(); (async function(){ for (let i = 0; i < 10; i++) { await asyncTick(); } })() 複製代碼
咱們看到這裏明明其實沒有異步等待的任務,可是若是 Promise.resolve
每次都和 setTimeout
同樣往異步隊列裏丟一個任務而後等待一個事件循環來執行。看起來彷佛沒有什麼大的問題,由於『事件循環』和一個 for
循環聽起來彷佛並無什麼本質上的不一樣。bash
而後在事實上,一次事件循環的耗時是遠遠超出一次 for 循環的。
咱們都知道 setTimeout(fn, 0)
並不是真的是當即執行,而是要等待至少 4ms
(事實上多是 10ms)纔會執行。
In modern browsers,
setTimeout()
/setInterval()
calls are throttled to a minimum of once every 4 ms when successive calls are triggered due to callback nesting (where the nesting level is at least a certain depth), or after certain number of successive intervals.
Note: 4 ms is specified by the HTML5 spec and is consistent across browsers released in 2010 and onward. Prior to (Firefox 5.0 / Thunderbird 5.0 / SeaMonkey 2.2), the minimum timeout value for nested timeouts was 10 ms.
這意味着若是沒有微任務的概念,咱們仍然採用宏任務的機制去執行 async function
(實際上就是 Promise
) ,性能會很是的糟糕。
並且對於正在執行一些複雜任務的頁面(例如繪製)就更加糟糕了,整個循環都會被這個任務直接阻塞。
微任務就是爲了適應這種場景,和宏任務最大的不一樣在於,若是在執行微任務的過程當中咱們往任務隊列中新增了任務,瀏覽器會所有消費掉爲止,再進入下一個循環。這也是爲何微任務和宏任務的時序上會存在差異。
看一個例子:
// setTimeout 版本 function test(){ console.log('test'); setTimeout(test); } test(); // Promise.resolve 版本 // 這會卡住你的標籤頁 function test(){ console.log('test'); Promise.resolve().then(test); } test(); // 同步版本 // 這會卡住你的標籤頁 function test(){ console.log('test'); test(); } test(); 複製代碼
你會發現 setTimeout
版本的頁面仍然可以操做,而控制檯上 test
的輸出次數在不斷增長。
而 Promise.resolve
和直接遞歸的表現是同樣的(其實有一些區別, Promise.resolve
仍然是異步執行的),標籤頁被卡住,Chrome Devtools 上的輸出次數隔一段時間蹦一下。
不得不說 Chrome 的 Devtools 優化的確實不錯,其實這裏已是死循環的狀態了,JS 線程被徹底阻塞
瞭解宏任務和微任務的差別有助於咱們理解 Promise 的性能。
咱們在實際生產中經常發現某些環境下的 Promise 的性能表現很是不如意,有些是不一樣容器的實現,另外一些則是不一樣版本的 polyfill 實現。尤爲是一些開發者會更傾向於體積更小的 polyfill
,例如這個有 1.3k Star
的實現
默認就是使用 setTimout
模擬的 Promise.resolve
,咱們在 jsperf.com/promise-per… 能夠看到性能的對比已經有了數量級的差距(事實上比較複雜的異步任務會感受到明顯的延遲)。
除了 Promise
是微任務外,還有不少 API 也是經過微任務設定的異步任務,其實若是有了解過 Vue
源碼的同窗就會注意到 Vue
的 $nextTick
源碼中,在沒有 Promise.resolve
時就是用 MutationObserver
模擬的。
看一個簡化的的 Vue.$nextTick
:
const timerFunc = (cb) => { let counter = 1 const observer = new MutationObserver(cb); const textNode = document.createTextNode(String(counter)) observer.observe(textNode, { characterData: true }) counter = (counter + 1) % 2 textNode.data = String(counter) } 複製代碼
原理其實很是簡單,手動構造一個 MutationObserver
而後觸發 DOM 元素的改變,從而觸發異步任務。
使用這種方式就明顯把數量級拉了回來
因爲這個 Promise 自己實現偏向於體積的緣故,這裏的 benchmark 性能仍有數倍差距,但其實
bluebird
等注重性能的實現方式在timer
函數用MutationObserver
構造的狀況下性能和原生不相上下,某些版本的瀏覽器下甚至更快
固然實際上 Vue 中的 NextTick
實現要更細緻一些,例如經過複用 MutationObserver
的方式避免屢次建立等。不過可以讓 Promise 實如今性能上拉開百倍差距的就只有宏任務和微任務之間的差別。
除
MutationObserver
外還有不少其餘的 API 使用的也是微任務,但從兼容性和性能角度MutationObserver
仍然是使用最普遍的。
宏任務和微任務在機制上的差別會致使不一樣的 Promise
實現產生巨大的性能差別,大到足以直接影響用戶的直接體感。因此咱們仍是要避免暴力引入 Promise polyfill
的方式,在現代瀏覽器上優先使用 Native Promise
,而在須要 polyfill 的地方則須要避免性能出現破壞性下滑的狀況。
另外,哪條 console.log
先執行看懂了就行了,真的不是問題的關鍵,由於你永遠不該該依賴宏任務和微任務的時序差別來編程。