《一文看懂瀏覽器事件循環》
- 2019 年 12 月 16 日
- 筆記
實際上瀏覽器的事件循環標準是由 HTML 標準規定的,具體來說就是由whatwg規定的,具體內容可以參考event-loops in browser。而NodeJS中事件循環其實也略有不同,具體可以參考event-loops in nodejs
我們在講解事件模型
的時候,多次提到了事件循環。事件
指的是其所處理的對象就是事件本身,每一個瀏覽器都至少有一個事件循環,一個事件循環至少有一個任務隊列。循環
指的是其永遠處於一個「無限循環」中。不斷將註冊的回調函數推入到執行棧。
那麼事件循環究竟是用來做什麼的?瀏覽器的事件循環和NodeJS的事件循環有什麼不同?讓我們從零開始,一步一步探究背後的原因。
為什麼要有事件循環
JS引擎
要回答這個問題,我們先來看一個簡單的例子:
function c() {} function b() { c(); } function a() { b(); } a();
以上一段簡單的JS程式碼,究竟是怎麼被瀏覽器執行的?
首先,瀏覽器想要執行JS腳本,需要一個「東西」,將JS腳本(本質上是一個純文本),變成一段機器可以理解並執行的電腦指令。這個「東西」就是JS引擎,它實際上會將JS腳本進行編譯和執行,整個過程非常複雜,這裡不再過多介紹,感興趣可以期待下我的V8章節,如無特殊說明,以下都拿V8來舉例子。
有兩個非常核心的構成,執行棧
和堆
。執行棧中存放正在執行的程式碼,堆中存放變數的值,通常是不規則的。
當V8執行到a()
這一行程式碼的時候,a會被壓入棧頂。

在a的內部,我們碰到了b()
,這個時候b被壓入棧頂。

在b的內部,我們又碰到了c()
,這個時候c被壓入棧頂。

c執行完畢之後,會從棧頂移除。

函數返回到b,b也執行完了,b也從棧頂移除。

同樣a也會被移除。

整個過程用動畫來表示就是這樣的:

這個時候我們還沒有涉及到堆記憶體
和執行上下文棧
,一切還比較簡單,這些內容我們放到後面來講。
DOM 和 WEB API
現在我們有了可以執行JS的引擎,但是我們的目標是構建用戶介面
,而傳統的前端用戶介面是基於DOM構建的,因此我們需要引入DOM。DOM是文檔對象模型
,其提供了一系列JS可以直接調用的介面,理論上其可以提供其他語言的介面,而不僅僅是JS。而且除了DOM介面可以給JS調用,瀏覽器還提供了一些WEB API。DOM也好,WEB API也好,本質上和JS沒有什麼關係,完全不一回事。JS對應的ECMA規範,V8用來實現ECMA規範,其他的它不管。這也是JS引擎和JS執行環境的區別,V8是JS引擎,用來執行JS程式碼,瀏覽器和Node是JS執行環境,其提供一些JS可以調用的API即JS bindings
。
由於瀏覽器的存在,現在JS可以操作DOM和WEB API了,看起來是可以構建用戶介面啦。有一點需要提前講清楚,V8隻有棧和堆,其他諸如事件循環,DOM,WEB API它一概不知。原因前面其實已經講過了,因為V8隻負責JS程式碼的編譯執行,你給V8一段JS程式碼,它就從頭到尾一口氣執行下去,中間不會停止。
另外這裡我還要繼續提一下,JS執行棧和渲染執行緒是相互阻塞的。為什麼呢?本質上因為JS太靈活了,它可以去獲取DOM中的諸如坐標等資訊。如果兩者同時執行,就有可能發生衝突,比如我先獲取了某一個DOM節點的x坐標,下一時刻坐標變了。JS又用這個「舊的」坐標進行計算然後賦值給DOM,衝突便發生了。解決衝突的方式有兩種:
- 限制JS的能力,你只能在某些時候使用某些API。這種做法極其複雜,還會帶來很多使用不便。
- JS和渲染執行緒不同時執行就好了,一種方法就是現在廣泛採用的
相互阻塞
。實際上這也是目前瀏覽器廣泛採用的方式。
單執行緒 or 多執行緒 or 非同步
前面提到了你給V8一段JS程式碼,它就從頭到尾一口氣執行下去,中間不會停止
。為什麼不停止,可以設計成可停止么,就好像C語言一樣?
假設我們需要獲取用戶資訊,獲取用戶的文章,獲取用的朋友。
單執行緒無非同步
由於是單執行緒無非同步,因此我們三個介面需要採用同步方式。
fetchUserInfoSync().then(doSomethingA); // 1s fetchMyArcticlesSync().then(doSomethingB);// 3s fetchMyFriendsSync().then(doSomethingC);// 2s
由於上面三個請求都是同步執行的,因此上面的程式碼會先執行fetchUserInfoSync
,一秒之後執行fetchMyArcticlesSync
,再過三秒執行fetchMyFriendsSync
。最可怕的是我們剛才說了JS執行棧和渲染執行緒是相互阻塞的
。因此用戶就在這期間根本無法操作,介面無法響應,這顯然是無法接受的。
多執行緒無非同步
由於是多執行緒無非同步,雖然我們三個介面仍然需要採用同步方式,但是我們可以將程式碼分別在多個執行緒執行,比如我們將這段程式碼放在三個執行緒中執行。
執行緒一:
fetchUserInfoSync().then(doSomethingA); // 1s
執行緒二:
fetchMyArcticlesSync().then(doSomethingB); // 3s
執行緒三:
fetchMyFriendsSync().then(doSomethingC); // 2s

1575538849801.jpg
由於三塊程式碼同時執行,因此總的時間最理想的情況下取決與最慢的時間,也就是3s,這一點和使用非同步的方式是一樣的(當然前提是請求之間無依賴)。為什麼要說最理想呢?由於三個執行緒都可以對DOM和堆記憶體進行訪問,因此很有可能會衝突,衝突的原因和我上面提到的JS執行緒和渲染執行緒的衝突的原因沒有什麼本質不同。因此最理想情況沒有任何衝突的話是3s,但是如果有衝突,我們就需要藉助於諸如鎖
來解決,這樣時間就有可能高於3s了。相應地編程模型也會更複雜,處理過鎖的程式設計師應該會感同身受。
單執行緒 + 非同步
如果還是使用單執行緒,改成非同步是不是會好點?問題的是關鍵是如何實現非同步呢?這就是我們要講的主題 – 事件循環
。
事件循環究竟是怎麼實現非同步的?
我們知道瀏覽器中JS執行緒只有一個,如果沒有事件循環,就會造成一個問題。即如果JS發起了一個非同步IO請求,在等待結果返回的這個時間段,後面的程式碼都會被阻塞。我們知道JS主執行緒和渲染進程是相互阻塞的,因此這就會造成瀏覽器假死。如何解決這個問題?一個有效的辦法就是我們這節要講的事件循環
。
其實事件循環就是用來做調度的,瀏覽器和NodeJS中的事件循壞就好像作業系統的調度器一樣。
作業系統的調度器決定何時將什麼資源分配給誰。對於有執行緒模型的電腦,那麼作業系統執行程式碼的最小單位就是執行緒,資源分配的最小單位就是進程,程式碼執行的過程由作業系統進行調度,整個調度過程非常複雜。 我們知道現在很多電腦都是多核的,為了讓多個core同時發揮作用,即沒有一個core是特別閑置的,也沒有一個core是特別累的。作業系統的調度器會進行某一種神秘演算法,從而保證每一個core都可以分配到任務。這也就是我們使用NodeJS做集群的時候,Worker節點數量通常設置為core的數量的原因,調度器會盡量將每一個Worker平均分配到每一個core,當然這個過程並不是確定的,即不一定調度器是這麼分配的,但是很多時候都會這樣。
了解了作業系統調度器的原理,我們不妨繼續回頭看一下事件循環。事件循環本質上也是做調度的,只不過調度的對象變成了JS的執行。事件循環決定了V8什麼時候執行什麼程式碼。V8隻是負責JS程式碼的解析和執行,其他它一概不知。
瀏覽器或者NodeJS中觸發事件之後,到事件的監聽函數被V8執行這個時間段的所有工作都是事件循環在起作用。
我們來小結一下:
- 對於V8來說,它有:
- 調用棧(call stack)
這裡的單執行緒指的是只有一個call stack。只有一個call stack 意味著同一時間只能執行一段程式碼。
- 堆(heap)
- 對於瀏覽器運行環境來說:
- WEB API
- DOM API
- 任務隊列
事件來觸發事件循環進行流動
以如下程式碼為例:
function c() {} function b() { c(); } function a() { setTimeout(b, 2000) } a();
執行過程是這樣的:

因此事件循環之所以可以實現非同步,是因為碰到非同步執行的程式碼「比如fetch,setTimeout」,瀏覽器會將用戶註冊的回調函數存起來,然後繼續執行後面的程式碼。等到未來某一個時刻,「非同步任務」完成了,會觸發一個事件,瀏覽器會將「任務的詳細資訊」作為參數傳遞給之前用戶綁定的回調函數。具體來說,就是將用戶綁定的回調函數推入瀏覽器的執行棧。
但並不是說隨便推入的,只有瀏覽器將當然要執行的JS腳本「一口氣」執行完,要」換氣「的時候才會去檢查有沒有要被處理的「消息」。如果於則將對應消息綁定的回調函數推入棧。當然如果沒有綁定事件,這個事件消息實際上會被丟棄,不被處理。比如用戶觸發了一個click事件,但是用戶沒有綁定click事件的監聽函數,那麼實際上這個事件會被丟棄掉。
我們來看一下加入用戶交互之後是什麼樣的,拿點擊事件來說:
$.on('button', 'click', function onClick() { setTimeout(function timer() { console.log('You clicked the button!'); }, 2000); }); console.log("Hi!"); setTimeout(function timeout() { console.log("Click the button!"); }, 5000); console.log("Welcome to loupe.");
上述程式碼每次點擊按鈕,都會發送一個事件,由於我們綁定了一個監聽函數。因此每次點擊,都會有一個點擊事件的消息產生,瀏覽器會在「空閑的時候」對應將用戶綁定的事件處理函數推入棧中執行。
偽程式碼:
while (true) { if (queue.length > 0) { queue.processNextMessage() } }
動畫演示:

(在線觀看)
加入宏任務&微任務
我們來看一個更複製的例子感受一下。
console.log(1) setTimeout(() => { console.log(2) }, 0) Promise.resolve().then(() => { return console.log(3) }).then(() => { console.log(4) }) console.log(5)
上面的程式碼會輸出:1、5、3、4、2。如果你想要非常嚴謹的解釋可以參考 whatwg 對其進行的描述 -event-loop-processing-model。
下面我會對其進行一個簡單的解釋。
- 瀏覽器首先執行宏任務,也就是我們script(僅僅執行一次)
- 完成之後檢查是否存在微任務,然後不停執行,直到清空隊列
- 執行宏任務
其中:
宏任務主要包含:setTimeout、setInterval、setImmediate、I/O、UI交互事件
微任務主要包含:Promise、process.nextTick、MutaionObserver 等

有了這個知識,我們不難得出上面程式碼的輸出結果。
由此我們可以看出,宏任務&微任務
只是實現非同步過程中,我們對於訊號的處理順序不同而已。如果我們不加區分,全部放到一個隊列,就不會有宏任務&微任務
。這種人為劃分優先順序的過程,在某些時候非常有用。
加入執行上下文棧
說到執行上下文,就不得不提到瀏覽器執行JS函數其實是分兩個過程的
。一個是創建階段Creation Phase
,一個是執行階段Execution Phase
。
同執行棧一樣,瀏覽器每遇到一個函數,也會將當前函數的執行上下文棧推入棧頂。
舉個例子:
function a(num) { function b(num) { function c(num) { const n = 3 console.log(num + n) } c(num); } b(num); } a(1);
遇到上面的程式碼。首先會將a的壓入執行棧,我們開始進行創建階段Creation Phase
, 將a的執行上下文壓入棧。然後初始化a的執行上下文,分別是VO,ScopeChain(VO chain)和 This。從這裡我們也可以看出,this其實是動態決定的。VO指的是variables, functions 和 arguments
。並且執行上下文棧也會同步隨著執行棧的銷毀而銷毀。
偽程式碼表示:
const EC = { 'scopeChain': { }, 'variableObject': { }, 'this': { } }

我們來重點看一下ScopeChain(VO chain)。如上圖的執行上下文大概長這個樣子,偽程式碼:
global.VO = { a: pointer to a(), scopeChain: [global.VO] } a.VO = { b: pointer to b(), arguments: { 0: 1 }, scopeChain: [a.VO, global.VO] } b.VO = { c: pointer to c(), arguments: { 0: 1 }, scopeChain: [b.VO, a.VO, global.VO] } c.VO = { arguments: { 0: 1 }, n: 3 scopeChain: [c.VO, b.VO, a.VO, global.VO] }
引擎查找變數的時候,會先從VOC開始找,找不到會繼續去VOB…,直到GlobalVO,如果GlobalVO也找不到會返回Referrence Error
,整個過程類似原型鏈的查找。
值得一提的是,JS是詞法作用域,也就是靜態作用域。換句話說就是作用域取決於程式碼定義的位置,而不是執行的位置,這也就是閉包產生的本質原因
。如果上面的程式碼改造成下面的:
function c() {} function b() {} function a() {} a() b() c()
或者這種:
function c() {} function b() { c(); } function a() { b(); } a();
其執行上下文棧雖然都是一樣的,但是其對應的scopeChain則完全不同,因為函數定義的位置發生了變化。拿上面的程式碼片段來說,c.VO會變成這樣:
c.VO = { scopeChain: [c.VO, global.VO] }
也就是說其再也無法獲取到a和b中的VO了。
總結
通過這篇文章,希望你對單執行緒,多執行緒,非同步,事件循環,事件驅動等知識點有了更深的理解和感悟。除了這些大的層面,我們還從執行棧,執行上下文棧角度講解了我們程式碼是如何被瀏覽器運行的,我們順便還解釋了作用域和閉包產生的本質原因。
最後我總結了一個瀏覽器運行程式碼的整體原理圖,希望對你有幫助:

參考
- Node.js event loop – logrocket
- event-loop – nodejs.org
- what-is-the-execution-context-in-javascript
- Event Loop in JS – youtube