在理解JavaScript事件迴圈機制之前,先理解幾個概念:
程式
程式是是作業系統進行資源分配和排程的基本單位,是一個具有一定獨立功能的程式在一個資料集上的一次動態執行的過程,這裡我們可以比喻為一個工廠的車間
執行緒
是程式執行流的最小單元,一個程式可以有多個執行緒,這裡我們可以比喻為車間裡的工人,一個車間可以由多個工人協同完成一個任務
程式與執行緒的關係
可以用下面的圖來表示:
瀏覽器的多程式架構
瀏覽器的每一個 tab頁都是一個程式,有對應的記憶體佔用空間、CPU使用量以及程式ID。新開啟一個tab頁時,都會新建一個程式,所以就有一個tab頁對應一個程式的說法,但是這種說法又是錯誤的,因為瀏覽器有自己的優化機制,當我們開啟多個空白的 tab頁時,瀏覽器會將這多個空白頁的程式合併為一個,從而減少了程式的數量個數。
瀏覽器核心是多執行緒的
瀏覽器核心是多執行緒的,多個執行緒之前相互配合以保持同步,其中的執行緒包括:
- GUI渲染執行緒
- JavaScript引擎執行緒
- 事件觸發執行緒
- 定時器觸發執行緒
- http請求執行緒
GUI渲染執行緒:
- 主要負責頁面的渲染,解析html、css,構建DOM樹,完成頁面的佈局和繪製等。
- 當頁面進行重繪或者回流時,會執行該執行緒。
- 與JavaScript引擎執行緒互斥,當執行JavaScript引擎執行緒時,GUI渲染執行緒會被掛起。
JavaScript引擎執行緒:
- 主要負責解析並執行JavaScript指令碼程式。
- 與GUI渲染執行緒互斥。
事件觸發執行緒:
- 當一個事件被觸發時,該執行緒會把事件新增到待處理佇列的隊尾,等待JavaScript引擎執行。
- 定時器、http非同步請求、事件繫結,當這類的事件被觸發時,都會執行該執行緒。
定時器執行緒:
- 瀏覽器的定時器並不是由JavaScript引擎執行緒來計數的,如果JavaScript引擎處於阻塞情況下會影響定時器的準確性,所以需要定時器執行緒單獨執行。
- 當setTimeout或者setInterval的計時結束後,會由事件觸發執行緒將其回撥函式加入待處理佇列的隊尾,等待JavaScript引擎執行。
http請求執行緒:
- 當遇到http請求的時候,會由該執行緒處理,比如ajax。
- 如果http請求中加入了回撥函式,且回掉函式被觸發,則會由事件觸發執行緒將其回撥函式加入待處理佇列的隊尾,等待JavaScript引擎執行。
為什麼JavaScript是單執行緒
這是因為Javascript這門指令碼語言誕生的使命所致:JavaScript為處理頁面中使用者的互動,以及操作DOM樹、CSS樣式樹來給使用者呈現一份動態而豐富的互動體驗和伺服器邏輯的互動處理。如果JavaScript是多執行緒的方式來操作這些UI DOM,則可能出現UI操作的衝突;如果Javascript是多執行緒的話,在多執行緒的互動下,處於UI中的DOM節點就可能成為一個臨界資源,假設存在兩個執行緒同時操作一個DOM,一個負責修改一個負責刪除,那麼這個時候就需要瀏覽器來裁決如何生效哪個執行緒的執行結果。當然我們可以通過鎖來解決上面的問題。但為了避免因為引入了鎖而帶來更大的複雜性,Javascript在最初就選擇了單執行緒執行。
同步任務和非同步任務
- 同步任務
按程式碼順序執行,只有當上一行程式碼被執行完畢才會執行下一行程式碼。
- 非同步任務
比如定時器、http請求、事件處理等都是非同步任務,不會阻塞主執行緒程式碼的執行。
看下面程式碼的執行過程:
'use strict';
console.log(1);
setTimeout(function() {
console.log(2);
});
console.log(3);
複製程式碼
執行過程如下:
- 建立全域性上下文環境,壓入執行棧中,並初始化,進入預解析階段。
- 進入執行階段,按預解析後的程式碼順序執行。
- 當執行到 console.log(1) 的時候,判斷其為同步任務,壓入執行棧中,立即執行。
- console.log(1) 執行完畢,出棧,重新回到全域性上下文環境。
- 執行到 setTimeout 定時器的時候,判斷其為非同步任務,將其交給 定時器觸發執行緒。
- 執行到 console.log(3) 的時候,判斷其為同步任務,壓入執行棧中,立即執行。
- 當主執行緒中的同步任務執行完畢,會去待處理任務佇列中依次執行任務。
- 此時 setTimeout 定時器的回撥函式被 事件觸發執行緒 加入到任務佇列中。
- 執行 setTimeout 定時器的回撥函式,壓入執行棧中,立即執行。
- 執行到 console.log(2),判斷其為同步任務,壓入執行棧中,立即執行。
- console.log(2) 執行完畢出棧。
- setTimeout 定時器的回撥函式執行完畢出棧。
- 重新回到全域性上下文執行環境。
所以最終列印的結果是: 1 3 2
巨集任務和微任務
- 巨集任務(可以有多個任務佇列)
包括全域性任務(script)、定時器、http請求、事件處理、I/O、UI渲染
- 微任務(只有一個任務佇列)
包括 process.nextTick、Promise.then()、Object.observe(已廢棄,避免使用)、MutationObserver
在微任務中process.nextTick的優先順序高於Promise
事件迴圈機制(Event Loop)
- 從全域性任務(script) 開始,依次進入執行棧中,在主執行緒中執行,執行完畢出棧。
- 如果遇到非同步任務,則由對應的觸發執行緒來處理。
- 當非同步任務達到可執行的狀態,事件觸發執行緒會將其回撥函式依次加入任務佇列,等待主執行緒執行完畢時依次執行。
- 主執行緒執行完畢。
- 執行任務佇列中的任務,首先執行微任務佇列中的任務。
- 微任務佇列中的任務執行完畢之後,會執行排在最前面的巨集任務佇列。
- 如果在執行巨集任務的過程中發現微任務,會由事件觸發執行緒將微任務加入到微任務佇列,等待下一次的迴圈執行。
- 排在最前面的巨集任務佇列執行完畢後,出棧,進行下輪迴圈(從第5步開始)。
看下面的例子:
'use strict';
console.log(1);
setTimeout(function() {
console.log(2);
Promise.resolve().then(() => {
console.log(3);
});
});
new Promise(resolve => {
console.log(4);
resolve();
})
.then(() => {
console.log(5);
});
console.log(6);
複製程式碼
執行過程解析:
第一輪迴圈:
- 從全域性任務開始,首先遇到同步任務 console.log(1),壓棧並立即執行,執行完畢出棧。
- 遇到巨集任務 setTimeout,將其交給 定時器觸發執行緒,我們先記為 setTimeout1。
- Promise在例項化的時候為同步任務,所以立即執行同步任務 console.log(4),執行完畢出棧。
- 遇到微任務 Promise.then(),由 事件觸發執行緒 將其加入微任務佇列中,我們暫且記為 Promise.then1。
- 遇到同步任務 console.log(6),壓棧並立即執行,執行完畢出棧。
- 主執行緒執行完成,第一輪迴圈結束,輸出 1 4 6 。
此時的任務佇列如下:
第二輪迴圈:
- 讀取微任務佇列中的 Promise.then1 任務。
- 遇到同步任務 console.log(5),壓棧並立即執行,執行完畢出棧。
- 微任務佇列執行完畢後,讀取巨集任務佇列最靠前的任務,即 setTimeout1。
- 遇到同步任務 console.log(2),壓棧並立即執行,執行完畢出棧。
- 遇到微任務 Promise.then(),加入微任務佇列,暫且記為 Promise.then2。
- 巨集任務 Promise.then1 執行完畢出棧,第二輪迴圈結束,輸出 1 4 6 5 2 。
此時的任務佇列如下:
第三輪迴圈:
- 讀取微任務佇列中的 Promise.thn2 任務。
- 遇到同步任務 console.log(3),壓棧並立即執行,執行完畢出棧。
- 此時任務佇列為空,第三輪迴圈結束,執行完畢。
- 最終控制檯輸出 1 4 6 5 2 3 。
結語:
至此整個事件迴圈就結束了,最後在將JavaScript事件迴圈機制總結一遍:
- 首先執行全域性任務,同步任務會依次壓棧並立即執行,執行完畢出棧。
- 當遇到非同步任務,會由其他的執行緒進行處理,比如:setTimeout 會由定時器觸發執行緒處理,http請求會由http請求執行緒處理,微任務會由事件觸發執行緒加入到微任務佇列。
- 當非同步任務達到可執行狀態,會由事件觸發執行緒將其回掉函式加入任務佇列,等待主執行緒任務執行完畢之後,依次讀取執行。
- 當主執行緒上的同步任務全部執行完畢之後,會去讀取任務佇列中的任務。
- 任務佇列中,首先讀取微任務佇列中的任務。
- 微任務佇列中的任務執行完畢之後,會讀取巨集任務佇列中最靠前的任務。
- 讀取巨集任務的過程中,如果遇到微任務,則加入到微任務佇列中。
- 當該巨集任務執行完畢之後,迴圈結束,進行下一輪迴圈。
整個流程圖如下: