宏任務、微任務和 Promise 的性能

背景

咱們都知道 setTimeout 和 Promise 並不在一個異步隊列中,前者屬於宏任務(MacroTask),然後者屬於微任務(MicroTask)。html

不少文章在介紹宏任務和微任務的差別時,每每用一個相似於 ++i++++ 同樣的題目讓你們猜想不一樣任務的執行前後。這麼作雖然能夠精確的理解宏任務和微任務的執行時序,但卻讓人對於它們之間真正的差別摸不着頭腦。c++

更重要的是,咱們徹底不該該依賴這個微小的時序差別進行開發(正如同在 c++ 中不該該依賴未定義行爲同樣)。雖然宏任務和微任務的定義是存在於標準中的,可是不一樣的運行環境並不必定可以精準的遵循標準,並且某些場景下的 Promise 是各類千奇百怪的 polyfill。git

總之,本文不關注執行時序上的差別,只關注性能。

github

異步

不管是宏任務仍是微任務,首先都是異步任務。在 JavaScript 中的異步是靠事件循環來實現的,拿你們最多見的 setTimeout 爲例。web

// 同步代碼
let count = 1;

setTimeout(() => {
    // 異步
  count = 2;
}, 0);

// 同步
count = 3;
複製代碼
Copy

一個異步任務會被丟到事件循環的隊列中,而這部分代碼會在接下來同步執行的代碼後面才執行(這個時序老是可靠的)。每次事件循環中,瀏覽器會執行隊列中的任務,而後進入下一個事件循環。編程

當瀏覽器須要作一些渲染工做時,會等待這一幀的渲染工做完成,再進入下一個事件循環vim

image.png

那麼,爲何已經有了這麼一個機制,爲何又要有所謂的微任務呢,難道只是爲了讓你們猜想不一樣異步任務的執行時序麼?promise

爲何要有微任務

咱們來看一個 async function 的例子瀏覽器

const asyncTick = () => Promise.resolve();

(async function(){
    for (let i = 0; i < 10; i++) {
    await asyncTick();
  }
})()
複製代碼
Copy

咱們看到這裏明明其實沒有異步等待的任務,可是若是 Promise.resolve 每次都和 setTimeout 同樣往異步隊列裏丟一個任務而後等待一個事件循環來執行。看起來彷佛沒有什麼大的問題,由於『事件循環』和一個 for 循環聽起來彷佛並無什麼本質上的不一樣。bash

而後在事實上,一次事件循環的耗時是遠遠超出一次 for 循環的。

咱們都知道 setTimeout(fn, 0) 並不是真的是當即執行,而是要等待至少 4ms (事實上多是 10ms)纔會執行。

MDN 相關文檔

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();
複製代碼
Copy

你會發現 setTimeout 版本的頁面仍然可以操做,而控制檯上 test 的輸出次數在不斷增長。

Promise.resolve 和直接遞歸的表現是同樣的(其實有一些區別, Promise.resolve 仍然是異步執行的),標籤頁被卡住,Chrome Devtools 上的輸出次數隔一段時間蹦一下。

不得不說 Chrome 的 Devtools 優化的確實不錯,其實這裏已是死循環的狀態了,JS 線程被徹底阻塞

Promise 的性能

瞭解宏任務和微任務的差別有助於咱們理解 Promise 的性能。

咱們在實際生產中經常發現某些環境下的 Promise 的性能表現很是不如意,有些是不一樣容器的實現,另外一些則是不一樣版本的 polyfill 實現。尤爲是一些開發者會更傾向於體積更小的 polyfill ,例如這個有 1.3k Star 的實現

github.com/taylorhakes…

默認就是使用 setTimout 模擬的 Promise.resolve ,咱們在 jsperf.com/promise-per… 能夠看到性能的對比已經有了數量級的差距(事實上比較複雜的異步任務會感受到明顯的延遲)。

image.png

如何正確的模擬 Promise.resolve

除了 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)
}
複製代碼
Copy

原理其實很是簡單,手動構造一個 MutationObserver 而後觸發 DOM 元素的改變,從而觸發異步任務。

使用這種方式就明顯把數量級拉了回來

image.png

因爲這個 Promise 自己實現偏向於體積的緣故,這裏的 benchmark 性能仍有數倍差距,但其實 bluebird 等注重性能的實現方式在 timer 函數用 MutationObserver 構造的狀況下性能和原生不相上下,某些版本的瀏覽器下甚至更快

image.png

固然實際上 Vue 中的 NextTick 實現要更細緻一些,例如經過複用 MutationObserver 的方式避免屢次建立等。不過可以讓 Promise 實如今性能上拉開百倍差距的就只有宏任務和微任務之間的差別。

MutationObserver 外還有不少其餘的 API 使用的也是微任務,但從兼容性和性能角度 MutationObserver 仍然是使用最普遍的。

總結

宏任務和微任務在機制上的差別會致使不一樣的 Promise 實現產生巨大的性能差別,大到足以直接影響用戶的直接體感。因此咱們仍是要避免暴力引入 Promise polyfill 的方式,在現代瀏覽器上優先使用 Native Promise ,而在須要 polyfill 的地方則須要避免性能出現破壞性下滑的狀況。

另外,哪條 console.log 先執行看懂了就行了,真的不是問題的關鍵,由於你永遠不該該依賴宏任務和微任務的時序差別來編程。

拓展閱讀

image.png

相關文章
相關標籤/搜索