首先,JavaScript是一個單線程的腳本語言。
是以就是說在一行代碼執行的過程中,必然不會存在同時執行的另一行代碼,就像使用<code>alert()</code>以後進行瘋狂<code>console.log</code>,如果沒有關閉彈框,控制台是不會顯示出一條<code>log</code>資訊的。
亦或者有些代碼執行了大量計算,比方說在前端暴力破解密碼之類的鬼操作,這就會導緻後續代碼一直在等待,頁面處于假死狀态,因為前邊的代碼并沒有執行完。
是以如果全部代碼都是同步執行的,這會引發很嚴重的問題,比方說我們要從遠端擷取一些資料,難道要一直循環代碼去判斷是否拿到了傳回結果麼?就像去飯店點餐,肯定不能說點完了以後就去後廚催着人炒菜的,會被揍的。
于是就有了異步事件的概念,注冊一個回調函數,比如說發一個網絡請求,我們告訴主程式等到接收到資料後通知我,然後我們就可以去做其他的事情了。
然後在異步完成後,會通知到我們,但是此時可能程式正在做其他的事情,是以即使異步完成了也需要在一旁等待,等到程式空閑下來才有時間去看哪些異步已經完成了,可以去執行。
比如說打了個車,如果司機先到了,但是你手頭還有點兒事情要處理,這時司機是不可能自己先開着車走的,一定要等到你處理完事情上了車才能走。
這個就像去銀行辦業務一樣,先要取号進行排号。
一般上邊都會印着類似:“您的号碼為XX,前邊還有XX人。”之類的字樣。
因為櫃員同時職能處理一個來辦理業務的客戶,這時每一個來辦理業務的人就可以認為是銀行櫃員的一個宏任務來存在的,當櫃員處理完目前客戶的問題以後,選擇接待下一位,廣播報号,也就是下一個宏任務的開始。
是以多個宏任務合在一起就可以認為說有一個任務隊列在這,裡邊是目前銀行中所有排号的客戶。
任務隊列中的都是已經完成的異步操作,而不是說注冊一個異步任務就會被放在這個任務隊列中,就像在銀行中排号,如果叫到你的時候你不在,那麼你目前的号牌就廢棄了,櫃員會選擇直接跳過進行下一個客戶的業務處理,等你回來以後還需要重新取号
而且一個宏任務在執行的過程中,是可以添加一些微任務的,就像在櫃台辦理業務,你前邊的一位老大爺可能在存款,在存款這個業務辦理完以後,櫃員會問老大爺還有沒有其他需要辦理的業務,這時老大爺想了一下:“最近P2P爆雷有點兒多,是不是要選擇穩一些的理财呢”,然後告訴櫃員說,要辦一些理财的業務,這時候櫃員肯定不能告訴老大爺說:“您再上後邊取個号去,重新排隊”。
是以本來快輪到你來辦理業務,會因為老大爺臨時添加的“理财業務”而往後推。
也許老大爺在辦完理财以後還想 再辦一個信用卡?或者 再買點兒紀念币?
無論是什麼需求,隻要是櫃員能夠幫她辦理的,都會在處理你的業務之前來做這些事情,這些都可以認為是微任務。
這就說明:你大爺永遠是你大爺
在目前的微任務沒有執行完成時,是不會執行下一個宏任務的。
是以就有了那個經常在面試題、各種部落格中的代碼片段:
<code>setTimeout</code>就是作為宏任務來存在的,而<code>Promise.then</code>則是具有代表性的微任務,上述代碼的執行順序就是按照序号來輸出的。
所有會進入的異步都是指的事件回調中的那部分代碼
也就是說<code>new Promise</code>在執行個體化的過程中所執行的代碼都是同步進行的,而<code>then</code>中注冊的回調才是異步執行的。
在同步代碼執行完成後才回去檢查是否有異步任務完成,并執行對應的回調,而微任務又會在宏任務之前執行。
是以就得到了上述的輸出結論<code>1、2、3、4</code>。
+部分表示同步執行的代碼
本來<code>setTimeout</code>已經先設定了定時器(相當于取号),然後在目前程序中又添加了一些<code>Promise</code>的處理(臨時添加業務)。
是以進階的,即便我們繼續在<code>Promise</code>中執行個體化<code>Promise</code>,其輸出依然會早于<code>setTimeout</code>的宏任務:
當然了,實際情況下很少會有簡單的這麼調用<code>Promise</code>的,一般都會在裡邊有其他的異步操作,比如<code>fetch</code>、<code>fs.readFile</code>之類的操作。
而這些其實就相當于注冊了一個宏任務,而非是微任務。
P.S. 在Promise/A+的規範中,<code>Promise</code>的實作可以是微任務,也可以是宏任務,但是普遍的共識表示(至少<code>Chrome</code>是這麼做的),<code>Promise</code>應該是屬于微任務陣營的
是以,明白哪些操作是宏任務、哪些是微任務就變得很關鍵,這是目前業界比較流行的說法:
#
浏覽器
Node
<code>I/O</code>
✅
<code>setTimeout</code>
<code>setInterval</code>
<code>setImmediate</code>
❌
<code>requestAnimationFrame</code>
有些地方會列出來<code>UI Rendering</code>,說這個也是宏任務,可是在讀了HTML規範文檔以後,發現這很顯然是和微任務平行的一個操作步驟
<code>requestAnimationFrame</code>姑且也算是宏任務吧,<code>requestAnimationFrame</code>在MDN的定義為,下次頁面重繪前所執行的操作,而重繪也是作為宏任務的一個步驟來存在的,且該步驟晚于微任務的執行
<code>process.nextTick</code>
<code>MutationObserver</code>
<code>Promise.then catch finally</code>
上邊一直在讨論 宏任務、微任務,各種任務的執行。
但是回到現實,<code>JavaScript</code>是一個單程序的語言,同一時間不能處理多個任務,是以何時執行宏任務,何時執行微任務?我們需要有這樣的一個判斷邏輯存在。
每辦理完一個業務,櫃員就會問目前的客戶,是否還有其他需要辦理的業務。(檢查還有沒有微任務需要處理)
而客戶明确告知說沒有事情以後,櫃員就去檢視後邊還有沒有等着辦理業務的人。(結束本次宏任務、檢查還有沒有宏任務需要處理)
這個檢查的過程是持續進行的,每完成一個任務都會進行一次,而這樣的操作就被稱為<code>Event Loop</code>。(這是個非常簡易的描述了,實際上會複雜很多)
而且就如同上邊所說的,一個櫃員同一時間隻能處理一件事情,即便這些事情是一個客戶所提出的,是以可以認為微任務也存在一個隊列,大緻是這樣的一個邏輯:
之是以使用兩個<code>for</code>循環來表示,是因為在循環内部可以很友善的進行<code>push</code>之類的操作(添加一些任務),進而使疊代的次數動态的增加。
以及還要明确的是,<code>Event Loop</code>隻是負責告訴你該執行那些任務,或者說哪些回調被觸發了,真正的邏輯還是在程序中執行的。
在上邊簡單的說明了兩種任務的差别,以及<code>Event Loop</code>的作用,那麼在真實的浏覽器中是什麼表現呢?
首先要明确的一點是,宏任務必然是在微任務之後才執行的(因為微任務實際上是宏任務的其中一個步驟)
<code>I/O</code>這一項感覺有點兒籠統,有太多的東西都可以稱之為<code>I/O</code>,點選一次<code>button</code>,上傳一個檔案,與程式産生互動的這些都可以稱之為<code>I/O</code>。
假設有這樣的一些<code>DOM</code>結構:
如果點選<code>#inner</code>,其執行順序一定是:<code>click</code> -> <code>promise</code> -> <code>observer</code> -> <code>click</code> -> <code>promise</code> -> <code>observer</code> -> <code>animationFrame</code> -> <code>animationFrame</code> -> <code>timeout</code> -> <code>timeout</code>。
因為一次<code>I/O</code>建立了一個宏任務,也就是說在這次任務中會去觸發<code>handler</code>。
按照代碼中的注釋,在同步的代碼已經執行完以後,這時就會去檢視是否有微任務可以執行,然後發現了<code>Promise</code>和<code>MutationObserver</code>兩個微任務,遂執行之。
因為<code>click</code>事件會冒泡,是以對應的這次<code>I/O</code>會觸發兩次<code>handler</code>函數(一次在<code>inner</code>、一次在<code>outer</code>),是以會優先執行冒泡的事件(早于其他的宏任務),也就是說會重複上述的邏輯。
在執行完同步代碼與微任務以後,這時繼續向後查找有木有宏任務。
需要注意的一點是,因為我們觸發了<code>setAttribute</code>,實際上修改了<code>DOM</code>的屬性,這會導緻頁面的重繪,而這個<code>set</code>的操作是同步執行的,也就是說<code>requestAnimationFrame</code>的回調會早于<code>setTimeout</code>所執行。
使用上述的示例代碼,如果将手動點選<code>DOM</code>元素的觸發方式變為<code>$inner.click()</code>,那麼會得到不一樣的結果。
在<code>Chrome</code>下的輸出順序大緻是這樣的:
<code>click</code> -> <code>click</code> -> <code>promise</code> -> <code>observer</code> -> <code>promise</code> -> <code>animationFrame</code> -> <code>animationFrame</code> -> <code>timeout</code> -> <code>timeout</code>。
與我們手動觸發<code>click</code>的執行順序不一樣的原因是這樣的,因為并不是使用者通過點選元素實作的觸發事件,而是類似<code>dispatchEvent</code>這樣的方式,我個人覺得并不能算是一個有效的<code>I/O</code>,在執行了一次<code>handler</code>回調注冊了微任務、注冊了宏任務以後,實際上外邊的<code>$inner.click()</code>并沒有執行完。
是以在微任務執行之前,還要繼續冒泡執行下一次事件,也就是說觸發了第二次的<code>handler</code>。
是以輸出了第二次<code>click</code>,等到這兩次<code>handler</code>都執行完畢後才會去檢查有沒有微任務、有沒有宏任務。
兩點需要注意的:
<code>.click()</code>的這種觸發事件的方式個人認為是類似<code>dispatchEvent</code>,可以了解為同步執行的代碼
<code>MutationObserver</code>的監聽不會說同時觸發多次,多次修改隻會有一次回調被觸發。
這就像去飯店點餐,服務員喊了三次,XX号的牛肉面,不代表她會給你三碗牛肉面。
上述觀點參閱自Tasks, microtasks, queues and schedules,文中有動畫版的講解
Node也是單線程,但是在處理<code>Event Loop</code>上與浏覽器稍微有些不同,這裡是Node官方文檔的位址。
就單從API層面上來了解,Node新增了兩個方法可以用來使用:微任務的<code>process.nextTick</code>以及宏任務的<code>setImmediate</code>。
在官方文檔中的定義,<code>setImmediate</code>為一次<code>Event Loop</code>執行完畢後調用。
<code>setTimeout</code>則是通過計算一個延遲時間後進行執行。
但是同時還提到了如果在主程序中直接執行這兩個操作,很難保證哪個會先觸發。
因為如果主程序中先注冊了兩個任務,然後執行的代碼耗時超過<code>XXs</code>,而這時定時器已經處于可執行回調的狀态了。
是以會先執行定時器,而執行完定時器以後才是結束了一次<code>Event Loop</code>,這時才會執行<code>setImmediate</code>。
有興趣的可以自己試驗一下,執行多次真的會得到不同的結果。
但是如果後續添加一些代碼以後,就可以保證<code>setTimeout</code>一定會在<code>setImmediate</code>之前觸發了:
如果在另一個宏任務中,必然是<code>setImmediate</code>先執行:
就像上邊說的,這個可以認為是一個類似于<code>Promise</code>和<code>MutationObserver</code>的微任務實作,在代碼執行的過程中可以随時插入<code>nextTick</code>,并且會保證在下一個宏任務開始之前所執行。
在使用方面的一個最常見的例子就是一些事件綁定類的操作:
因為上述的代碼在執行個體化<code>Lib</code>對象時是同步執行的,在執行個體化完成以後就立馬發送了<code>init</code>事件。
而這時在外層的主程式還沒有開始執行到<code>lib.on('init')</code>監聽事件的這一步。
是以會導緻發送事件時沒有回調,回調注冊後事件不會再次發送。
我們可以很輕松的使用<code>process.nextTick</code>來解決這個問題:
這樣會在主程序的代碼執行完畢後,程式空閑時觸發<code>Event Loop</code>流程查找有沒有微任務,然後再發送<code>init</code>事件。
關于有些文章中提到的,循環調用<code>process.nextTick</code>會導緻報警,後續的代碼永遠不會被執行,這是對的,參見上邊使用的雙重循環實作的<code>loop</code>即可,相當于在每次<code>for</code>循環執行中都對數組進行了<code>push</code>操作,這樣循環永遠也不會結束
因為,<code>async/await</code>本質上還是基于<code>Promise</code>的一些封裝,而<code>Promise</code>是屬于微任務的一種。是以在使用<code>await</code>關鍵字與<code>Promise.then</code>效果類似:
async函數在await之前的代碼都是同步執行的,可以了解為await之前的代碼屬于<code>new Promise</code>時傳入的代碼,await之後的所有代碼都是在<code>Promise.then</code>中的回調
JavaScript的代碼運作機制在網上有好多文章都寫,本人道行太淺,隻能簡單的說一下自己對其的了解。
并沒有去生摳文檔,一步一步的列出來,像什麼檢視目前棧、執行選中的任務隊列,各種balabala。
感覺對實際寫代碼沒有太大幫助,不如簡單的入個門,掃個盲,大緻了解一下這是個什麼東西就好了。
推薦幾篇參閱的文章:
tasks-microtasks-queues-and-schedules
understanding-js-the-event-loop
了解Node.js裡的process.nextTick()
浏覽器中的EventLoop說明文檔
Node中的EventLoop說明文檔
requestAnimationFrame | MDN
MutationObserver | MDN