JS專題之事件迴圈

南波發表於2018-11-24

準備知識

1. 程式(process)

程式是系統資源分配一個獨立單位,一個程式至少有一個程式。比方說:一個工廠代表一個 CPU, 一個車間就是一個程式,任一時刻,只能有一個程式在執行,其他程式處於非執行狀態。

2. 執行緒(Thread)

執行緒是CPU排程和分派的基本單位,一個執行緒只能屬於一個程式,一個程式可以有多個執行緒且至少有一個。比方說一個車間的工人,可以有多個工人一起工作。

生活中常常能看到,某某電腦 CPU 的 4 核 4 執行緒,其意思是指,這款 CPU 同一時間最多隻能執行 4 個執行緒,所以有些執行緒會處於工作狀態,有的執行緒會處於中斷,堵塞,睡眠狀態。

經常看到有很多工同時在進行,一邊工作,一邊聽歌,還一邊下載電影。那是因為這些執行緒在以閃電般的速度不斷的切換主要的幾個執行緒,所以,人的體驗上感覺是很多很多工在同時進行。

3. 棧(stack)

棧是一種資料結構,具有後進先出的特點,最開始進入棧結構的資料反而最後才能出來。

JS專題之事件迴圈

4. 佇列(queue)

佇列也是一種資料結構,資料只能從一邊進,一邊出,先進去的自然就先出來。

JS專題之事件迴圈

5. 同步和非同步(sync async)

同步和非同步關注的訊息通訊機制,同步在函式呼叫時,如果呼叫者沒有拿到響應結果,程式會繼續等待,知道拿到結果為止。而非同步會執行其後的程式碼,等到有響應結果後,才處理響應。

6. 阻塞和非阻塞(blocking & non-blocking)

阻塞和非阻塞關注的是程式等待呼叫結果時的狀態,阻塞的意思是,在呼叫結果返回響應前,執行緒會被掛起佔用,程式無法繼續往下走,而非阻塞的執行緒則不會掛起,後面的程式碼能夠繼續往下執行。

比方說:我去超市買包薯片,老闆告訴我貨架上沒貨了,馬上去庫房拿,這過程中,老闆要我站著等他,直到他拿到貨出來給我。這個過程就是阻塞。

如果老闆告訴我,可以先回去,他一會去庫房拿,拿到了之後打電話給我。這個過程,就是非阻塞的,我不用等待,還可以幹其他的事情。

7. 執行棧(execution stack)

js 程式碼在執行程式碼時,JS 會給呼叫程式碼生成一個執行上下文物件,並將其壓入執行上下文棧,首先進入棧底的是全域性上下文,然後是函式的執行上下文(Execution Context),函式執行完之後,函式上下文從棧中彈出,直到退出瀏覽器,全域性上下文才從棧底彈出。

用程式碼舉個例子:

var globalName = "window";

var foo1 = function() {
    console.log("foo1");
}

var foo2 = function() {
    console.log("foo2");
    foo1();
}

foo2();
複製程式碼

JS專題之事件迴圈

上面的圖片大致能夠描述執行上下文棧的實現邏輯,有關執行上下文的知識,大家可以翻看我之前的文章 - 《JavaScript 之執行上下文》

二、為什麼 JS 是單執行緒模型?

JavaScript 的一個非常有趣的特性是事件迴圈模型,與許多其他語言不同,它永不阻塞。 處理 I/O 通常通過事件和回撥來執行 -- MDN

瀏覽器主要任務是給使用者是視覺和互動上的體驗,如果頁面使用過程中,偶爾出現阻塞、掛起、無響應的體驗一定是非常糟糕的。同時,如果採用多執行緒同步的模型,那麼如何保證同一時間修改了 DOM, 到底是哪個執行緒先生效呢。

瀏覽器執行環境的核心思想在於任務排程方式的特別:

哪個任務的優先順序高,先來就先執行,直到執行完了才執行下一個,並且同一時刻只能執行一個程式碼片段,即所謂的單執行緒模型。

比方說,銀行的櫃檯只開啟了一個櫃檯,每個人想要辦理業務,就得先拿號排隊,叫到了你的號碼,你才能上去辦理業務。不能多個人同時在一個櫃檯辦理業務,不然就很容易出差錯。

三、事件迴圈

事件迴圈是 JS 處理各種事件的核心,由於多個執行緒同時操作 DOM, 造成不可控的問題,所以 JS 採用了單執行緒模型。另外,由於所有的事件同步執行,執行完一個才能執行下一個,會造成頁面渲染的堵塞。JS 中存在非同步事件,使用者可以在點選頁面的時候,請求網路響應的同事,還可以進行其他的點選操作,保證了頁面不會因為網路請求,多種 IO 介面響應慢造成程式碼執行的堵塞和掛起。

事件迴圈的順序是:

  1. 進入 script 標籤,建立全域性上下文
  2. 執行全域性上下文中的函式,將其壓入執行呼叫棧
  3. 某個函式執行完後,函式彈出執行棧,清空函式上下文中的變數物件和記憶體空間,判斷是否需要更新渲染,如果需要則更新渲染。
  4. 如果遇到非同步事件,也會壓入執行呼叫棧,但瀏覽器識別到它是非同步事件後,會將其彈出執行棧,然後將非同步事件的回撥函式放入事件佇列中。
  5. 執行直到函式呼叫棧清空只剩全域性執行上下文,這時,JS 會檢查事件佇列中是否有事件,如果有,則將事件佇列中的一個事件出隊,然後壓入執行棧中執行。
  6. 當執行棧又清空只剩全域性執行上下文時,又會重複第 5 步。這就是 JS 的事件迴圈。
  7. 當使用者關閉瀏覽器,全域性執行上下文彈出執行棧,清空相應上下文中的變數物件和記憶體空間。

JS專題之事件迴圈

接下來我們用程式碼來解釋:

console.log("script start!");

function foo1() {
    console.log("foo1");
}

foo1();

setTimeout(function () {
    console.log("setTimeout!");
}, 1000);

function foo2() {
    console.log("foo2");
}

foo2();

console.log("script end!");

列印:
// script start!
// foo1
// foo2
// script end!

// setTimeout!
複製程式碼

那我們嘗試把 setTimeout 的延遲時間改為 0,想要立即執行,看會不會立即執行:

console.log("script start!");

function foo1() {
    console.log("foo1");
}

foo1();

setTimeout(function () {
    console.log("setTimeout!");
}, 0);

function foo2() {
    console.log("foo2");
}

foo2();

console.log("script end!");

列印:
// script start!
// foo1
// foo2
// script end!
// setTimeout!
複製程式碼

可以看出 setTimeout 屬於非同步事件,總是會在主執行緒的任務執行完後才開始執行。

順便說一下事件迴圈幾個原則:

  1. 一次只處理一個任務
  2. 一個任務從開始到完成,不會被其他任務所中斷

這兩個原則保證了瀏覽器任務單元的完整性,事件呼叫的有序性。

四、巨集任務和微任務

事件迴圈的實現本來應該由一個用於巨集任務的佇列和一個用於微任務的佇列進行完成,這使得事件迴圈要根據任務型別來進行優先處理。

巨集任務:
巨集任務包括:

  1. 建立文件物件、解析 HTML、執行主執行緒程式碼(script)
  2. 執行各種事件:頁面載入、輸入、點選
  3. setTimout,setInterval 非同步事件

巨集任務代表一個個離散、獨立的工作單元,執行完任務後,瀏覽器可以進行其他的任務排程,如更新渲染或執行垃圾回收。巨集任務需要多次事件迴圈才能執行完。

微任務:
微任務包括:

  1. Promise 回撥函式
  2. new MutaionObserver()

微任務是更小的任務,微任務需要儘可能地、通過非同步方式執行,微任務更新瀏覽器的狀態,但必須在瀏覽器執行其他任務之前執行。微任務使得我們避免不必要的 UI 重繪。微任務在一次事件迴圈中必須全部執行完。

巨集任務和微任務的執行優先順序原則是:

完成一個巨集任務後,執行餘下的微任務

同一次事件迴圈中,巨集任務永遠在微任務之前執行。

JS專題之事件迴圈

ok,知道了優先順序原則後,我們來看一段程式碼:

console.log(1);

setTimeout(function() {
    console.log(2);
    new Promise(resolve => {
        console.log(3);
        resolve(4);
        console.log(5);
    }).then(data => {
        console.log(data);
    });
}, 0);

new Promise(resolve => {
    console.log(6);
    resolve(7);
    console.log(8);
}).then(data => {
    console.log(data);
});

setTimeout(function() {
    console.log(9);
}, 0);

console.log(10);

output:  
第一次迴圈:
// 1
// 6
// 8
// 10
// 7

第二次迴圈:
// 2
// 3
// 5
// 4


第三次迴圈
// 9
複製程式碼

我們一起來分析以上程式碼:

  1. 進入第一次事件迴圈,script 這個巨集任務,輸出 1
  2. 第一個 setTimeout 函式本身是函式呼叫,屬於任務源,setTimeout 的回撥函式,即第一個引數,才是被分發的任務,任務被加入巨集任務佇列,第二次迴圈時呼叫。
  3. Promise 屬於微任務,但是 Promise 初始化中程式碼會立即進行。所以會立即輸出 6 和 8;
  4. Promise 初始化後的回撥放入微任務佇列
  5. 第二個 setTimeout 也屬於巨集任務源,回撥函式的任務放入巨集任務佇列,第三次事件迴圈時呼叫
  6. 繼續呼叫棧,輸出 10, 沒毛病
  7. 第一次事件迴圈的巨集任務執行完畢,執行餘下的所有微任務,所以輸出 7,
  8. 第二次事件迴圈,發現有巨集任務,即第一個 setTimeout 的回撥,輸出 2,呼叫 Promise 構建函式的呼叫棧,直接執行,所以輸出3 和 5
  9. 第一個 setTimeout 的 promise 回撥放入微任務佇列。
  10. 第二次事件迴圈的巨集任務呼叫執行完,執行剛才前一步 Promise 建立的微任務,輸出 4,第二次迴圈執行完畢。
  11. 進入第 3 次事件迴圈,只有一個巨集任務,即第二個 SetTimeout,所以輸出 9;

JS專題之事件迴圈

關於事件迴圈巨集任務和微任務的執行過程:

  1. 首先兩個型別的任務都是逐個執行
  2. 微任務會前下一個渲染或垃圾回收前全部執行完
  3. 一次事件迴圈中先只執行一個巨集任務,在下一次事件迴圈前執行完所有的微任務,包括新建立的微任務。

五、web worker

儘管 HTML5 新標準加入了 web worker 的多執行緒技術,但是 web worker 只能用於計算,並且 JS 的多執行緒 worker 無法操作 DOM, 不然就無法控制頁面是在被誰操作的了。

主執行緒傳給子執行緒的資料是通過拷貝複製,同樣子執行緒傳給主執行緒的資料也是通過拷貝複製,而不是共享同一個記憶體空間。

以上說明,JS 不存線上程同步,所以還是可以把 JS 看做單執行緒模型,把 web worker 當做 JS 的一種回撥機制。

總結

事件迴圈是 JS 和 Nodejs 事件呼叫機制的核心,保證了頁面可以有序無阻塞的進行。

事件迴圈的主要邏輯是先執行呼叫棧,直到清空呼叫棧只剩下全域性上下文。

然後 JS 檢查巨集任務佇列,如果有任務則取出一個進行呼叫,進行頁面渲染和垃圾回收。

同時將所有的微任務源派發的任務加入微任務事件佇列,最後執行餘下的所有微任務。微任務執行後完,進行頁面渲染和垃圾回收後進行下一輪事件迴圈。

歡迎關注我的個人公眾號“謝南波”,專注分享原創文章。

JS專題之事件迴圈

掘金專欄 JavaScript 系列文章

  1. JavaScript之變數及作用域
  2. JavaScript之宣告提升
  3. JavaScript之執行上下文
  4. JavaScript之變數物件
  5. JavaScript之原型與原型鏈
  6. JavaScript之作用域鏈
  7. JavaScript之閉包
  8. JavaScript之this
  9. JavaScript之arguments
  10. JavaScript之按值傳遞
  11. JavaScript之例題中徹底理解this
  12. JavaScript專題之模擬實現call和apply
  13. JavaScript專題之模擬實現bind
  14. JavaScript專題之模擬實現new
  15. JS專題之事件模型

相關文章