JavaScript、瀏覽器、事件之間的關係
JavaScript程式採用了非同步事件驅動程式設計(Event-driven programming)模型,維基百科對它的解釋是:
事件驅動程式設計(英語:Event-driven programming)是一種電腦程式設計模型。這種模型的程式執行流程是由使用者的動作(如滑鼠的按鍵,鍵盤的按鍵動作)或者是由其他程式的訊息來決定的。相對於批處理程式設計(batch programming)而言,程式執行的流程是由程式設計師來決定。批量的程式設計在初級程式設計教學課程上是一種方式。然而,事件驅動程式設計這種設計模型是在互動程式(Interactive program)的情況下孕育而生的
簡而言之,在web前端程式設計裡面JavaScript通過瀏覽器提供的事件模型API和使用者互動,接受使用者的輸入。
事件驅動程式模型基本的實現原理基本上都是使用 事件迴圈(Event Loop)。
而JS的執行環境主要有兩個:瀏覽器、Node。
在兩個環境下的Event Loop實現是不一樣的,在瀏覽器中基於 規範 來實現,不同瀏覽器可能有小小區別。在Node中基於 libuv 這個庫來實現
JS是單執行緒執行的,而基於事件迴圈模型,形成了基本沒有阻塞(除了alert或同步XHR等操作)的狀態。
瀏覽器中的事件迴圈 event loop
先看HTML標準的一系列解釋:
為了協調事件(event),使用者互動(user interaction),指令碼(script),渲染(rendering),網路(networking)等,使用者代理(user agent)必須使用事件迴圈(event loops)。 有兩類事件迴圈:一種針對瀏覽上下文(browsing context),還有一種針對worker(web worker)。
為了更好地理解Event Loop,請看下圖(轉引自Philip Roberts的演講《Help, I'm stuck in an event-loop》)
上圖中,主執行緒執行的時候,產生堆疊,棧中的程式碼呼叫各種外部API,非同步操作執行完成後,就在訊息佇列中排隊。只要棧中的程式碼執行完畢,主執行緒就會去讀取“任務佇列”,依次執行那些事件所對應的回撥函式。
詳細的步驟如下:
- 所有同步任務都在主執行緒上執行,形成一個執行棧
- 主執行緒之外,還存在一個“訊息佇列”。只要非同步操作執行完成,就到訊息佇列中排隊
- 一旦執行棧中的所有同步任務執行完畢,系統就會依次讀取訊息佇列的非同步任務,於是被讀取的非同步任務結束等待狀態,進入執行棧,開始執行
- 主執行緒不斷重複上面的的第三步
下面看一個有意思的例子,猜一下它的執行結果:
setTimeout(
function(){
console.log('1')
},0);
new Promise(
function(resolve){
console.log('2');
resolve()
}).then(
function(){
console.log('3');
});
console.log('4');
複製程式碼
列印結果:
2
4
3
1
複製程式碼
這是為什麼?是不是跟上面說的相違背了?其實這裡面就有了兩個概念巨集任務(task/macrotask),微任務(microtask),下面我們來詳細介紹一下這兩個東東。
Macrotask 與 Microtask
根據 規範,每個執行緒都有一個事件迴圈(Event Loop),在瀏覽器中除了主要的頁面執行執行緒 外,Web worker是在一個新的執行緒中執行的,所以可以將其獨立看待。
每個事件迴圈有至少一個任務佇列(Task Queue,也可以稱作Macrotask巨集任務),各個任務佇列中放置著不同來源(或者不同分類)的任務,可以讓瀏覽器根據自己的實現來進行優先順序排序
以及一個微任務佇列(Microtask Queue),主要用於處理一些狀態的改變,UI渲染工作之前的一些必要操作(可以防止多次無意義的UI渲染)
主執行緒的程式碼執行時,會將執行程式置入執行棧(Stack)中,執行完畢後出棧,另外有個堆空間(Heap),主要用於儲存物件及一些非結構化的資料。
常見的macrotask有:
run <script>(同步的程式碼執行)
setTimeout
setInterval
setImmediate (Node環境中)
requestAnimationFrame
I/O
UI rendering
複製程式碼
常見的microtask有:
process.nextTick (Node環境中)
Promise callback
Object.observe (基本上已經廢棄)
MutationObserver
複製程式碼
事件迴圈執行順序
1. event loop 執行步驟:
1、執行巨集任務(先進先出),一次迴圈只執行一個巨集任務)
2、執行棧 —— 同步方法順序執行,非同步方法交給非同步處理模組
3、執行棧為空時取出微任務執行(先進先出),直到微任務佇列為空
4、更新UI渲染。完成一輪迴圈,反覆執行1-4。(不一定每次迴圈都會渲染)
複製程式碼
2.update the rendering 渲染更新:
在一輪event loop中多次修改同一dom,只有最後一次會進行繪製。
渲染更新(Update the rendering)會在event loop中的tasks和microtasks完成後進行,但並不是每輪event loop都會更新渲染,瀏覽器有自己的機制來確定是否要更新渲染。如果在一幀(16.7ms)裡多次修改了dom,瀏覽器可能只會渲染繪製一次。
如果希望在每輪event loop都即時呈現變動,可以使用requestAnimationFrame.
複製程式碼
那麼我們回到上面的那個例子就不難解釋了:
==注意==: Promise 自身的程式碼是同步執行的,只有 .then後的回撥函式才是微任務。
主執行緒的執行過程:
- 從巨集任務佇列(task)中取出 script,將所有同步程式碼推入執行棧中執行,遇到非同步程式碼交給非同步處理模組,非同步處理模組處理完成後將任務按規則推入事件佇列,巨集任務推巨集任務佇列(先進先出),微任務推微任務佇列(先進先出)。所以輸出 2 和 4。
- 執行完 script 中的同步程式碼,再將微任務佇列中最老的任務推入執行棧執行,直到清空微任務佇列。所以輸出 3。
- 瀏覽器更新渲染,再去巨集任務佇列中取出最老的任務推入執行棧中執行,迴圈以上步驟。所以輸出 1。
在Node中的實現
在Node環境中,macrotask部分主要多了setImmediate,microtask部分主要多了process.nextTick,而這個nextTick是獨立出來自成佇列的,優先順序高於其他microtask
不過事件迴圈的的實現就不太一樣了,可以參考 Node事件文件 libuv事件文件
Node中的事件迴圈有6個階段
- timers:執行setTimeout() 和 setInterval()中到期的callback
- I/O callbacks:上一輪迴圈中有少數的I/Ocallback會被延遲到這一輪的這一階段執行
- idle, prepare:僅內部使用
- poll:最為重要的階段,執行I/Ocallback,在適當的條件下會阻塞在這個階段
- check:執行setImmediate的callback
- close callbacks:執行close事件的callback,例如socket.on("close",func)
每一輪事件迴圈都會經過六個階段,在每個階段後,都會執行microtask
比較特殊的是在poll階段,執行程式同步執行poll佇列裡的回撥,直到佇列為空或執行的回撥達到系統上限
接下來再檢查有無預設的setImmediate,如果有就轉入check階段,沒有就先查詢最近的timer的距離,以其作為poll階段的阻塞時間,如果timer佇列是空的,它就一直阻塞下去
而nextTick並不在這些階段中執行,它在每個階段之後都會執行。
一個簡單的例子:
setTimeout(() => console.log(1));
setImmediate(() => console.log(2));
process.nextTick(() => console.log(3));
Promise.resolve().then(() => console.log(4));
console.log(5);
複製程式碼
根據以上知識,應該很快就能知道輸出結果是 5 3 4 1 2
修改一下:
process.nextTick(() => console.log(1));
Promise.resolve().then(() => console.log(2));
process.nextTick(() => console.log(3));
Promise.resolve().then(() => {
process.nextTick(() => console.log(0));
console.log(4);
});
複製程式碼
輸出為 1 3 2 4 0,因為nextTick佇列優先順序高於同一輪事件迴圈中其他microtask佇列
再次修改:
process.nextTick(() => console.log(1));
console.log(0);
setTimeout(()=> {
console.log('timer1');
Promise.resolve().then(() => {
console.log('promise1');
});
}, 0);
process.nextTick(() => console.log(2));
setTimeout(()=> {
console.log('timer2');
process.nextTick(() => console.log(3));
Promise.resolve().then(() => {
console.log('promise2');
});
}, 0);
複製程式碼
輸出結果為:
0
1
2
timer1
timer2
3
promise1
promise2
複製程式碼
與在瀏覽器中不同,這裡promise1並不是在timer1之後輸出,因為在setTimeout執行的時候是出於timer階段,會先一併處理timer回撥.
善用事件迴圈
知道JS的事件迴圈是怎麼樣的了,就需要知道怎麼才能把它用好:
-
在microtask中不要放置複雜的處理程式,防止阻塞UI的渲染
-
可以使用process.nextTick處理一些比較緊急的事情
-
可以在setTimeout回撥中處理上輪事件迴圈中UI渲染的結果
-
注意不要濫用setInterval和setTimeout,它們並不是可以保證能夠按時處理的,setInterval甚至還會出現丟幀的情況,可考慮使用 requestAnimationFrame
-
一些可能會影響到UI的非同步操作,可放在promise回撥中處理,防止多一輪事件迴圈導致重複執行UI的渲染
-
在Node中使用immediate來可能會得到更多的保證
如有錯誤歡迎指正,相互進步。
參考連結:
JavaScript 執行機制詳解:再談Event Loop