我與Microtasks的前世今生之一眼望穿千年
2018年9月21日,雖然沒有參加該場GDD,但是也有幸拜讀了百度@小蘑菇小哥總結的文章 ofollow,noindex">深入瀏覽器的事件迴圈(GDD@2018) ,配注的說明插圖形象生動,文終的click程式碼也很有意思,推薦大家閱讀。這裡就先恬不知恥的將該文的精華以及一些自己的總結陳列如下:

非同步任務 | 特點 | 常見產生處 |
---|---|---|
Tasks (Macrotasks) | - 當次事件迴圈執行佇列內的一個任務 - 當次事件迴圈產生的新任務會在指定時機加入任務佇列等待執行 |
- setTimeout - setInterval - setImmediate - I/O |
Animation callbacks | - 渲染過程(Structure-Layout-Paint)前執行 - 當次事件迴圈 執行佇列裡的所有任務 - 當次事件迴圈 產生的新任務會在下一次迴圈執行 |
|
Microtasks | - 當次事件迴圈的結尾立即執行的任務 - 當次事件迴圈 執行佇列裡的所有任務 - 當次事件迴圈 產生的新任務會立即執行 |
- Promise - Object.observe - MutationObserver - process.nextTick |
直觀的感受一下Macrotasks和Microtasks
看過一篇公眾號文章下面的留言:
那個所謂的mtask和task的區別我並不認同...,我認為事件對列只有一個,就是task。
特別是對於JS非同步程式設計思維還不太熟悉的同學,比如兩年前從java轉成javascript後的我,對於這種非同步的呼叫順序其實很難理解。
不過有一個特別能說明Macrotasks和Microtasks的例子:
// 普通的遞迴, 造成死迴圈, 頁面無響應 function callback() { console.log('callback'); callback(); } callback(); 複製程式碼
上面的程式碼相信大家非常好理解,一個很簡單的遞迴,由於事件迴圈得不到釋放,UI渲染無法進行導致頁面無響應。
通常我們可以使用setTimeout來進行改造,我們把下一次執行放到非同步佇列裡面,不會持久的佔用計算資源,這就是我們說的Macrotasks:
// Macrotasks,不會造成死迴圈 function callback() { console.log('callback'); setTimeout(callback,0); } callback(); 複製程式碼
但是Promise回撥產生的Microtasks呢,如下程式碼,同樣會造成死迴圈。
通過上文我們也可以知道 當次事件迴圈產生的新Microtasks會立即執行 ,同時當次事件迴圈要等到所有Microtasks佇列執行完畢後才會結束。所以當我們的Microtasks在產生新的任務的同時,會導致Microtasks佇列一直有任務等待執行,這次事件迴圈永遠不會退出,也就導致了我們的死迴圈。
// Microtasks,同樣會造成死迴圈,頁面無響應 function callback() { console.log('callback'); Promise.resolve().then(callback); } callback(); 複製程式碼
Microtasks 與 Promise A+
當然,上文解決了本人關於Microtasks的相關疑慮 (
特別是有人拿出一段參雜setTimeout和Promise的程式碼讓你看程式碼輸出順序時
) 的同時,也讓我回憶起似乎曾幾何時也在哪裡看到過關於Microtask的字眼。
經過多日的尋找,終於在以前寫過的一片關於Promise的總結文章 開啟Promise的正確姿勢 裡找到了。該文通過一個例項說明了新建Promise的程式碼是會立即執行的,並不會放到非同步佇列裡:
var d = new Date(); // 建立一個promise例項,該例項在2秒後進入fulfilled狀態 var promise1 = new Promise(function (resolve,reject) { setTimeout(resolve,2000,'resolve from promise 1'); }); // 建立一個promise例項,該例項在1秒後進入fulfilled狀態 var promise2 = new Promise(function (resolve,reject) { setTimeout(resolve,1000,promise1); // resolve(promise1) }); promise2.then( result => console.log('result:',result,new Date() - d), error => console.log('error:',error) ) 複製程式碼
上面的程式碼輸出
result: resolve from promise 1 2002 複製程式碼
我們得到兩點結論:
- 驗證了Promise/A+中的2.3.2規範
- 新建Promise的程式碼時會立即執行的 (執行時間是2秒而不是3秒)
但是當時本人忽略了Promise/A+的相關注解內容:
Here “platform code” means engine,environment,and promise implementation code. In practice,this requirement ensures that onFulfilled
and onRejected
execute asynchronously,after the event loop turn in which then
is called,and with a fresh stack. This can be implemented with either a “macro-task” mechanism such as setTimeout
or setImmediate
,or with a “micro-task” mechanism such as MutationObserver
or process.nextTick
. Since the promise implementation is considered platform code,it may itself contain a task-scheduling queue or “trampoline” in which the handlers are called.
是的,這就是本人與MicroTasks的第一次相遇,沒有一見鍾情還真是非常抱歉啊。
該註解說明了Promise的 onFulfilled
和 onRejected
回撥的執行只要確保是在 then
被呼叫後非同步執行就可以了。具體實現成 setTimeout
似的 macrotasks 機制或者 process.nextTick
似的microtasks機制都可以,具體視平臺程式碼而定。
為什麼需要Microtasks
搜尋引擎能找到的相關文章基本都指向了一篇 《Tasks,microtasks,queues and schedules》 ,也許這就是傳說中原罪的發源之地吧。
Microtasksare usually scheduled for things that should happen straight after the currently executing script,such as reacting to a batch of actions,or to make something async without taking the penalty of a whole new task.
簡單來說,就是希望對一系列的任務做出迴應或者執行非同步操作,但是又不想額外付出一整個非同步任務的代價。在這種情況下,Microtasks就可以用來排程這些 應當在當前執行指令碼結束後立馬執行的任務 。
The microtask queue is processed after callbacks as long as no other JavaScript is mid-execution,and at the end of each task. Any additional microtasks queued during microtasks are added to the end of the queue and also processed.
單獨看Macrotasks和 Microtasks,執行順序可以總結如下:
- 取出Macrotasks任務佇列的一個任務,執行;
- 取出Microtasks任務佇列的所有任務,依次執行;
- 本次事件迴圈結束,等待下次事件迴圈;
從這個方面我們也可以理解為什麼Promise.then要被實現成Microtasks,回撥在實現Promise/A+規範 (必須是非同步執行)的基礎上,也保證能夠更快的被執行,而不是跟Macrotasks一樣必須等到下次事件迴圈才能執行。大家可以重新執行一下上文對比Macrotasks和Microtasks時舉的例子,也會發現他們兩的單位時間內的執行次數是不一樣的。
可以試想一些綜合了非同步任務和同步任務的的Promise例項,Microtasks可以保證它們更快的得到執行資源,例如:
new Promise((resolve) => { if(/* 檢查資源是否需要非同步載入 */) { return asyncAction().then(resolve); } // 直接返回載入好的非同步資源 return syncResource; }); 複製程式碼
如果上面的程式碼是為了載入遠端的資源,那麼只有第一次需要執行非同步載入,後面的所有執行都可以直接同步讀取快取內容。如果使用Microtasks,我們也就不用每次都等待多一次的事件迴圈來獲取該資源,Promise例項的新建過程是立即執行的,同時 onFulfilled
回撥也是在本次事件迴圈中全部執行完畢的,減少了切換上下文的成本,提高了效能。
但是呢,從上文關於Promise/A+規範的引用中我們已經知道不同瀏覽器對於該實現是不一致的。部分瀏覽器 (越來越少) 將Promise的回撥函式實現成了Macrotasks,原因就在於Promise的定義來自ECMAScript而不是HTML。
A Job is an abstract operation that initiates an ECMAScript computation when no other ECMAScript computation is currently in progress. A Job abstract operation may be defined to accept an arbitrary set of job parameters.
按照ECMAScript的規範,是沒有Microtasks的相關定義的,類似的有一個 jobs
的概念,和Microtasks很相似.
相關應用
Vue - src/core/utils/next-tick.js 中也有相關Macrotask和Microtask的實現
let microTimerFunc let macroTimerFunc if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { macroTimerFunc = () => { setImmediate(flushCallbacks) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // PhantomJS MessageChannel.toString() === '[object MessageChannelConstructor]' )) { const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = flushCallbacks macroTimerFunc = () => { port.postMessage(1) } } else { /* istanbul ignore next */ macroTimerFunc = () => { setTimeout(flushCallbacks,0) } } // Determine microtask defer implementation. /* istanbul ignore next,$flow-disable-line */ if (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve() microTimerFunc = () => { p.then(flushCallbacks) // in problematic UIWebViews,Promise.then doesn't completely break,but // it can get stuck in a weird state where callbacks are pushed into the // microtask queue but the queue isn't being flushed,until the browser // needs to do some other work,e.g. handle a timer. Therefore we can // "force" the microtask queue to be flushed by adding an empty timer. if (isIOS) setTimeout(noop) } } else { // fallback to macro microTimerFunc = macroTimerFunc } 複製程式碼