最近在學習Vue
源碼,恰好學到虛擬DOM的異步更新,這裏就涉及到JavaScript
中的事件循環Event Loop
。以前對這個概念仍是比較模糊,大概知道是什麼,但一直沒有深刻學習。恰好藉此機會,回過頭來學習一下Event Loop
。javascript
事件循環Event Loop
,這是目前瀏覽器和NodeJS
處理JavaScript
代碼的一種機制,而這種機制存在的背後,就有由於JavaScript
是一門單線程的語言。html
單線程和多線程最簡單的區別就是:單線程同一個時間只能作一件事情,而多線程同一個時間能作多件事情。java
而JavaScript
之所謂設計爲單線程語言,主要是由於它做爲瀏覽器腳本語言,主要的用途就是與用戶互動,操做Dom
節點。web
而在這個情景設定下,假設JavaScript
同時有兩個進程,一個是操做A節點,一個是刪除A節點,這時候瀏覽器就不知道要以哪一個線程爲準了。面試
所以爲了不這類型的問題,JavaScript
從一開始就屬於單線程語言。json
在JavaScript
運行的時候,主線程會造成一個棧,這個棧主要是解釋器用來最終函數執行流的一種機制。一般這個棧被稱爲調用棧Call Stack
,或者執行棧(Execution Context Stack
)。api
調用棧,顧名思義是具備LIFO(後進先出,Last in First Out)的結構。調用棧內存放的是代碼執行期間的全部執行上下文。promise
如今用個小案例來演示一下調用棧。瀏覽器
function a() {
console.log('a');
}
function b() {
console.log('b');
}
function c() {
console.log('c');
a();
b();
}
c();
/** * 輸出結果:c a b */
複製代碼
執行這段代碼的時候,首先調用的是函數c()
。所以function c(){}
的執行上下文就會被放入調用棧中。性能優化
而後開始執行函數c
,執行的第一個語句是console.log('c')
。
所以解釋器也會將其放入調用棧中。
當console.log('c')
方法執行完後,控制檯打印了'c'
,調用棧就會將其移除。
接着就是執行a()
函數。
解釋器就將function a() {}
的執行上下文放入調用棧中。
緊接着就執行a()
中的語句——console.log('a')
。
當函數a
執行結束後,調用棧就將執行上下文移除。
而後接着執行c()
函數剩下的語句,也就是執行b()
函數,所以它的執行上下文就加入調用棧中。
緊接着就執行b()
中的語句——console.log('b')
。
b()
執行完後,調用棧就將其移出。
這時c()
也執行結束了,調用棧也將其移出棧。
這時候,咱們這段語句就執行結束了。
上面的案例簡單的介紹了關於JavaScript
單線程的執行方式。
但這其中會存在一些問題,就是若是當一個語句也須要執行很長時間的話,好比請求數據、定時器、讀取文件等等,後面的語句就得一直等着前面的語句執行結束後纔會開始執行。
顯而易見,這是不可取的。
所以,JavaScript
將全部執行任務分爲了同步任務和異步任務。
其實咱們每一個任務都是在作兩件事情,就是發起調用和獲得結果。
而同步任務和異步任務最主要的差異就是,同步任務發起調用後,很快就能夠獲得結果,而異步任務是沒法當即獲得結果,好比請求接口,每一個接口都會有必定的響應時間,根據網速、服務器等等因素決定,再好比定時器,它須要固定時間後纔會返回結果。
所以,對於同步任務和異步任務的執行機制也不一樣。
同步任務的執行,其實就是跟前面那個案例同樣,按照代碼順序和調用順序,支持進入調用棧中並執行,執行結束後就移除調用棧。
而異步任務的執行,首先它依舊會進入調用棧中,而後發起調用,而後解釋器會將其響應回調任務放入一個任務隊列,緊接着調用棧會將這個任務移除。當主線程清空後,即全部同步任務結束後,解釋器會讀取任務隊列,並依次將已完成的異步任務加入調用棧中並執行。
這裏有個重點,就是異步任務不是直接進入任務隊列的。
這裏舉一個簡單的例子。
console.log(1);
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(response => response.json())
.then(json => console.log(json))
console.log(2);
複製代碼
很顯然,fetch()
就是一個異步任務。
但執行到console.log(2)
以前,其實fetch()
已經被調用且發起請求了,可是還未響應數據。而響應數據和處理數據的函數then()
此時已經在任務隊列中,等候console.log(2)
執行結束後,因此同步任務清空後,再進入調用棧執行響應動做。
前面聊到同步任務和異步任務的時候,說起到了任務隊列。
在任務隊列中,其實還分爲宏任務隊列(Task Queue)和微任務隊列(Microtask Queue),對應的裏面存放的就是宏任務和微任務。
首先,宏任務和微任務都是異步任務。
而宏任務和微任務的區別,就是它們執行的順序,這也是爲何要區分宏任務和微任務。
在同步任務中,任務的執行都是按照代碼順序執行的,而異步任務的執行也是須要按順序的,隊列的屬性就是先進先出(FIFO,First in First Out),所以異步任務會按照進入隊列的順序依次執行。
但在一些場景下,若是隻按照進入隊列的順序依次執行的話,也會出問題。好比隊列先進入一個一小時的定時器,接着再進入一個請求接口函數,而若是根據進入隊列的順序執行的話,請求接口函數可能須要一個小時後纔會響應數據。
所以瀏覽器就會將異步任務分爲宏任務和微任務,而後按照事件循環的機制去執行,所以不一樣的任務會有不一樣的執行優先級,具體會在事件循環講到。
這裏還有一個知識點,就是關於任務入隊。
任務進入任務隊列,其實會利用到瀏覽器的其餘線程。雖說JavaScript
是單線程語言,可是瀏覽器不是單線程的。而不一樣的線程就會對不一樣的事件進行處理,當對應事件能夠執行的時候,對應線程就會將其放入任務隊列。
setInterval
、setTimeout
等待時間結束後,會把執行函數推入任務隊列中;click
、mouse
等UI交互事件發生後,將要執行的回調函數放入到事件隊列中。這個其實就能夠解釋了下列代碼爲何後面的定時器會比前面的定時器先執行。由於後者的定時器會先被推動宏任務隊列,而前者會以後到點了再被推入宏任務隊列。
setTimeout(() => {
console.log('a');
}, 10000);
setTimeout(() => {
console.log('b');
}, 100);
複製代碼
瀏覽器 | Node | |
---|---|---|
總體代碼(script) | ✅ | ✅ |
UI交互事件 | ✅ | ❌ |
I/O | ✅ | ✅ |
setTimeout | ✅ | ✅ |
setInterval | ✅ | ✅ |
setImmediate | ❌ | ✅ |
requestAnimationFrame | ✅ | ❌ |
瀏覽器 | Node | |
---|---|---|
process.nextTick | ❌ | ✅ |
MutationObserver | ✅ | ❌ |
Promise.then catch finally | ✅ | ✅ |
其實宏任務隊列和微任務隊列的執行,就是事件循環的一部分了,因此放在這裏一塊兒說。
事件循環的具體流程以下:
這裏有幾個重點:
script
放入宏任務隊列中,所以事件循環是從第一個宏任務開始的;接下來,經過一個常見的面試題例子來模擬一下事件循環。
console.log("a");
setTimeout(function () {
console.log("b");
}, 0);
new Promise((resolve) => {
console.log("c");
resolve();
})
.then(function () {
console.log("d");
})
.then(function () {
console.log("e");
});
console.log("f");
/** * 輸出結果:a c f d e b */
複製代碼
首先,當代碼執行的時候,總體代碼script
被推入宏任務隊列中,並開始執行該宏任務。
按照代碼順序,首先執行console.log("a")
。
該函數上下文被推入調用棧,執行完後,即移除調用棧。
接下來執行setTimeout()
,該函數上下文也進入調用棧中。
由於setTimeout
是一個宏任務,所以將其callback
函數推入宏任務隊列中,而後該函數就被移除調用棧,繼續往下執行。
緊接着是Promise
語句,先將其放入調用棧,而後接着往下執行。
執行console.log("c")
和resolve()
,這裏就很少說了。
接着來到new Promise().then()
方法,這是一個微任務,所以將其推入微任務隊列中。
這時new Promise
語句已經執行結束了,就被移除調用棧。
接着作執行console.log('f')
。
這時候,script
宏任務已經執行結束了,所以被推出宏任務隊列。
緊接着開始清空微任務隊列了。首先執行的是Promise then
,所以它被推入調用棧中。
而後開始執行其中的console.log("d")
。
執行結束後,檢測到後面還有一個then()
函數,所以將其推入微任務隊列中。
此時第一個then()
函數已經執行結束了,就會移除調用棧和微任務隊列。
此時微任務隊列還沒被清空,所以繼續執行下一個微任務。
執行過程跟前面差很少,就很少說了。
此時微任務隊列已經清空了,第一個事件循環已經結束了。
接下來執行下一個宏任務,即setTimeout callback
。
執行結束後,它也被移除宏任務隊列和調用棧。
這時候微任務隊列裏面沒有任務,所以第二個事件循環也結束了。
宏任務也被清空了,所以這段代碼已經執行結束了。
ECMAScript2017中添加了async functions
和await
。
async
關鍵字是將一個同步函數變成一個異步函數,並將返回值變爲promise
。
而await
能夠放在任何異步的、基於promise
的函數以前。在執行過程當中,它會暫停代碼在該行上,直到promise
完成,而後返回結果值。而在暫停的同時,其餘正在等待執行的代碼就有機會執行了。
下面經過一個例子來體驗一下。
async function async1() {
console.log("a");
const res = await async2();
console.log("b");
}
async function async2() {
console.log("c");
return 2;
}
console.log("d");
setTimeout(() => {
console.log("e");
}, 0);
async1().then(res => {
console.log("f")
})
new Promise((resolve) => {
console.log("g");
resolve();
}).then(() => {
console.log("h");
});
console.log("i");
/** * 輸出結果:d a c g i b h f e */
複製代碼
首先,開始執行前,將總體代碼script
放入宏任務隊列中,並開始執行。
第一個執行的是console.log("d")
。
緊接着是setTimeout
,將其回調放入宏任務中,而後繼續執行。
緊接着是調用async1()
函數,所以將其函數上下文放置到調用棧。
而後開始執行async1
中的console.log("a")
。
接下來就是await
關鍵字語句。
await
後面調用的是async2
函數,所以咱們將其放入調用棧。
而後開始執行async2
中的console.log("c")
,並return
一個值。
執行完成後,async2
就被移出調用棧。
這時候,await
會阻塞async2
的返回值,先跳出async1
進行往下執行。
須要注意的是,如今async1
中的res
變量,仍是undefined
,沒有賦值。
緊接着是執行new Promise
。
執行console.log("i")
。
這時,async1
外面的同步任務都執行完成了,所以就從新回到前面阻塞的位置,進行往下執行。
這時res
成功賦值了async2
的結果值,而後往下執行console.log("b")
。
這時候async1
纔算是執行結束,緊接着再將其調用的then()
函數放入微任務隊列中。
這時script
宏任務已經所有執行完了,開始準備清空微任務隊列了。
第一個被執行的微任務隊列是promise then
,也就是將執行其中的console.log("h")
語句。
執行完Promise then
微任務後,緊接着開始執行async1
的promise then
微任務。
這時候微任務隊列已經清空了,即開始執行下一個宏任務。
最後來說將事件循環中的頁面更新渲染,這也是Vue
中異步更新的邏輯所在。
每次當一次事件循環結束後,即一個宏任務執行完成後以及微任務隊列被清空後,瀏覽器就會進行一次頁面更新渲染。
一般咱們瀏覽器頁面刷新頻率是60fps,也就是意味着16.67ms要刷新一次,所以咱們也要儘可能保證一次事件循環控制在16.67ms以內,這也是咱們須要作代碼性能優化的一個緣由。
接下來仍是經過一個案例來看一下。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Event Loop</title>
</head>
<body>
<div id="demo"></div>
<script src="./src/render1.js"></script>
<script src="./src/render2.js"></script>
</body>
</html>
複製代碼
// render1
const demoEl = document.getElementById('demo');
console.log('a');
setTimeout(() => {
alert('渲染完成!')
console.log('b');
},0)
new Promise(resolve => {
console.log('c');
resolve()
}).then(() => {
console.log('d');
alert('開始渲染!')
})
console.log('e');
demoEl.innerText = 'Hello World!';
複製代碼
// render2
console.log('f');
demoEl.innerText = 'Hi World!';
alert('第二次渲染!');
複製代碼
根據HTML
的執行順序,第一個被執行的JavaScript
代碼是render1.js
,所以解釋器將其推入宏任務隊列,並開始執行。
第一個被執行的是console.log("a")
。
其次是setTimeout
,並將其回調加入宏任務隊列中。
緊接着執行new Promise
。
一樣,將其then()
推入微任務隊列中去。
緊接着執行console.log("e")
。
最後,修改DOM節點的文本內容,可是這時候頁面還不會更新渲染。
這時候script
宏任務也執行結束了。
緊接着,開始清空微任務隊列,執行Promise then
。
這時候,alert
一個通知,而這個語句結束後,則微任務隊列清空,表明第一個事件循環結束,即將要開始渲染頁面了。
當點擊關閉alert
後,事件循環結束,頁面也開始渲染。
渲染結束後,就開始執行下一個宏任務,即setTimeout callback
。
緊接着執行console.log("b")
。
這時候宏任務隊列已清空了,可是html
文件還沒執行結束,所以進入render2.js
繼續執行。
首先執行console.log('f')
。
緊接着,再次修改節點的文本信息,此時依舊不會更新頁面渲染。
接着執行alert
語句,當關閉alert
通知後,該宏任務結束,微任務隊列也爲空,所以該事件循環也結束了,這時候就開始第二次頁面更新。
但若是將全部JavaScript
代碼使用內嵌方式的話,瀏覽器會先把兩個script
丟到宏任務隊列中去,所以執行的順序也會不同,這裏就不一一推導了。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Event Loop</title>
</head>
<body>
<div id="demo"></div>
<script> const demoEl = document.getElementById('demo'); console.log('a'); setTimeout(() => { alert('渲染完成!') console.log('b'); },0) new Promise(resolve => { console.log('c'); resolve() }).then(() => { console.log('d'); alert('開始渲染!') }) console.log('e'); demoEl.innerText = 'Hello World!'; </script>
<script> console.log('f'); demoEl.innerText = 'Hi World!'; alert('第二次渲染!'); </script>
</body>
</html>
複製代碼
輸出:a c e d "開始渲染!" f "第二次渲染!" "渲染完成!" b
複製代碼