JavaScript 事件迴圈機制

淘淘笙悅發表於2018-07-07

前端開發的童鞋應該都知道,JavaScript 是一門單執行緒的指令碼語言。這就意味著 JavaScript 程式碼在執行的時候,只有一個主執行緒來執行所有的任務,同一個時間只能做同一件事情。

那麼為什麼 JavaScript 不設計成多執行緒的語言呢?

這是由其執行的環境是瀏覽器環境所決定的。試想一下如果 JavaScript 是多執行緒語言的話,那麼當兩個執行緒同時對 Dom 節點進行操作的時候,則可能會出現有歧義的問題,例如一個執行緒操作的是在一個 Dom 節點中新增內容,另一個執行緒操作的是刪除該 Dom 節點,那麼應該以哪個執行緒為準呢?所以 JavaScript 作為瀏覽器的指令碼語言,其設計只能是單執行緒的。

需要注意的是,Html5 提出了 Web Worker,允許建立多個在後臺執行的子執行緒來執行 JavaScript 指令碼。但是由於子執行緒完全受主執行緒控制,而且不能夠干擾使用者介面(即不能操作 Dom),所以這並沒有改變 JavaScript 單執行緒的本質。

上面講到,JavaScript 是一門單執行緒的指令碼語言。所謂單執行緒,就是指所有的任務都需要排隊一個個執行,只有前一個任務執行完了才可以執行後一個任務。這就造成了一個問題,如果前一個任務耗時過長,則會阻塞下一個任務的執行,在頁面上使用者的感知便會是瀏覽器卡死的現象。

而由於在大部分的情況中,造成任務耗時過長不是任務本身計算量大而導致 CPU 處理不過來,而是因為該任務需要與 IO 裝置互動而導致的耗時過長,但這時 CPU 卻是處於閒置狀態的。所以為了解決這個問題,便有了本章節的 JavaScript(也可以說是瀏覽器的)事件迴圈(Event Loop)機制

在 JavaScript 事件迴圈機制中,使用到了三種資料物件,分別是棧(Stack)、堆(Heap)和佇列(Queue)。

  • 棧:一種後進先出(LIFO)的資料結構。可以理解為取乒乓球時的場景,後面放進去的乒乓球反而是最先取出來的。
  • 堆:一種樹狀的的資料結構。可以理解為在圖書館中取書的場景,可以通過圖書索引的方式直接找到需要的書。
  • 佇列:一種先進先出(FIFO)的資料結構。即我們平時排隊的場景,先排的人總是先出佇列。

在 JavaScript 事件迴圈機制中,使用的棧資料結構便是執行上下文棧,每當有函式被呼叫時,便會建立相對應的執行上下文並將其入棧;使用到堆資料結構主要是為了表示一個大部分非結構化的記憶體區域存放物件;使用到的佇列資料結構便是任務佇列,主要用於存放非同步任務。

棧、堆、佇列視覺化表示

執行上下文棧

在 JavaScript 程式碼執行過程中,會進入到不同的執行環境中,一開始執行時最先進入到全域性環境,此時全域性上下文首先被建立併入棧,之後當呼叫函式時則進入相應的函式環境,此時相應函式上下文被建立併入棧,當處於棧頂的執行上下文程式碼執行完畢後,則會將其出棧。這裡的棧便是執行上下文棧。

舉個例子~

function fn2() {
    console.log('fn2')
}
function fn1() {
    console.log('fn1')
    fn2();
}

fn1();
複製程式碼

上述程式碼中的執行上下文棧變化行為如下圖

執行上下文棧 ECStack

任務佇列

在 JavaScript 事件迴圈機制中,存在多種任務佇列,其分為巨集任務(macro-task)和微任務(micro-task)兩種。

  • 巨集任務包括:setTimeoutsetIntervalI/OUI rendering
  • 微任務包括:PromiseObject.observe(已廢棄)MutationObserver(html5新特性)

上述所描述的 setTimeout、Promise 等都是指一種任務源,其對應一種任務佇列,真正放入任務佇列中的,是任務源指定的非同步任務。在程式碼執行過程中,遇到上述任務源時,會將該任務源指定的非同步任務放入不同的任務佇列中。

不同的任務源對應的任務佇列其執行順序優先順序是不同的,上述巨集任務和微任務的先後順序代表了其任務佇列執行順序的優先順序。

即在巨集任務佇列中,各個佇列的優先順序為 setTimeout > setInterval > I/O 在微任務佇列中,各個佇列的優先順序為 Promise > Object.observe > MutationObserver

對於 UI rendering 來說,瀏覽器會在每次清空微任務佇列會根據實際情況觸發,這裡不做詳細贅述。

事件迴圈機制流程

  1. 主執行緒執行 JavaScript 整體程式碼,形成執行上下文棧,當遇到各種任務源時將其所指定的非同步任務掛起,接受到響應結果後將非同步任務放入對應的任務佇列中,直到執行上下文棧只剩全域性上下文;
  2. 將微任務佇列中的所有任務佇列按優先順序、單個任務佇列的非同步任務按先進先出(FIFO)的方式入棧並執行,直到清空所有的微任務佇列;
  3. 將巨集任務佇列中優先順序最高的任務佇列中的非同步任務按先進先出(FIFO)的方式入棧並執行;
  4. 重複第 2 3 步驟,直到清空所有的巨集任務佇列和微任務佇列,全域性上下文出棧。

簡單來說,事件迴圈機制的流程就是,主執行緒執行 JavaScript 整體程式碼後將遇到的各個任務源所指定的任務分發到各個任務佇列中,然後微任務佇列和巨集任務佇列交替入棧執行直到清空所有的任務佇列,全域性上下文出棧。

這裡要注意的是,任務源所指定的非同步任務,並不是立即被放入任務佇列中的,而是在接收到響應結果後才會將其放入任務佇列中排隊。如 setTimeout 中指定延遲事件為 1s,則在 1s 後才會將該任務源所指定的任務佇列放入佇列中;I/O 互動只有接收到響應結果後才將其非同步任務放入佇列中排隊等待執行。

事件迴圈機制流程

是不是感覺挺抽象的,舉個例子來實際感受一下~

console.log('global');

setTimeout(function() {
    console.log('setTimeout1');
    new Promise(function(resolve) {
        console.log('setTimeout1_promise');
        resolve();
    }).then(function() {
        console.log('setTimeout1_promiseThen')
    })
    process.nextTick(function() {
        console.log('setTimeout1_nextTick');
    })
},0)

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promiseThen1')
})

setImmediate(function() {
    console.log('setImmediate');
})

process.nextTick(function() {
    console.log('nextTick');
})

new Promise(function(resolve) {
    console.log('promise2');
    resolve();
}).then(function() {
    console.log('promiseThen2')
})

setTimeout(function() {
    console.log('setTimeout2');
},0)
複製程式碼

在這個例子中,主要分析在事件迴圈流程中各個任務佇列的變化情況,對於執行上下文棧的行為暫不做分析。任務佇列圖中左邊代表隊頭,右邊代表隊尾。

為了能夠實現該例子中有多個巨集任務佇列和多個微任務佇列的情況,我加入了 node 中的 setImmediate 和 process.nextTick ,node 中的事件迴圈機制與 JavaScript 類似,只是其實現機制有所不同,這裡我們不需要關心。加入 node 兩個屬性後,其優先順序如下

在巨集任務佇列中,各個佇列的優先順序為 setTimeout > setInterval > setImmediate > I/O 微任務佇列中,各個佇列的優先順序為 process.nextTick > Promise > Object.observe > MutationObserver

所以上述例子只能夠在 node 環境中執行,不能夠在瀏覽器中執行。那麼讓我們來一步步分析上述程式碼的執行過程。

一,執行 Javascript 程式碼,全域性上下文入棧,輸出 global ,此時遇到第一個 setTimeout 任務源,由於其執行延遲時間為 0,所以能夠立即接收到響應結果,將其指定的非同步任務放入巨集任務佇列中;

1

二,遇到第一個 Promise 任務源,此時會執行 Promise 第一個引數中的程式碼,即輸出 promise1,然後將其指定的非同步任務(then 中函式)放入微任務佇列中;

2

三,遇到 setImmediate 任務源,將其指定的非同步任務放入巨集任務佇列中;

3

四,遇到 nextTick 任務源,將其指定的非同步任務放入微任務佇列中;

4

五,遇到第二個 Promise 任務源,輸出 promise2,將其指定的非同步任務放入微任務佇列中;

5

六,遇到第二個 setTimeout 任務源,將其指定的非同步任務放入巨集任務佇列中;

6

七,JavaScript 整體程式碼執行完畢,開始清空微任務佇列,將微任務佇列中的所有任務佇列按優先順序、單個任務佇列的非同步任務按先進先出的方式入棧並執行。此時我們可以看到微任務佇列中存在 Promise 和 nextTick 佇列,nextTick 佇列優先順序比較高,取出 nextTick 非同步任務入棧執行,輸出 nextTick;

7

八,取出 Promise1 非同步任務入棧執行,輸出 promiseThen1;

8

九,取出 Promise2 非同步任務入棧執行,輸出 promiseThen2;

9

十,微任務佇列清空完畢,執行巨集任務佇列,將巨集任務佇列中優先順序最高的任務佇列中的非同步任務按先進先出的方式入棧並執行。此時我們可以看到巨集任務佇列中存在 setTimeout 和 setImmediate 佇列,setTimeout 佇列優先順序比較高,取出 setTimeout1 非同步任務入棧執行,輸出 setTimeout1,遇到 Promise 和 nextTick 任務源,輸出 setTimeout1_promise,將其指定的非同步任務放入微任務佇列中;

10

十一,取出 setTimeout2 非同步任務入棧執行,輸出 setTimeout2;

image.png

十二,至此一個微任務巨集任務事件迴圈完畢,開始下一輪迴圈。從微任務佇列中的 nextTick 佇列取出 setTimeout1_nextTick 非同步任務入棧執行,輸出 setTimeout1_nextTick;

12

十三,從微任務佇列中的 Promise 佇列取出 setTimeout1_promise 非同步任務入棧執行,輸出 setTimeout1_promiseThen;

13

十四,從巨集任務佇列中的 setImmediate 佇列取出 setImmediate 非同步任務入棧執行,輸出 setImmediate;

14

十五,全域性上下文出棧,程式碼執行完畢。最終輸出結果為

global
promise1
promise2
nextTick
promiseThen1
promiseThen2
setTimeout1
setTimeout1_promise
setTimeout2
setTimeout1_nextTick
setTimeout1_promiseThen
setImmediate
複製程式碼

覺得還不錯的小夥伴,可以關注一波公眾號哦。

JavaScript 事件迴圈機制

相關文章