原文發自個人 GitHub blog,歡迎關注javascript
咱們都知道 JavaScript 是一門單線程語言,這意味着同一事件只能執行一個任務,結束了才能去執行下一個。若是前面的任務沒有執行完,後面的任務就會一直等待。試想,有一個耗時很長的網絡請求,若是全部任務都須要等待這個請求完成才能繼續,顯然是不合理的而且咱們在瀏覽器中也沒有體驗過這種狀況(除非你要同步請求 Ajax),究其緣由,是 JavaScript 藉助異步機制來實現了任務的調度。html
程序中如今運行的部分和未來運行的部分之間的關係就是異步編程的核心。前端
咱們先看一個面試題:vue
try {
setTimeout(() => {
throw new Error("Error - from try statement");
}, 0);
} catch (e) {
console.error(e);
}
複製代碼
上面這個例子會輸出什麼?答案是:html5
說明並無 catch 到丟出來的 error,這個例子可能理解起來費勁一點。java
若是我換一個例子git
console.log("A");
setTimeout(() => {
console.log("B");
}, 100);
console.log("C");
複製代碼
稍微瞭解一點瀏覽器中異步機制的同窗都能答出會輸出 「A C B」,本文會經過分析 event loop 來對瀏覽器中的異步進行梳理,並搞清上面的問題。github
函數調用棧其實就是執行上下文棧(Execution Context Stack),每當調用一個函數時就會產生一個新的執行上下文,同時新產生的這個執行上下文就會被壓入執行上下文棧中。web
全局上下文最早入棧,而且在離開頁面時開會出棧,JavaScript 引擎不斷的執行上下文棧中棧頂的那個執行上下文,在它執行完畢後將它出棧,直到整個執行棧爲空。關於執行棧有五點比較關鍵:面試
- 單線程(這是由 JavaScript 引擎決定的)。
- 同步執行(它會一直同步執行棧頂的函數)。
- 只有一個全局上下文。
- 可有無數個函數上下文(理論是函數上下文沒有限制,可是太多了會爆棧)。
- 每一個函數調用都會建立一個新的
執行上下文
,哪怕是遞歸調用。
這裏首先要明確一個問題,函數上下文執行棧是與 JavaScript 引擎(Engine)相關的概念,而異步/回調是與運行環境(Runtime)相關的概念。
若是執行棧與異步機制徹底無關,咱們寫了無數遍的點擊觸發回調是如何作到的呢?是運行環境(瀏覽器/Node)來完成的, 在瀏覽器中,異步機制是藉助 event loop 來實現的,event loop 是異步的一種實現機制。JavaScript 引擎只是「傻傻」的一直執行棧頂的函數,而運行環境負責管理在何時壓入執行上下文棧什麼函數來讓引擎執行。
JavaScript 引擎自己並無時間的概念,只是一個按需執行 JavaScript 任意代碼片斷的環境。「事件」( JavaScript 代碼執行)調度老是由包含它的環境進行。
另外,從一個側面能夠反應出執行上下文棧與異步無關的 —— 執行上下文棧是寫在 ECMA-262 的規範中,須要遵照它的是瀏覽器的 JavaScript 引擎,好比 V八、Quantum 等。event loop 的是寫在 HTML 的規範中,須要遵照它的是各個瀏覽器,好比 Chrome、Firefox 等。
咱們經過 HTML5規範 的定義來看 event loop 的定義來看模型,本章節全部引用的部分都是翻譯自規範。
爲了協調時間,用戶交互,腳本,界面渲染,網絡等等,用戶代理必須使用下一節描述的 event loops。event loops 分爲兩種:瀏覽器環境及爲 Web Worker 服務的。
本文只關注瀏覽器部分,因此忽略 Web Worker。JavaScript 引擎並非獨立運行的,它須要運行在宿主環境中, 因此其實用戶代理(user agent)在這個情境下更好的翻譯應該是運行環境或者宿主環境,也就是瀏覽器。
每一個用戶代理必須至少有一個 browsing context event loop,但每一個 unit of related similar-origin browsing contexts 最多隻能有一個。
關於 unit of related similar-origin browsing contexts,節選一部分規範的介紹:
Each unit of related browsing contexts is then further divided into the smallest number of groups such that every member of each group has an active document with an origin that, through appropriate manipulation of the
document.domain
attribute, could be made to be same origin-domain with other members of the group, but could not be made the same as members of any other group. Each such group is a unit of related similar-origin browsing contexts.
簡而言之就是一個瀏覽器環境(unit of related similar-origin browsing contexts.),只能有一個事件循環(event loop)。
event loop 又是幹什麼的呢?
每一個 event loop 都有一個或多個 task queues. 一個 task queue 是 tasks 的有序的列表, 是用來響應以下以下工做的算法:
事件
在
EventTarget
觸發的時候發佈一個事件Event
對象,這一般由一個專屬的 task 完成。注意:並非全部的事件都從是 task queue 中發佈,也有不少是來自其餘的 tasks。
解析
HTML 解析器 令牌化而後產生 token 的過程,是一個典型的 task。
回調函數
通常使用一個特定的 task 來調用一個回調函數。
使用資源(譯者注:其實就是網絡)
當算法 獲取 到了資源,若是獲取資源的過程是非阻塞的,那麼一旦獲取了部分或者所有的內容將由 task 來執行這個過程。
響應 DOM 的操做
有一些元素會對 DOM 的操做產生 task,好比當元素被 插入到 document 時。
能夠看到,一個頁面只有一個 event loop,可是一個 event loop 能夠有多個 task queues。
每一個來自相同 task source 並由相同 event loop(好比,
Document
的計時器產生的回調函數,Document
的鼠標移動產生的事件,Document
的解析器產生的 tasks) 管理的 task 都必須加入到同一個 task queue 中,但是來自不一樣 task sources 的 tasks 可能會被排入到不一樣的 task queues 中。
來自相同的 task source 的 task 將會被排入相同的 task queue,可是規範說來自不一樣 task sources 的 tasks 可能會被排入到不一樣的 task queues 中,也就是說一個 task queue 中可能排列着來自不一樣 task sources 的 tasks,可是具體什麼 task source 對應什麼 task queue,規範並無具體說明。
可是規範對 task source 進行了分類:
以下 task sources 被大量應用於本規範或其餘規範無關的特性中:
DOM 操做的 task source
這種 task source 用來對 DOM 的操做進行反應,好比像 inserted into the document 的非阻塞的行爲。
用戶操做的 task source
這種 task source 用來響應用戶的反應,好比鼠標和鍵盤的事件。這些用來反應用戶輸入的事件必須由 user interaction task source 來觸發並排入 tasks queued。
網絡 task source
這種 task source 用來反應網絡活動的響應。
時間旅行 task source
這種 task source 用來將
history.back()
等 API 排入 task queue。
通常咱們看個各個文章中對於 task queue 的描述都是隻有一個,不管是網絡,用戶時間內仍是計時器都會被 Web APIs 排入到用一個 task queue 中,但事實上規範中明確表示了是有多個 task queues,並舉例說明了這樣設計的意義:
舉例來講,一個用戶代理能夠有一個處理鍵盤鼠標事件的 task queue(來自 user interaction task source),還有一個 task queue 來處理全部其餘的。用戶代理能夠以 75% 的概率先處理鼠標和鍵盤的事件,這樣既不會完全不執行其餘 task queues 的前提下保證用戶界面的響應, 並且不會讓來自同一個 task source 的事件順序錯亂。
接着看。
當用戶代理將要排入任務時,必須將任務排入相關的 event loop 的 task queues。
這句話很關鍵,是用戶代理(宿主環境/運行環境/瀏覽器)來控制任務的調度,這裏就引出了下一章的 Web APIs。
接下來我麼來看看 event loop 是如何執行 task 的。
咱們能夠形象的理解 event loop 爲以下形式的存在:
while (queue.waitForMessage()) {
queue.processNextMessage();
}
複製代碼
event loop 會在整個頁面存在時不停的將 task queues 中的函數拿出來執行,具體的規則以下:
一個 event loop 在它存在的必須不斷的重複一下的步驟:
- 從 task queues 中取出 event loop 的最早添加的 task,若是沒有能夠選擇的 task,那麼跳到第
Microtasks
步。- 設定 event loop 當前執行的 task 爲上一步中選擇的 task。
執行
:執行選中的 task。- 將 event loop 的當前執行 task 設爲 null。
- 從 task queue 中將剛剛執行的 task 移除。
Microtasks
: 執行 microtask 檢查點的任務。- 更新渲染,若是是瀏覽器環境中的 event loop(相對來講就是 Worker 中的 event loop)那麼執行如下步驟:
- 若是是 Worker 環境中的 event loop(例如,在 WorkerGlobalScope 中運行),但是在 event loop 的 task queues 中沒有 tasks 而且 WorkerGlobalScope 對象爲關閉的標誌,那麼銷燬 event loop,終止這些步驟的執行,恢復到 run a worker 的步驟。
- 回到第 1 步。
規範引出了 microtask,
每一個 event loop 都有一個 microtask queue。microtask 是一種要排入 microtask queue 的而不是 task queue 的任務。有兩種 microtasks:solitary callback microtasks 和 compound microtasks。
規範只介紹了 solitary callback microtasks,compound microtasks 能夠先忽略掉。
當一個 microtask 要被排入的時候,它必須被排如相關 event loop 的 microtask queue,microtask 的 task source 是 microtask task source.
當用戶代理執行到了 microtasks 檢查點的時候,若是 performing a microtask checkpoint flag 爲 false,則用戶代理必須運行下面的步驟:
將 performing a microtask checkpoint flag 置爲 true。
處理 microtask queue
:若是 event loop 的 microtask queue 是空的,直接跳到Done
步。選擇 event loop 的 microtask queue 中最老的 microtask。
設定 event loop 當前執行的 task 爲上一步中選擇的 task。
執行
:執行選中的 task。注意:這有可能包含執行含有 clean up after running script 步驟的腳本,而後會致使再次 執行 microtask 檢查點的任務,這就是咱們要使用 performing a microtask checkpoint flag 的緣由。
將 event loop 的當前執行 task 設爲 null。
將上一步中執行的 microtask 從 microtask queue 中移除,而後返回
處理 microtask queue
步驟。
完成
: 對每個 responsible event loop 就是當前的 event loop 的 environment settings object,給 environment settings object 發一個 rejected promises 的通知。將 performing a microtask checkpoint flag 設爲 false。
整個流程以下圖:
task 主要包含:
microtask 主要包含:
在上一章講講到了用戶代理(宿主環境/運行環境/瀏覽器)來控制任務的調度,task queues 只是一個隊列,它並不知道何時有新的任務推入,也不知道何時任務出隊。event loop 會根據規則不斷將任務出隊,那誰來將任務入隊呢?答案是 Web APIs。
咱們都知道 JavaScript 的執行是單線程的,可是瀏覽器並非單線程的,Web APIs 就是一些額外的線程,它們一般由 C++ 來實現,用來處理非同步事件好比 DOM 事件,http 請求,setTimeout 等。他們是瀏覽器實現併發的入口,對於 Node.JavaScript 來講,就是一些 C++ 的 APIs。
WebAPIs 自己並不能直接將回調函數放在函數調用棧中來執行,不然它會隨機在整個程序的運行過程當中出現。每一個 WebAPIs 會在其執行完畢的時候將回調函數推入到對應的任務隊列中,而後由 event loop 按照規則在函數調用棧爲空的時候將回調函數推入執行棧中執行。event loop 的基本做用就是檢查函數調用棧和任務隊列,並在函數調用棧爲空時將任務隊列中的的第一個任務推入執行棧中,每個任務都在下一個任務執行前執行完畢。
WebAPIs 提供了多線程來執行異步函數,在回調發生的時候,它們會將回調函數和推入任務隊列中並傳遞返回值。
至此,咱們已經瞭解了執行上下文棧,event loop 及 WebAPIs,它們的關係能夠用下圖來表示(圖片來自網絡,原始出處已沒法考證),一輪 event loop 的文字版流程以下:
首先執行一個 task,若是整個第一輪 event loop,那麼總體的 script 就是一個 task,同步執行的代碼會直接放進 call stack(調用棧)中,諸如 setTimeout、fetch、ajax 或者事件的回調函數會由 Web APIs 進行管理,而後 call stack 繼續執行棧頂的函數。當網絡請求獲取到了響應或者 timer 的時間到了,Web APIs 就會將對應的回調函數推入對應的 task queues 中。event loop 不斷執行,一旦 event loop 中的 current task 爲 null,它就回去掃 task queues 有沒有 task,而後按照必定規則拿出 task queues 中一個最先入隊的回調函數(好比上面提到的以 75% 的概率優先執行鼠標鍵盤的回調函數所在的隊列,可是具體規則我還沒找到),取出的回調函數放入上下文執行棧就開始同步執行了,執行完以後檢查 event loop 中的 microtask queue 中的 microtask,按照規則將它們所有同步執行掉,最後完成 UI 的重渲染,而後再執行下一輪的 event loop...
JavaScript 引擎並非獨立運行的,它運行在宿主環境中
瞭解了上面 Web APIs,咱們知道瀏覽器中有一個 Timers 的 Web API 用來管理 setTimeout 和 setInterval 等計時器,在同步執行了 setTimeout 後,瀏覽器並無把你的回調函數掛在事件循環隊列中。 它所作的是設定一個定時器。 當定時器到時後, 瀏覽器會把你的回調函數放在事件循環中, 這樣, 在將來某個時刻的 tick 會摘下並執行這個回調。
可是若是定時器的任務隊列中已經被添加了其餘的任務,後面的回調就要等待。
let t1, t2
t1 = new Date().getTime();
// 1
setTimeout(()=>{
let i = 0;
while (i < 50000000) {i++}
console.log('block finished')
}
, 300)
// 2
setTimeout(()=>{
t2 = new Date().getTime();
console.log(t2 - t1)
}
, 300)
複製代碼
這個例子中,打印出來的時間戳就不會等於 300,雖然兩個 setTimeout 的函數都會在時間到了時被 Web API 排入任務隊列,而後 event loop 取出第一個 setTimeout 的回調開始執行,可是這個回調函數會同步阻塞一段時間,致使只有它執行完畢 event loop 才能執行第二個 setTimeout 的回調函數。
try {
setTimeout(() => {
throw new Error("Error - from try statement");
}, 0);
} catch (e) {
console.error(e);
}
複製代碼
回到最開始的那個問題,整個過程是這樣的:執行到 setTimeout
時先同步地將回調函數註冊給 Web APIs 的 timer,要清楚此時 setTimeout 的回調函數此時根本沒有入調用棧甚至連 task queue 都沒有進入,因此 try 的這個代碼塊就執行結束了,沒有拋出任何 error,catch 也被直接跳過,同步執行完畢。
等到 timer 的計時到了(要注意並不必定是下一個 event loop,由於 setTimeout 在每一個瀏覽器中的最短期是不肯定的,在 Chrome 中執行幾回也會發現每次時間都不一樣,0 ms ~ 2 ms 都有),會將 setTimeout 中的回調放入 task queue 中,此時 event loop 中的 current task 爲 null,就將這個回調函數設爲 current task 並開始同步執行,此時調用棧中只有一個全局上下文,try catch 已經結束了,就會直接將這個 error 丟出。
for (var i = 0; i < 5; i++) {
setTimeout((function(i) {
console.log(i);
})(i), i * 1000);
}
複製代碼
正確答案是當即輸出 「0 1 2 3 4」,setTime 的第一個參數接受的是一個函數或者字符串,這裏第一個參數是一個當即執行函數,返回值爲 undefined,而且在當即執行的過程當中就輸出了 "0 1 2 3 4",timer 沒有接收任何回調函數,就與 event loop 跟無關了。
new Promise(resolve => {
resolve(1);
Promise.resolve().then(() => console.log(2));
console.log(4)
}).then(t => console.log(t)); // a
console.log(3);
複製代碼
是阮老師推特上的一道題,首先 Promise 構造函數中的對象同步執行(不瞭解 Promise 的同窗能夠先看下 這篇文章),碰到 resolve(1)
,將當前 Promise 標記爲 resolve,可是注意它 then 的回調函數尚未被註冊,由於尚未執行到 a 處。繼續執行又碰到一個 Promise,而後也馬上被 resolved 了,而且執行它的 then 註冊,將第二個 then 的回調函數推入空的 microtaskQueue 中。繼續執行輸出一個 4,而後 a 處的 then 如今纔開始註冊,將第一個 Promise 的 then 回調函數推入 microtaskQueue 中。繼續執行輸出一個 3。如今 task queue 中的任務已經執行完畢,到了 microtask checkpoint flag,發現有兩個 microtask,按照添加的順序執行,第一個輸出一個 2,第二個輸出一個 1,最後再更新一下 UI 而後這一輪 event loop 就結束了,最終的輸出是"4 3 2 1"
筆者本人並無使用過 Vue,可是稍微知道一點 Vue 的 DOM 更新中有批量更新,緩衝在同一事件循環中的數據變化,即 DOM 只會被修改一次。
爲啥要用 microtask?根據HTML Standard,在每一個 task 運行完之後,UI 都會重渲染,那麼在 microtask 中就完成數據更新,當前 task 結束就能夠獲得最新的 UI 了。反之若是新建一個 task 來作數據更新,那麼渲染就會進行兩次。
在 event loop 那章的規範中明確的寫到,在 event loop 的一輪中會按照 task -> microTask -> UI render 的順序。用戶的代碼可能會屢次修改數據,而這些修改中後面的修改可能會覆蓋掉前面的修改,再加上 DOM 的操做是很昂貴的,必定要儘可能減小,因此要將用戶的修改 thunk 起來而後只修改一次 DOM,因此須要使用 microTask 在 UI 更新渲染前執行,就算有屢次修改,也會只修改一次 DOM,而後進行渲染。
更新一下,如今 Vue 的 nextTick 實現移除了 MutationObserver 的方式(兼容性緣由),取而代之的是使用 MessageChannel。
其實用什麼具體的 API 不是最關鍵的,重要的是使用 microTask 在 在 UI render 前進行 thunk。