舒適提示:如無特殊交代,本文所給出的示例代碼的執行環境均爲瀏覽器chrome v81.0.4044.92。javascript
在這個言必稱single threaded
,event loop
,microtask
,macrotask
......的javascript時代,相對深刻地去了解這些概念和概念背後實現的運行機制是十分有必要的。html
2014年的時候,Philip Roberts前後在Scotlan JS大會和JSConfEU大會發表了關於event loop的優秀演講,轟動業界(詳見個人演講整理)。這同時意味着event loop這個概念正式地進入到前端開發者的視野(於此同時,國內業界發生了著名的打臉事件,詳見JavaScript 運行機制詳解:再談Event Loop)。前端
隨着mutiple-processor計算機普及和前端做業愈來愈繁雜,javascript的併發編程愈來愈被重視。而javascript的併發模型是基於event loop機制的。因此,理解好event loop的實現機制可以幫助咱們在併發編程的大背景下,更好地優化和架構咱們的代碼。java
理由如此充分,那咱們還在等什麼?node
須要反覆強調的是,概念是人類有效溝通交流的基礎,更確切地說,將同一個(概念)「名」理解爲同一個「實」,即概念理解的一致性是人類有效溝通交流的基礎。概念落實到某個相關領域就稱之爲「術語」。鑑於不管是官方文檔仍是業內技術文章在使用術語的不一致性,咱們有必要梳理一下闡述event loop過程當中所涉及的術語,以下:react
在MDN的諸多闡述event loop相關的文檔中,都使用了這個術語。在這篇文檔中,task的定義是這麼下的:git
A task is any JavaScript code which is scheduled to be run by the standard mechanisms such as initially starting to run a program, an event callback being run, or an interval or timeout being fired.github
這篇文檔中說到:web
The tasks form a queue, so-called 「macrotask queue」 (v8 term)面試
能夠看到,咱們每天一口一個的macrotask,好比:setTimeout/setInterval等等的callback就是一個「task」。
task會被推入到一個隊列當中,等待調度。這個隊列就是task queue。在Philip Roberts的演講中,他提到了一個叫「callback queue」的術語,同時他也提到了,它就是「task queue」的別名。
macrotask === task,這裏就不贅述了。
macrotask queue === task queue === callback queue,這裏也不贅述了。
這篇MDN文檔,通篇下來都在用「message」這個術語,能夠看得出,這個message跟它對應的callback一塊兒,二者能夠統一稱之爲「task」。那麼裏面所說的「message queue」就是「task queue」。
MDN上如是說:
A microtask is a short function which is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop being used by the user agent to drive the script's execution environment.
簡單理解就是,microtask是一個「小」函數(正好呼應了它的名字:micro)。這個函數僅在JavaScript執行棧爲空,而且建立它的函數或程序退出後纔會執行。可是,它會在將控制權返回給用戶代理以前執行。「控制權返回給用戶代理以前」是什麼意思呢,其實就是「下一個event loop以前」的意思。
跟macrotask queue同樣,microtask queue也是用於存放microtask的隊列。
在jake archibald的這篇技術文章中,他指出了「job」的概念來自於ECMAScript規範,它與「microtask」幾乎等同,可是不徹底等同。業界對二者區別的闡述一直處於含糊不清的狀態。鑑於整篇文章下來,在他的闡述中,他已經把「job」等同於「microtask」了。因此,我也傾向於採用這種理解。
而另一位同行Daniel Chang 在他的技術文章Microtasks & Macrotasks — More On The Event Loop也秉持着一樣的見解:
In this write up I’ve been using the term task interchangeably between macrotask and microtask, much of the documentation out there refers to macrotasks as tasks and microtasks as job. Knowing this will make understanding documentation easier.
首先,咱們先去維基百科上面看看有關於call stack的定義。
在計算機科學中,call stack是一種存儲計算機程序當前正在執行的子程序(subroutine)信息的棧結構......使用call stack的主要緣由是保存一個用於追蹤【當前子程序執行完畢後,程序控制權應該歸還給誰】的指針.....一個call stack是由多個stack frame組成。每一個stack frame對應於一個子程序調用。做爲stack frame,這個子程序此時應該尚未被return語句所終結。舉個例子,咱們有一個叫DrawLine的子程序正在運行。這個子程序又被另一個叫作DrawSquare的子程序所調用,那麼call stack中頂部的佈局應該長成下面那樣:
結合維基百科給出的定義和Philip Roberts的演講,咱們再來看看MDN的諸多文檔通篇下來使用的「execution context stack」這個概念。顧名思義,「execution context stack」固然是由「execution context」組成的,那「execution context」又是什麼?不難理解,「execution context」就是維基百科給出示意圖中stack frame裏面的「Locals of xxx」,即函數執行所須要用到的上下文環境。
最後,咱們能夠看出,「execution context stack」其實就是「call stack」的子集。由於在event loop語境下,咱們不關心stack frame裏面的其餘「成分」:「parametres for xxx」和「return address」。因此,二者能夠等同起來理解。
有鑑於業內技術文章對這些術語的使用頻率,在本文的闡述中,相比於「task」,我會採用「macrotask」的叫法;相比於「job」,我會採用「microtask」的叫法;相比於「execution context stack」,我會採用「call stack」的叫法。
That is a deal!
從廣義上來講,event loop就是一種【user agents用於協調各類事件,用戶交互,腳本執行,網絡活動等執行時機的】調度機制。
實現event loop的user agent有好多個。好比說:
window
worker
worker又能夠分爲:
worklet
nodejs
實現event loop的通用算法(注意,這是簡單的,通用的算法)大概是這樣的:
用代碼來簡單表示就是:
while (queue.waitForTask()) {
queue.processNextTask()
}
複製代碼
做爲單線程的javascript就是經過event loop來實現了它的異步編程特性。
鑑於實現event loop的user agent之多和時間有限,我在這裏只是深刻討論瀏覽器中的event loop(特指window event loop)和nodejs中的event loop。
我這裏爲了通俗易懂,使用了「瀏覽器中的event loop」這種描述方式。在後面,如何特殊交代,它都是特指規範文檔裏面的「window event loop」。
上面指出,咱們將會統一使用「call stack」,「microtask」和「macrotask」等術語來闡述event loop。
在瀏覽器這個上下文中,macrotask有如下的種類:
microtask的種類有如下幾種:
以Chrome瀏覽器爲例,event loop的處理模型圖大概是下面這樣:
上圖中,須要事先交代兩點:
下面咱們解釋一下這個處理模型圖。
setTimeout(()=> {
console.log('time out');
});
複製代碼
那麼瀏覽器就會把這個setTimeout調用交給web API,而後把它從call stack中pop出來。web API接收到個setTimeout調用後,它會在本身的線程裏面啓動一個定時器,由於在這段代碼裏面,沒有傳遞time out時間,那麼就是默認的0。接着,web API沒有絲毫猶豫,它就把setTimeout的callback推入到它所屬的macrotask queue裏面。假如是遇到在promise對象身上調用then/catch/finally方法,那麼它們的callback最終會被web API推入到microtask queue中;假如遇到的是界面更新的DOM操做,那麼這些DOM操做就會被封裝成一個render callback,推入到render callback queue中。這些callback通過封裝後成爲一個task,靜靜地躺在各自的隊列中,等待調度。等待誰的調度呢?固然是等待event loop的調度。
注意,從標準規範的角度來看,「取出隊列中入隊時間最長的task」這種表述是不正確的,詳見規範文檔。可是至於在V8的內部,具體實現是如何的呢,目前就不得而知了。爲了易於理解和幫助推演,本文姑且採用這種表述方式。
而後把它推入到call stack去執行。跟macrotask queue和render callback queue不一樣的,一旦第一個microtask在call stack執行完以後,第二個microtask就會緊跟着推入到call stack去執行,而不是等到下一次的event loop纔會執行。也就是說,microtask queue中的全部 micrtask會被一次性執行完畢。
以上是理論表述,下面咱們結合一下實際的代碼來驗證並理解上面的話。
<script>
console.log(1);
setTimeout(()=>{
console.log(2);
}, 0);
new Promise(res=> {
res();
}).then(()=> {
console.log(3);
throw new Error('error');
}).catch(e=> {
console.log(4);
}).finally(()=> {
console.log(5);
});
console.log(6);
</script>
複製代碼
就像上面所說的那樣,對<script>標籤所包裹的代碼的執行是一個macrotask。爲了方面描述,咱們能夠把這段代碼的初始執行理解爲C語言中「main函數」。event loop首先執行macrotask,因此,「main()」調用推入到call stack中。這個時候,遇到同步代碼的console.log(),那麼就推入call stack,在瀏覽器控制檯打印1以後,console.log()就會被pop出call stack。接着下來,call stack會執行setTimeout(),call stack立刻把它交給web API,而後把它從call stack中彈走。由於web API的實現並不在js engine(特指V8)裏面,而是另一個線程裏面,因此js engine的執行跟web API的執行能夠是並行的。在call stack繼續往下執行的同時,web API會檢查setTimeout調用時傳入的表示須要延遲的time out,發現它爲默認的0,因而乎就立刻把相應的callback推入到macrotask中。與此同時,call stack執行到了new Promise().then().catch().finally()語句。值得注意的是,這段語句回分兩步執行:
(1)const temp = new Promise(executor);
(2)temp.then().catch().finally();
複製代碼
第一句promise實例的構造調用是屬於同步代碼,會在call stack中執行。對構造好promise實例的then/catch/finally方法的調用,都會交給web API,web API會在promise被reslove的時候,把這些方法所對應的callback推入的microtask queue中。由於在這裏,promise會被立刻reslove掉,於是then/catch/finally這三個方法的callback所以會立刻被推入到microtask queue中。
到了這裏,若是咱們只關注macrotask queue和microtask queue的話,而且爲了描述簡單起見,咱們用須要打印的數字來表明相應的task,那麼兩個隊列的應該是長下面這個樣子:
macrotask queue:
---------------------------
| 2 | |
---------------------------
microtask queue;
---------------------------
| 3 | 4 | 5 |
---------------------------
複製代碼
好,咱們繼續往下看,如今call stack依然有着「main()」佔據着,並不處於清空狀態。由於咱們還要一句「console.log(6)」沒執行。好吧,執行它,在瀏覽器打印出「6」,而後把它從call stack中pop走。到了這裏,咱們已經到達了「main」函數的底部,「main」函數調用完畢,因而它也從call stack中pop走。此時call stack終於處於清空狀態了。
好了,一直處於欲睡未睡狀態的event loop看到call stack爲空,它立刻就打起十二分精神了。由於「main」函數調用自己就是一個macrotask。輪完macrotask,那麼此次得輪到microtask queue了。一人一次,至關的公平,是吧?就想咱們上面所說的,microtask queue中全部的microtask是會被依次被推入到call stack,整個隊列會被一次性執行完並清空的。因此,瀏覽器控制檯會依次打印「3」,「4」和「5」。打印完畢後,call stack從新回到清空狀態。這一次, 輪到render callback queue了。由於咱們這段代碼中並無操做界面的東西,因此render callback queue是空的。event loop看到這個隊列中爲空,心中大喜,想着這一次的event loop結束後,本身終於能夠休息了。可是可憐的event loop是勞碌命,它被瀏覽器逼迫着進入了下一個loop中去了。
在下一個loop中,老規矩,咱們仍是會先檢查macrotask queue。這個時候,它發現有一個macrotask在裏面,因而它二話不說,把它推入到call stack去執行,最終在瀏覽器控制檯打印出「2」,call stack處於清空狀態。event loop接着看microtask queue和render callback queueu,發現這個兩個隊列都是爲空。最終的最終,event loop能夠歇着了,它如願以償地進入休眠狀態。
爲了驗證一下咱們的理解是否準確,咱們不妨把代碼複製到chrome瀏覽器控制檯去運行一下,結果是這樣的:
macrotask和microtask雖然都會被入隊到隊列中,都會最終能推入到call stack去執行,可是二者的不一樣的仍是挺明顯的,而且對於理解整個event loop的運行機制仍是挺重要的。它們兩個之間主要有兩個不一樣點:
下面,咱們在chrome瀏覽器(v81.0.4044.92)跑幾個例子來驗證一下。
先說一下第一個不一樣點。不管是script標籤內部的js代碼片斷仍是經過外部加載進來的js代碼文件,瀏覽器都會將對它的執行視爲一個macrotask,這也是驅動js代碼執行流的第一個macrotask。從這個角度來看,microtask老是從macrotask衍生而來的,那咱們憑什麼能說「在同一個event loop中,microtask會比macrotask先執行呢?」。這道理就好像,媽媽把兒子生下來以後,兒子長大後,指着媽媽說:「我長得比你高,我比你先來到這個世界」。你不以爲不符合邏輯嗎?不過,話說回來,要想經過瀏覽器控制檯的打印順序來正面證實 macrotask比microtask先執行仍是挺難的。不過咱們能夠反向證實一下。
假設js引擎掃描代碼後並無把整個js代碼片斷/文件做爲macrotask來執行,而是把「console.log(1)」和promise的代碼分別入隊到macrotask queue和microtask中。當js引擎準備執行代碼的時候,倘若它是先執行microtask,後執行macrotask的話,那麼,控制檯會先打印「2」,後打印「1」。實際上,這段代碼不管你執行多少次,結果都是同樣的:會先打印「1」,後打印「2」。這就反向證實了兩點:1)代碼片斷和代碼文件的執行自己就是一個macrotask;2)從源頭上說,microtask是做爲macrotask的一個執行結果而存在的,或者說,macrotask衍生了microtask。 因此,從表象上說,確定是先執行macrotask,再執行microtask。
這裏再次強調,第一點理解「代碼片斷和代碼文件的執行自己就是一個macrotask」是十分重要的。由於一旦你看不到它的話,那麼你就會下錯結論。請看下面這個簡單圖示:
基於event loop的執行流:
======================================================================
|| macrotask || microtask || macrotask || microtask || .....
======================================================================
^ ^
| |
| |
| |
觀察點1 觀察點2
複製代碼
由於macrtask queue和microtask queue是交替式地獲得一次推入call stack的機會的。那麼,如圖,若是你忽略了「代碼片斷和代碼文件的執行自己就是一個macrotask,而且是驅動執行流的第一個macrotask」這個實現上的事實後,光從控制檯的打印結果去作簡單判斷的話的話,那麼實際上你是站在了「觀察點2」上。這個時候,你會以爲先執行microtask,後執行macrotask的。然而,這並非事實。
綜上所說,macrotask是先於microtask先執行的。
第二個不一樣點,卻是能夠經過簡單地在瀏覽器控制檯運行代碼來驗證。
首先,咱們先來驗證一下,同一個event loop中,microtask是批量地,依次地執行的,而macrotask是單個執行的:
setTimeout(()=>{
console.log(2);
}, 0);
setTimeout(()=>{
console.log(3);
}, 0);
Promise.resolve().then(()=> {
console.log(4);
});
Promise.resolve().then(()=> {
console.log(5);
});
複製代碼
初始macrotask執行後,macrotask queue和microtask queue應該是長這樣的(跟上面闡述同樣,一樣是用【所須要打印的數字】來標誌這個任務):
---------------------------
| 2 | 3 |
---------------------------
microtask queue;
--------------------------
| 4 | 5 |
---------------------------
複製代碼
若是,單個macrotask跟單個microtask是交替執行的話,那麼打印結果將會是:
4
2
5
3
複製代碼
可是實際上打印結果是:
看這個結果,你可能會說,我是看到microtask是批量執行了,可是macrotask不也是「批量執行」嗎?。實際上,不是這樣的。那是由於進入第二次event loop以後,執行完(2)以後,microtask queue中並無任務的任務可執行,因而乎又進入了第三次event loop,這個時候,才執行了(3)。下面咱們在第一個setTimeout的callback入隊一個microtask(爲了簡便起見,這裏用全局方法queueMicrotask)來試試看:
setTimeout(()=>{
console.log(2);
queueMicrotask(()=> {
console.log(2.5);
});
}, 0);
setTimeout(()=>{
console.log(3);
}, 0);
Promise.resolve().then(()=> {
console.log(4);
});
Promise.resolve().then(()=> {
console.log(5);
});
複製代碼
若是macrotask也是批量執行的話,那麼打印結果將會是:
4
5
2
3
2.5
複製代碼
可是實際打印結果是什麼呢?實際以下:
實際的打印結果是:
4
5
2
2.5
3
複製代碼
這是爲何呢?這是由於,瀏覽器在走完第二次的event loop的macrotask以後,代碼使用queueMicrotask入隊了一個microtask(2.5)。上面說過,一旦執行完一個macrotask,接下來就會去檢查microtask queue是否有任務等待執行。此時,正好有一個microtask(2.5)在裏面,因此,event loop就把它推入到call stack執行了,而後打印出「2.5」。再而後才進入第三次的event loop,這纔有了macrotask(3)的執行。
上面,基本上是在驗證microtask執行的「批量性,依次性」。那下面來驗證,microtask執行的「連續性」。簡單來講,若是一個microtask在call stack上執行的過程當中致使了一個新的microtask入隊,而這個新的microtask在call stack執行過程當中又致使了一個更新的microtask入隊,如此類推.....的話,那麼這些連續產生的microtask都會在同一次event loop中被連續地執行完,中間不會去執行macrotask queue或者render callback queue裏面的任務。注意,這裏強調的是「致使了一個新的microtask入隊」的意思是指,瀏覽器以幾乎能夠忽略的時間差,真正地把一個microtask入隊到microtask queue中。好比,下面的代碼就不是「致使了一個新的microtask入隊」:
Promise.resolve().then(()=> {
setTimeout(function macrotask2() {
queueMicrotask(()=> {
console.log(4.5);
});
}, 0)
});
複製代碼
由於只有當「macrotask2」這個macrotask被推入到call stack去執行的時候,(4.5)這個microtask纔會真正入隊。
去掉setTimeout的包裹,纔是真正的「致使了一個新的microtask入隊」:
Promise.resolve().then(()=> {
queueMicrotask(()=> {
console.log(4.5);
});
});
複製代碼
以上這兩種狀況對執行流有啥影響呢?咱們下面看看各類的打印結果的差別。
(1)有setTimeout這層wrapper:
setTimeout(()=>{
console.log(2);
queueMicrotask(()=> {
console.log(2.5);
});
}, 0);
setTimeout(()=>{
console.log(3);
}, 0);
Promise.resolve().then(()=> {
console.log(4);
setTimeout(()=> {
queueMicrotask(()=> {
console.log(4.5);
});
}, 0)
});
Promise.resolve().then(()=> {
console.log(5);
});
// 打印結果是:
4
5
2
2.5
3
4.5
複製代碼
(2)把setTimeout這層wrapper去掉後:
setTimeout(()=>{
console.log(2);
queueMicrotask(()=> {
console.log(2.5);
});
}, 0);
setTimeout(()=>{
console.log(3);
}, 0);
Promise.resolve().then(()=> {
console.log(4);
queueMicrotask(()=> {
console.log(4.5);
});
});
Promise.resolve().then(()=> {
console.log(5);
});
// 打印結果是:
4
5
4.5
2
2.5
3
複製代碼
從打印結果來看,你能夠看到二者執行流的差異嗎?一個是4.5放在最後打印了,一個是接着前面兩個的microtask的尾巴,連續打印了。說了這麼多,我就是想說,我這裏所說的「microtask執行的連續性」是指第二種狀況。下面咱們把這個例子放大來看:
setTimeout(()=>{
console.log(1);
}, 0);
Promise.resolve().then(()=> {
console.log(2);
queueMicrotask(()=> {
console.log(3);
queueMicrotask(()=> {
console.log(4);
queueMicrotask(()=> {
console.log(5);
queueMicrotask(()=> {
console.log(6);
});
});
});
});
});
複製代碼
你猜猜打印結果如何?
你猜對了嗎?到這裏,不知道你看清楚所謂的「microtask執行的連續性」是啥沒?它的具象化理解其實就是「連續入隊的microtask會被依次,連續地推入到call stack去執行,中間不會調度其餘的任務(macrotask後者render callback)去打斷這種連續性」。
實際上microtask這種連續性,在使用不當(好比說入隊過多,遞納入隊)的時候,就會長期佔用call stack,本質上形成了瀏覽器運行的阻塞。MDN在文檔裏面也給出了相關的警告:
Warning: Since microtasks can themselves enqueue more microtasks, and the event loop continues processing microtasks until the queue is empty, there's a real risk of getting the event loop endlessly processing microtasks. Be cautious with how you go about recursively adding microtasks.
對於microtask跟macrotask的不一樣點,到這裏已經闡述得差很少了。還有一個值得強調的是,要想理解event loop的運行機制,理解microtask/macrotask的入隊時機也是十分重要,而且須要額外注意的一點。microtask/macrotask的入隊時機是掌握在web API手上的。關於這一點,Philip Roberts在他的演講中有提到過。在這裏,會給出一個示例:
const timeout1 = 0;
const timeout2 = 0;
const timeout3 = 0;
// 代碼塊(1)
setTimeout(()=>{
console.log(1);
}, timeout1);
// 代碼塊(2)
new Promise(resolve=> {
setTimeout(()=> {
resolve('finished');
},timeout2);
// 能夠嘗試把setTimeout wrapper去掉
// resolve('finished');
}).then(()=> {
console.log(2)
throw new Error("error");
}).catch((e)=> {
console.log(3);
}).finally(()=> {
console.log(4);
});
// 代碼塊(3)
setTimeout(()=>{
console.log(5);
}, timeout3);
複製代碼
你能夠經過如下一種或者多種結合的方式來觀察一下入隊時機是如何影響執行流的:
提示1:記得,這個時候,必定要想起web API這個掃地僧啊。
提示2:構造promise實例的代碼是同步代碼。
首先,咱們來聊聊setImmediate。在MDN上,開門見山了指出這個方法並非標準規範要求實現的方法:
This feature is non-standard and is not on a standards track. Do not use it on production sites facing the Web: it will not work for every user. There may also be large incompatibilities between implementations and the behavior may change in the future.
This method is not expected to become standard, and is only implemented by recent builds of Internet Explorer and Node.js 0.10+. It meets resistance both from Gecko (Firefox) and Webkit (Google/Apple).
也就是說,這個特性當前不是標準方法,它的發展也不在能夠標準化的方向上。當前只有最新的(相對於Feb 22, 2020)IE版本和Node.js 0.10+上實現了它。 它的標準化進程受到了Gecko (Firefox) 和 Webkit (Google/Apple)的抵制。故而,請不要在生產環境中使用它。
題外話:當我在掘金域名下的頁面的控制檯輸入「set....」的時候,它居然提示有這個「setImmediate」API,而且也是能執行的。我當時就懵掉了,難道在最新版本的chrome中,它實現了這個方法?後面通過摸索(就簡單地用「setImmediate.toString()」來看看,原來它並非原生方法,應該是掘金本身引入了外部的polyfill。
雖然它不是標準方法,可是考慮到nodejs有這個方法,而且有個別面試官的「喪心病狂」,咱們不妨看看這個方法究竟是怎麼一個回事。
MDN上對它的介紹是這樣的:
This method is used to break up long running operations and run a callback function immediately after the browser has completed other operations such as events and display updates.
在這段介紹裏面,咱們沒有看到這裏面有提到setImmediate跟(window)event loop的關係。咱們只看到了,它會在「event callback」和 UI更新等操做後面執行。爲了弄清楚調用它的時候傳入的function究竟是入隊到哪一個隊列中,咱們來看看市面上各類setImmediate polyfill是如何解讀它的。
咱們挑一個star最多的,也就是第一個「YuzuJS/setImmediate」來看看,只見它的readme裏面是這麼寫的:
The setImmediate API, as specified, gives you access to the environment's task queue, sometimes known as its "macrotask" queue. This is crucially different from the microtask queue used by web features such as MutationObserver, language features such as promises and Object.observe, and Node.js features such as process.nextTick.
第一句話就很明確地指出,它所入隊的隊列是macrotask queue。一樣,在stackoverflow上面的這個問題的一個高分答主也秉持一樣的觀點:
鑑於,在個人電腦上只有Microsoft Edge(v44.18362.329.0),而且它原生實現了setImmediate方法:
那麼咱們就在上面把示例代碼跑起來看看,先來個簡單版:
setTimeout(() => {
console.log('setTimeout');
});
setImmediate(()=> { console.log('setImmediate')});
Promise.resolve().then(()=> {
console.log('Promise');
});
//output:
Promise
setImmediate
setTimeout
複製代碼
從這一次的運行結果來看,setImmediate入隊的任務要麼是追加到microtask queue的後面,要麼就插隊到macrotask queue的最前面了。這二者都有可能。可是隨着深刻試驗,我有個驚奇的發現。先賣個關子,咱們先看看下面兩個代碼運行結果截圖:
從這個運行結果來看,咱們仍是沒法 判斷setImmediate入隊的任務到底歸屬於那個任務隊列。可是咱們能夠有一個結論,那就是:(1)多個setImmediate的入隊順序仍是按照它們在代碼書寫期的順序來入隊的,它沒有後來者居上的插隊表現。 可是,從下面這運行結果咱們就能夠大概看出個端倪來:
首先,咱們姑且把一樣代碼會有不一樣的執行結果這個發現放在一邊。咱們能夠看到,兩次的setTimeout居然在兩次setImmediate前面打印出來了。這就證實了:(2)setImmediate入隊的任務是歸屬於macrotask queue的。 爲何呢?由於假如setImmediate入隊的任務是歸屬於microtask queue的話,那麼這段代碼不管執行多少次都不會出現第二張截圖所顯示的運行結果。第二張截圖所顯示的運行結果證實了setImmediate入隊的任務確定是歸屬於macrotask queue的,可是綜合兩次運行結果來看,咱們基本能夠判斷:(3)setImmediate和setTimeout的入隊順序沒法獲得保證。不過絕大部分的狀況下,都是setImmediate入隊在先。 咱們偉大的扎叔在他的技術博客裏面也提到過:
Another advantage is that the specified function executes after a much smaller delay, without a need to wait for the next timer tick. That means the entire process completes much faster than with using setTimeout(fn, 0).
這種不一致表現,好像有點似曾相識,好像哪裏見過,是吧?對的,就是咱們親愛的nodejs。nodejs在本身的官方文檔setImmediate() vs setTimeout()中對於這種不肯定性如是說道:
The order in which the timers are executed will vary depending on the context in which they are called. If both are called from within the main module, then timing will be bound by the performance of the process (which can be impacted by other applications running on the machine).
因此,咱們不妨作個大膽的推測:(4)Microsoft Edge對setImmediate的實現機制跟nodejs對setImmediate實現機制是大體相仿的。
最後,咱們來看最後一個例子:
從個示例代碼的運行結果來看, (5)雖然setImmediate()和setTimeout()所入隊的任務都在一個macrotask裏面,可是不管書寫代碼的順序如何,二者都不會交叉入隊。 也就是說,使用同一個方法入隊的多個任務,要麼不執行,要麼就一塊兒執行。
網上對於(瀏覽器環境下的)非標準的setImmediate的研究資料和技術文章着實少。相對權威點的資料我查到三個:
好了,對非標準的setImmediate在event loop中的表現的探索到此爲止,有空可繼續深刻。
經過MutationObserver接口咱們可以去監聽DOM樹的各類更改。引入該特性是爲了替代DOM3事件規範的Mutation Event3特性。MDN文檔中如是說。
關於MutationObserver接口的語法以及如何在監聽DOM樹更改領域的應用,本文不打算深刻講解。本文只是探索經過它來入隊的任務是如何參與到event loop中去的。爲了此目標,咱們不妨基於它來封裝這樣的一個方法:
function queueMicrotaskWithMutationObserver(callback){
const div = document.createElement('div')
let count = 0
const observer = new MutationObserver(() => {
callback && typeof callback === 'function' && callback.call(null)
})
observer.observe(div, { attributes: true })
div.setAttribute('count', ++count);
}
複製代碼
好的,有了它,咱們就能夠愉快地玩耍了。咱們來看看下面這個示例:
基本上能夠,肯定經過MutationObserver來入隊的任務是屬於microtask。這與網上盛傳的說法是一致的。爲了進一步驗證,咱們再看看複雜一點示例:
咱們能夠經過各類更加複雜的示例來觀察過MutationObserver在event loop中的表現。咱們會發現,它並不具有比其餘接口更高的優先級,它跟Promise和queueMicrotask等接口在入隊方面的表現徹底同樣的。經過它來入隊的microtask的執行方式同樣具備「批量性和連續性」。
限於篇幅的緣由,我在這裏就不深刻探討async...await了。也就是說,不會經過深刻分析async...await的實現原理來探索它在event loop中的表現以及爲何這樣表現。咱們只須要記住一個當前的事實就是:async...await是promise的語法糖。因此,在這一小節,咱們經過desugar來理解它在event loop中的表現。
咱們結合具體的示例來談談如何desugar:
const response = await fetch(…);
const json = await response.json();
const foo = JSON.parse(json);
console.log(foo);
複製代碼
fetch(…)
.then(response => response.json())
.then(json => {
const foo = JSON.parse(json);
console.log(foo);
});
複製代碼
desugar一個await,基本能夠按三部步走:
第一步,將await所在的語句以後,(塊/函數/全局)做用域底部邊界以前的全部語句都封裝到一個callback函數裏面:
const callback = (json) => {
const foo = JSON.parse(json);
console.log(foo);
};
複製代碼
第二部步,將await關鍵字所在的語句改造爲promise...then:
2. response.json().then();
複製代碼
第三步,將callback裝進then方法裏面:
response.json().then((json) => {
const foo = JSON.parse(json);
console.log(foo);
});
複製代碼
desugar多個await的順序應該由下到上地應用上面的「算法」。那麼咱們繼續往上desugar的話,應該是這樣的:
第一步:
const callback = (response) => {
response.json().then((json) => {
const foo = JSON.parse(json);
console.log(foo);
});
};
複製代碼
第二步:
fetch(…).then();
複製代碼
第三步:
fetch(…).then((response) => {
response.json().then((json) => {
const foo = JSON.parse(json);
console.log(foo);
});
});
複製代碼
爲了代碼結構變得更扁平,咱們把上面嵌套調用then的代碼風格改成鏈式調用then的代碼風格:
fetch(…).
then(response => response.json())
then((json => {
const foo = JSON.parse(json);
console.log(foo);
});
複製代碼
到了這裏,咱們就基本把多個「await」desugar爲一個「promise...then」的代碼。至於async關鍵字標誌的函數其實就是構造promise對象時傳入構造函數的executor,而後函數的return值就是promise的resolve值。好比如下的async標誌的函數:
async function foo(){
return 'bar';
}
複製代碼
那麼它就會被desugar爲:
new Promise(res=> {
res('bar');
})
複製代碼
好,講完如何將async...await轉換爲promise寫法後,咱們在一個例子上面驗證一下:
console.log('script start')
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 start')
return Promise.resolve().then(()=>{
console.log('async2 end')
})
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('new promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
// output:
script start
async1 start
async2 start
new promise
script end
async2 end
promise1
promise2
async1 end
setTimeout
複製代碼
而後咱們將其中的async...await降級爲promise以後是這樣的:
console.log('script start')
function asyn1(){
new Promise(()=> {
console.log('async1 start');
new Promise((res)=> {
console.log('async2 start');
// 這裏有一個注意點,只有then方法執行完,promise纔會resolve掉
res(Promise.resolve().then(()=>{
console.log('async2 end');
}) );
}).then(()=> {
console.log('async1 end');
})
})
}
asyn1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('new promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
複製代碼
從運行結果的截圖來看,代碼的執行順序一致的。這裏採用了一個取巧的方式去理解async...await在event loop的表現,不能爲長遠之計。有時間,再回來深刻研究。
雖然業務型的前端開發中不多須要用到event loop機制,可是仍是有很多場景還真的十分須要它。下面列舉一下。
某種狀況下,有些框架/類庫雖然提供異步的API,可是卻沒有提供回調給咱們hook進去,這個時候就須要祭出咱們的殺手鐗了:setTimeout。
好比說,早期版本的react的setState方法,並無提供一個callback讓咱們hook進去,來獲取更新後的state值。可是因爲event handler裏面的代碼是在一個批量更新的事務中,這就致使了這種狀況下,setState的執行是「異步」(相對原生的異步行爲,這種異步是僞異步)的。這個時候,若是你把獲取更新state值的代碼寫在setState()後面的話,那麼你是沒法獲取到更新後的state值的。好比下面:
import React from 'react';
const Count = React.createClass({
getInitialState() {
return {
count: 0
}
},
render() {
return <button onClick={()=> {
this.setState({count: this.state.count + 1});
console.log(this.state.count);
}}>{this.state.count}</button>
}
componentDidMount() {
}
})
export default Count;
複製代碼
按理說,你要想獲取更新後的state值的話,你應該在生命週期函數componentDidUpdate裏面去獲取。可是,假如咱們非要經過寫在this.setState({count: this.state.count + 1});
以後的代碼去獲取呢?咱們有什麼辦法呢?辦法仍是有的。就是用setTimeout來包裹一下就好:
render() {
return <button onClick={()=> {
this.setState({count: this.state.count + 1});
setTimeout(()=> {
console.log(this.state.count);
}, 0);
}}>{this.state.count}</button>
}
複製代碼
原理是什麼呢?原理有二:
這裏須要強調的是,只提setTimeout只是爲了拋磚引玉。在這種場景下,任何把()=> { console.log(this.state.count);}
入隊到microtask queue/macrotask queue的方法都是可行的。好比說,setInterval,promise,queueMicrotask等等API都是可行的。在這個需求之下,入隊到microtask queue仍是入隊到 macrotask queue,其實區別都不打,咱們只須要使之變爲異步代碼便可。
這裏拿react的setState方法舉例子也只是拋磚引玉,全部有這種需求的場景,咱們均可以用這種方法實現咱們的需求。
在不借助web worker的狀況,咱們如何在主線程去執行一些本來耗時,阻塞主線程的任務(好比CPU-hungry task)呢。答案是基於event loop的運行機制去作任務切片。至於什麼是阻塞主線程,阻塞主線程會有什麼後果,本文就不贅述了。詳情看Event Loop究竟是什麼鬼?。首先咱們來看看下面這個示例1:
<body>
<input type="text" placeholder="我是input,試一試點擊我" style="width: 100%;"/>
</body>
<script>
window.onload = function(){
let i = 0;
let start = Date.now();
function count() {
// do a heavy job
for (let j = 0; j < 1e9; j++) {
i++;
}
console.log("Done in " + (Date.now() - start) + 'ms');
}
count();
}
</script>
複製代碼
把這個頁面代碼運起來以後,你會發現,在控制檯打印結果出來以前,頁面上的input框是點不動(沒法獲取焦點)的。這是由於count()的執行一直在佔用call stack,致使render callback沒法放到call stack去執行。這就是所謂的「阻塞主線程」。如今咱們使用setTimeout來對count()這個大任務進行切片(示例2):
<body>
<input type="text" placeholder="我是input,試一試點擊我" style="width: 100%;"/>
</body>
<script>
window.onload = function(){
let i = 0;
let start = Date.now();
function count() {
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
console.log("Done in " + (Date.now() - start) + 'ms');
} else {
setTimeout(count); // schedule the new call
}
}
count();
}
複製代碼
通過切片後,這段代碼的執行流是這樣的:
其本質就是經過macrotask和render callback的交替執行來防止這個耗時的大任務來阻塞call stack。雖然切片以後,累加到1e9的所用的總時間變長了,可是咱們保證了界面的可交互性,因此,這一點時間代價不值一提。
若是你想基於示例2去繼續優化,想要它既不阻塞call stack,有可以儘可能地縮短它的執行時長,方法也是有的。下面,咱們來看看示例3:
<body>
<input type="text" placeholder="我是input,試一試點擊我" style="width: 100%;"/>
</body>
<script>
window.onload = function(){
let i = 0;
let start = Date.now();
function count() {
if(i < 1e9 - 1e6){
setTimeout(count); // schedule the new call
}
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
console.log("Done in " + (Date.now() - start) + 'ms');
}
}
count();
}
複製代碼
在這個示例中,咱們入隊動做放在累計以前,經過這樣作,咱們能把任務的耗時從示例2的12秒左右減小到8秒左右。這是爲何呢?
這是由於,雖然咱們setTimeout()調用沒有傳遞time out時間,即爲默認值0。可是HTML5規範要求,嵌套執行的setTimeout的最小延遲時間爲4ms,即便咱們顯式設置爲0也不行。大部分瀏覽器也是這麼實現的。當咱們把入隊的動做放在最前面的時候,在每一輪累加中,咱們就能省掉4毫秒。1e9/1e6 = 1000(輪),因此咱們能省掉的時間大概爲 1000 * 4 = 4000(ms)。這也是跟我 們的實際執行結果相吻合的。至於,把入隊的動做放在最前面以後,爲何每輪能夠省掉4毫秒呢?你大可依據上面給出的event loop處理模型圖來進行推斷,這個練習就留給你本身了。
這種場景通常是特指在不一樣的條件分支裏面,一個分支用了異步代碼,一個分支沒用異步代碼,從而致使在不一樣的狀況下,代碼的執行順序無法保持一致。請看下面這個例子:
customElement.prototype.getData = url => {
if (this.cache[url]) {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
} else {
fetch(url).then(result => result.arrayBuffer()).then(data => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
});
}
};
element.addEventListener("load", () => console.log("Loaded data"));
console.log("Fetching data...");
element.getData();
console.log("Data fetched");
複製代碼
在這裏,假設咱們是想對特定的接口作數據緩存。當你第一次執行的時候,程序會執行第二個條件分支,打印結果是這樣的:
Fetching data
Data fetched
Loaded data
複製代碼
當你第二次執行的時候,程序會執行第一個條件分支,打印結果將會這樣:
Fetching data
Loaded data
Data fetched
複製代碼
由於代碼的執行次序沒法獲得保證,這就會增長了代碼運行的不可預測性,從而理解和維護成本變得更高。爲了解決這個問題,咱們可使得兩個條件分支的都是異步代碼便可:
customElement.prototype.getData = url => {
if (this.cache[url]) {
queueMicrotask(() => {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
});
} else {
fetch(url).then(result => result.arrayBuffer()).then(data => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
});
}
};
複製代碼
其實,這種解決方案對全部的條件語句都適用,不必定是if...else。具體使用的API,也不必定是queueMicrotask和promise...then,只要保證兩個條件分支的代碼都編排成同種類型的task便可。
有時候咱們須要批量做業,即將屢次連續的操做請求合併爲一次的操做請走,最終實際執行一次操做。咱們來看看下面的代碼:
const messageQueue = [];
let sendMessage = message => {
messageQueue.push(message);
if (messageQueue.length === 1) {
queueMicrotask(() => {
const json = JSON.stringify(messageQueue);
messageQueue.length = 0;
// 這裏用console.log來模擬實際的操做
console.log('最終要操做的數據的json序列是:', json)
});
}
};
sendMessage(1);
sendMessage(2);
sendMessage(3);
sendMessage(4);
sendMessage(5);
// output:
// 最終要操做的數據的json序列是: [1,2,3,4,5]
複製代碼
這裏的原理就是:event loop和閉包。 在屢次連續的sendMessage()調用中,咱們經過判斷來確保在第一次調用的時候就把一個函數編排爲microtask,併入隊到microtask queue中(此處,不必定用「messageQueue.length === 1」這種判斷條件,咱們也能夠用標誌位來實現)。此時,咱們已經把變量messageQueue保存在函數閉包中了。在後續的sendMessage()調用,咱們其實就是操做這個閉包中的變量(閉包變量會相對地常駐內存)。就像上面所說的那樣,代碼片斷的初始執行自己就是一個macrotask,當這個macrotask在call stack執行完畢後(即call stack處於清空狀態),此時event loop發現microtask queue上有一個microtask,因而乎就把它推入到call stack去執行。在執行microtask以前,messageQueue變量的值其實已是數組類型的[1,2,3,4,5]
,那麼最後在microtask操做數據的時候確定沒問題了。其實上面的調用代碼就等同於:
const messageQueue = [];
messageQueue.push(1);
messageQueue.push(2);
messageQueue.push(31);
messageQueue.push(4);
messageQueue.push(5);
const json = JSON.stringify(messageQueue);
messageQueue.length = 0;
console.log('最終要操做的數據的json序列是:', json)
複製代碼
咱們能夠借鑑這裏的思想,模仿實現原生react中setState方法的行爲表現:異步和批量:
const componentInstance = {
state:null,
_pendingState:null,
_stateList:[],
render(){
console.log('reconciling...');
},
setState(partialState){
this._stateList.push(partialState);
if(this._stateList.length === 1) {
queueMicrotask(() => {
if(this.state !== null){
this._stateList.unshift(this.state);
}
const finalState = this._stateList.reduceRight((prev,curr)=>{
return Object.assign(curr,prev);
},{})
this._pendingState = finalState;
console.log('reconciliation start...')
this.render();
console.log('reconciliation end...')
this.state = this._pendingState;
this._pendingState = null;
this._stateList.length = 0;
});
}
}
}
複製代碼
首先,咱們來考察上面實現的異步表現:
componentInstance.state = {count: 1};
componentInstance.setState({count: componentInstance.state.count + 1});
// 能拿到更新後的state值嗎?
// 打印結果:state: 1,因此答案是:不能。
console.log('state:', componentInstance.state);
// 能拿到更新後的state值嗎?
// 打印結果:state: 2,因此答案是:能。
setTimeout(() => {
console.log('state:', componentInstance.state);
}, 0);
複製代碼
而後,咱們在來考察上面實現的批量更新表現:
componentInstance.state = {count: 1};
componentInstance.setState({count: componentInstance.state.count + 1});
componentInstance.setState({count: componentInstance.state.count + 1});
componentInstance.setState({count: componentInstance.state.count + 1});
// output: state: 2,證實是批量更新
setTimeout(() => {
console.log('state:', componentInstance.state);
}, 0);
複製代碼
event loop在javascript異步編程領域下,應該還要不少的應用場景,期待有更多的發掘。
面試題難度的幾個層級:
好下面,咱們來看看市面上面試題:
問題1: 如下的三個場景的執行結果會是怎樣?爲何?
// 場景1:
function foo() {
setTimeout(foo, 0);
};
foo();
// 場2:
function foo() {
return Promise.resolve().then(foo);
};
foo();
// 場景3:
function foo() {
foo()
};
foo();
複製代碼
解析:
問題2: 如下的打印順序結果會是怎樣的呢?:
setTimeout(function() {
console.log(1)}, 0);
new Promise(function executor(resolve) {
console.log(2);
for( var i=0 ; i<10000 ; i++ ) {
i == 9999 && resolve();
}
console.log(3);
}).then(function() {
console.log(4);
});
console.log(5);
複製代碼
解析: 打印結果是:
2
3
5
4
1
複製代碼
考點有:
問題3: 如下的打印順序結果會是怎樣的呢?:
// 位置 1
setTimeout(function () {
console.log('timeout1');
}, 1000);
// 位置 2
console.log('start');
// 位置 3
Promise.resolve().then(function () {
// 位置 5
console.log('promise1');
// 位置 6
Promise.resolve().then(function () {
console.log('promise2');
});
// 位置 7
setTimeout(function () {
// 位置 8
Promise.resolve().then(function () {
console.log('promise3');
});
// 位置 9
console.log('timeout2')
}, 0);
});
// 位置 4
console.log('done');
複製代碼
解析: 打印結果是:
start
done
promise1
promise2
timeout2
promise3
timeout1
複製代碼
這裏有好幾個考點。首先在考你:
針對考點1,其實就是考你同一個類型的任務,入隊時機的問題。這種問題得具體問題具體分析。不過通常是看如下幾點:
拿setTimeout這個入隊動做舉個例子,兩個setTimeout的入隊順序算法以下:
promise也是同樣的,只不過它所對應的delay時間是由resolve方法執行的時間點來決定的。
迴歸到本示例,由於位置7前面的同步代碼的執行時間幾乎忽略不計,而位置1總的delay時間則爲1000毫秒。因此,最早入隊的是位置7。假如,咱們把位置7的delay時間改成1001ms的話,那麼打印結果將會是這樣的:
start
done
promise1
promise2
timeout1
timeout2
promise3
複製代碼
能夠看出,「timeout1」在前面,「timeout2」在後面。具體的執行結果截圖就不給出,你們能夠自行去驗證。
爲了測試咱們算法的準確性,那咱們再來測試一下在delay時間相等的狀況:
// 位置 1
setTimeout(function () {
console.log('timeout1');
}, 1000);
// 位置 2
console.log('start');
// 位置 3
Promise.resolve().then(function () {
// ....
setTimeout(function () {
// 位置 8
Promise.resolve().then(function () {
console.log('promise3');
});
// 位置 9
console.log('timeout2')
}, 1000);
});
// 位置 4
console.log('done');
複製代碼
那麼打印結果將會是:「timeout1」在前面,「timeout2」在後面。若是咱們調換一下二者的書寫順序:
// 位置 2
console.log('start');
setTimeout(function () {
// 位置 8
Promise.resolve().then(function () {
console.log('promise3');
});
// 位置 9
console.log('timeout2')
}, 1000);
// 位置 1
setTimeout(function () {
console.log('timeout1');
}, 1000);
// 位置 4
console.log('done');
複製代碼
那麼打印結果將會:「timeout2」在前面,「timeout1」在後面。爲了證實咱們算法的準確性,咱們最後來驗證一下「同步代碼的執行時間不能忽略不計的狀況」。咱們有如下代碼:
// 位置 1
setTimeout(function () {
console.log('timeout1');
}, 1000);
// 位置 2
console.log('start');
// 位置 3
Promise.resolve().then(function () {
// 位置 5
console.log('promise1');
// 位置 6
Promise.resolve().then(function () {
console.log('promise2');
});
// 阻塞2ms
const now = Date.now();
while(Date.now() - now < 3){}
// 位置 7
setTimeout(function () {
// 位置 8
Promise.resolve().then(function () {
console.log('promise3');
});
// 位置 9
console.log('timeout2')
}, 999);
});
// 位置 4
console.log('done');
複製代碼
以上代碼中,雖然位置7自己的delay時間比位置1的delay時間少了1毫秒,可是位置7前面在call stack上阻塞了2ms,那麼位置7的入隊所用總時間 = 999 + 2 = 1001(ms)。1000 < 1001,因此,位置1先入隊。最終打印結果將會:「timeout1」在前面,「timeout2」在後面。執行結果截圖爲證:
對於promise而言,只要把delay之間改成resolve所須要的時間便可,在這裏就很少加討論了。通常而言,面試不會出一些那麼牛角尖的題目,可是若是咱們本身提早深刻到這一點話,那麼咱們就可以應付得了一些喪心病狂的面試題。
針對考點1已經解釋完了,那麼看看考點2。哎,其實考點2也沒有啥好說的,就是在考microtask的連續性。換句話說,要是同時入隊兩個任務,一個是macrotask,一個microtask,那麼接下來要執行的確定是microtask。
針對同一個示例,咱們能夠根據上面給出的面試題考點來舉一反三地改造它,而後在瀏覽器的控制檯運行起來,看看代碼的執行結果跟本身推演的結果是否一致就能夠。多加練習,相信你會愈來愈有信心,對(window)event loop的理解也會更加深刻的。