JS事件循環(Event Loop)
概念
JavaScript 有一個基於事件循環的並發模型,事件循環負責執行代碼、收集和處理事件以及執行隊列中的子任務。這個模型與其它語言中的模型截然不同,比如 C 和 Java。(摘自MDN)
簡單地說,對於 JS 運行中的任務,JS 有一套處理收集,排隊,執行的特殊機制,我們把這套處理機制稱為事件循環(Event Loop)。
為了更深刻的理解事件循環,我們先了解幾個相關概念
單線程
我們都知道 JS 是單線程的,什麼意思呢?
JS 單線程指的是 javascript 引擎(如V8)在同一時刻只能處理一個任務。
有人或許會問,異步任務 ajax 難道不是可以和 JS 代碼同時執行么?
答案是可以的,但是這和 JS 單線程並不衝突,前面說過 javascript 引擎(如V8)在同一時刻只能處理一個任務。但這並不是說瀏覽器在同一個時刻只能處理一件事情,實際上 ajax 等異步任務不是在 JS 引擎上運行的,ajax 在瀏覽器處理網絡的模塊中執行,此時不會影響到 JS 引擎的任務處理。
需要強調的是,同一時刻只能處理一個任務,並不表示此時處理的只有一個函數,我們可以有多個正在處理的函數,同時擁有多個執行環境,後面會有分析。
執行環境
關於執行環境可以參考我之前的博客淺談JS執行環境及作用域。
執行環境是 JS 代碼語句執行的環境,包括全局執行環境和函數執行環境。
-
全局執行環境:全局環境是最外圍的一個執行環境,根據ECMAScript實現所在的宿主環境不同,表示執行環境的對象也不一樣,在web中,全局執行環境被認為是window對象。
-
函數執行環境:每個函數都有自己的執行環境。
當一個任務執行時,相應的會對應一個動態變化的執行環境棧,這個執行環境棧包括了不同的執行環境,是一個後進先出的結構。
以下面代碼為例,我們看看執行環境棧的動態變化
function Fn1() {
var a = 1;
function Fn2() {
var b = 2;
}
Fn2(); // 當程序執行到此時
}
Fn1();
變量對象
關於變量對象可以參考我之前的博客淺談JS執行環境及作用域
每個執行環境都有一個變量對象與之關聯(一一對應),變量對象包含了執行環境中定義的所有變量及函數。(在此處可以思考下為什麼我們提倡盡量少創建全局變量,答案就是因為全局環境對應的變量對象一直會存在內存中。)
事件循環機制
我們先看看 MDN 上的一張圖片
上面這張圖很好地展示了 JS 中的事件循環機制,我們可以看到圖中主要包括三個部分,Stack,Heap,Queue,下面逐個分析。
-
Stack 表示計算機的棧結構,此處 Stack 區域表示的是當前 JS 線程正在處理的任務(一個任務)。結合執行環境部分,我們其實可以把這些 Frame 的組合當作當前的執行環境棧。一個 Frame 表示一個執行環境。這裡也解釋了一個任務下其實可以包含多個相關函數。
-
Heap 一般用來表示計算機內存,此處 Heap 表示當前任務下相關的數據,結合上面變量對象的概念,我們可以把其中的 Object 標籤當作是執行環境對應的變量對象。一個執行環境推入執行環境棧時,創建一個變量對象放入 Heap 區域,當執行環境棧推出這個執行環境時,其相對應的變量對象在 Heap 移除並銷毀。如果再深入點,我們可以發現,裏面 Object 的集合其實就是我們的作用域鏈的變量對象集合。
-
Queue 在計算機中表示隊列,是一種先進先出的數據結構。此處 Queue 區表示了當前正在排隊的任務集合,我們稱之為任務隊列。一個 Message 表示一個待執行任務,它們是按順序排隊的。
分析完圖片的不同區域,我們就可以很輕鬆地分析出這張圖中闡釋的事件環境機制了
-
JS 線程在同一時間只執行一個任務,期間可能創建多個函數執行環境,對應 Frame。
-
在執行任務的時候,隨時執行環境棧的動態變化,相對應的變量對象不斷創建銷毀,對應 Object。
-
異步任務 ajax I/O 等得到結果時,會將其回調作為一個任務添加到任務隊列,排隊等待執行。
-
當 JS 線程中的任務執行完畢,會讀取任務隊列 Queue,並將隊列中的第一個任務添加到 JS 線程中並執行。
-
循環 3 4 步,異步任務完成後不斷地往任務隊列中添加任務,線程空閑時從任務列表讀取任務並執行。
事件循環下的宏任務與微任務
通常我們把異步任務分為宏任務與微任務,它們的區分在於:
-
宏任務(macro-task):一般是 JS 引擎和宿主環境發生通信產生的回調任務,比如 setTimeout,setInterval 是瀏覽器進行計時的,其中回調函數的執行時間需要瀏覽器通知到 JS 引擎,網絡模塊, I/O處理的通信回調也是。包含有 setTimeout,setInterval,DOM事件回調,ajax請求結束後的回調,整體 script 代碼,setImmediate。
-
微任務(micro-task):一般是宏任務在線程中執行時產生的回調,如 Promise,process.nextTick,Object.observe(已廢棄), MutationObserver(DOM監聽),這些都是 JS 引擎自身可以監聽到回調。
上面我們了解了宏任務與微任務的分類,那麼為什麼我們要將其分為宏任務與微任務呢?主要是因為其添加到事件循環中的任務隊列的機制不同。
在事件循環中,任務一般都是由宏任務開始執行的(JS代碼的加載執行),在宏任務的執行過程中,可能會產生新的宏任務和微任務,這時候宏任務(如ajax回調)會被添加到任務隊列的末尾等待事件循環機制執行,而微任務則會被添加到當前任務隊列的前端,也是等待事件循環機制的執行。
其中相同類型的宏任務或微任務會按照回調的先後順序進行排序,而不同任務類型的任務會有一定的優先級,按照不同類型任務區分
宏任務優先級,主代碼塊 > setImmediate > MessageChannel > setTimeout / setInterval
微任務優先級,process.nextTick > Promise > MutationObserver
舉個🌰
我們來分析下面這段代碼的打印順序
// setTimeout1
setTimeout(() => {
console.log(1)
new Promise((resolve) => {
resolve()
// Promise1
}).then(() => {
console.log(2)
});
})
// setTimeout2
setTimeout(() => {
console.log(3)
})
new Promise((resolve) => {
console.log(4)
resolve()
console.log(5)
// Promise2
}).then(() => {
console.log(6)
})
console.log(7)
new Promise((resolve) => {
resolve()
// Promise3
}).then(() => {
console.log(8)
})
我們假設這段代碼正在 JS 的線程中執行(script 代碼屬於宏任務),在執行的時候產生了一些異步任務,setTimeout 和 Promise。其中 setTimeout 為宏任務,Promise 屬於微任務。
根據上面的宏任務,微任務的在任務隊列的添加機制,我們可以得到在代碼執行過程中的任務隊列將如下所示
分析出了任務隊列後,我們就可以輕鬆得到打印順序了
首先執行宏任務,按照從上至下的執行順序依次打印 4 5 7
接着按照任務隊列的先後順序執行異步任務,依次打印 6 8 1 2 3
結語
以上便是我對 Event Loop 的理解。JS 的事件循環機制是個很基礎的概念,掌握它可以幫助我們理解 JS 中代碼的執行順序及原理,希望無論是初學者還是有一定基礎的同學都能真正弄明白。如果本文有理解錯誤或說的不明白的地方歡迎大家指出並添加評論。
參考
歡迎來前端學習打卡群一起學習~516913974