作一些動圖,學習一下EventLoop

最近在學習Vue源碼,恰好學到虛擬DOM的異步更新,這裏就涉及到JavaScript中的事件循環Event Loop。以前對這個概念仍是比較模糊,大概知道是什麼,但一直沒有深刻學習。恰好藉此機會,回過頭來學習一下Event Loopjavascript

JavaScript是單線程的語言

事件循環Event Loop,這是目前瀏覽器和NodeJS處理JavaScript代碼的一種機制,而這種機制存在的背後,就有由於JavaScript是一門單線程的語言。html

單線程和多線程最簡單的區別就是:單線程同一個時間只能作一件事情,而多線程同一個時間能作多件事情。java

JavaScript之所謂設計爲單線程語言,主要是由於它做爲瀏覽器腳本語言,主要的用途就是與用戶互動,操做Dom節點。web

而在這個情景設定下,假設JavaScript同時有兩個進程,一個是操做A節點,一個是刪除A節點,這時候瀏覽器就不知道要以哪一個線程爲準了。面試

所以爲了不這類型的問題,JavaScript從一開始就屬於單線程語言。json

調用棧 Call Stack

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(){}的執行上下文就會被放入調用棧中。性能優化

call_stack_1.gif

而後開始執行函數c,執行的第一個語句是console.log('c')

所以解釋器也會將其放入調用棧中。

call_stack_2.gif

console.log('c')方法執行完後,控制檯打印了'c',調用棧就會將其移除。

call_stack_3.gif

接着就是執行a()函數。

解釋器就將function a() {}的執行上下文放入調用棧中。

call_stack_4.gif

緊接着就執行a()中的語句——console.log('a')

call_stack_5.gif

當函數a執行結束後,調用棧就將執行上下文移除。

而後接着執行c()函數剩下的語句,也就是執行b()函數,所以它的執行上下文就加入調用棧中。

call_stack_6.gif

緊接着就執行b()中的語句——console.log('b')

call_stack_7.gif

b()執行完後,調用棧就將其移出。

這時c()也執行結束了,調用棧也將其移出棧。

call_stack_8.gif

這時候,咱們這段語句就執行結束了。

任務隊列

上面的案例簡單的介紹了關於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)執行結束後,因此同步任務清空後,再進入調用棧執行響應動做。

async.png

宏任務和微任務

前面聊到同步任務和異步任務的時候,說起到了任務隊列

在任務隊列中,其實還分爲宏任務隊列(Task Queue)微任務隊列(Microtask Queue),對應的裏面存放的就是宏任務微任務

首先,宏任務和微任務都是異步任務。

而宏任務和微任務的區別,就是它們執行的順序,這也是爲何要區分宏任務和微任務。

在同步任務中,任務的執行都是按照代碼順序執行的,而異步任務的執行也是須要按順序的,隊列的屬性就是先進先出(FIFO,First in First Out),所以異步任務會按照進入隊列的順序依次執行。

但在一些場景下,若是隻按照進入隊列的順序依次執行的話,也會出問題。好比隊列先進入一個一小時的定時器,接着再進入一個請求接口函數,而若是根據進入隊列的順序執行的話,請求接口函數可能須要一個小時後纔會響應數據。

所以瀏覽器就會將異步任務分爲宏任務和微任務,而後按照事件循環的機制去執行,所以不一樣的任務會有不一樣的執行優先級,具體會在事件循環講到。

任務入隊

這裏還有一個知識點,就是關於任務入隊。

任務進入任務隊列,其實會利用到瀏覽器的其餘線程。雖說JavaScript是單線程語言,可是瀏覽器不是單線程的。而不一樣的線程就會對不一樣的事件進行處理,當對應事件能夠執行的時候,對應線程就會將其放入任務隊列。

  • js引擎線程:用於解釋執行js代碼、用戶輸入、網絡請求等;
  • GUI渲染線程:繪製用戶界面,與JS主線程互斥(由於js能夠操做DOM,進而會影響到GUI的渲染結果);
  • http異步網絡請求線程:處理用戶的get、post等請求,等返回結果後將回調函數推入到任務隊列;
  • 定時觸發器線程setIntervalsetTimeout等待時間結束後,會把執行函數推入任務隊列中;
  • 瀏覽器事件處理線程:將clickmouse等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

事件循環 Event Loop

其實宏任務隊列和微任務隊列的執行,就是事件循環的一部分了,因此放在這裏一塊兒說。

事件循環的具體流程以下:

  1. 從宏任務隊列中,按照入隊順序,找到第一個執行的宏任務,放入調用棧,開始執行;
  2. 執行完該宏任務下全部同步任務後,即調用棧清空後,該宏任務被推出宏任務隊列,而後微任務隊列開始按照入隊順序,依次執行其中的微任務,直至微任務隊列清空爲止
  3. 當微任務隊列清空後,一個事件循環結束;
  4. 接着從宏任務隊列中,找到下一個執行的宏任務,開始第二個事件循環,直至宏任務隊列清空爲止。

這裏有幾個重點:

  • 當咱們第一次執行的時候,解釋器會將總體代碼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被推入宏任務隊列中,並開始執行該宏任務。

task_queque_1.gif

按照代碼順序,首先執行console.log("a")

該函數上下文被推入調用棧,執行完後,即移除調用棧。

task_queque_2.gif

接下來執行setTimeout(),該函數上下文也進入調用棧中。

task_queque_3.gif

由於setTimeout是一個宏任務,所以將其callback函數推入宏任務隊列中,而後該函數就被移除調用棧,繼續往下執行。

task_queque_4.gif

緊接着是Promise語句,先將其放入調用棧,而後接着往下執行。

task_queque_5.gif

執行console.log("c")resolve(),這裏就很少說了。

task_queque_6.gif

接着來到new Promise().then()方法,這是一個微任務,所以將其推入微任務隊列中。

task_queque_7.gif

這時new Promise語句已經執行結束了,就被移除調用棧。

接着作執行console.log('f')

task_queque_8.gif

這時候,script宏任務已經執行結束了,所以被推出宏任務隊列。

緊接着開始清空微任務隊列了。首先執行的是Promise then,所以它被推入調用棧中。

task_queque_9.gif

而後開始執行其中的console.log("d")

task_queque_10.gif

執行結束後,檢測到後面還有一個then()函數,所以將其推入微任務隊列中。

此時第一個then()函數已經執行結束了,就會移除調用棧和微任務隊列。

task_queque_11.gif

此時微任務隊列還沒被清空,所以繼續執行下一個微任務。

執行過程跟前面差很少,就很少說了。

task_queque_12.gif

此時微任務隊列已經清空了,第一個事件循環已經結束了。

接下來執行下一個宏任務,即setTimeout callback

task_queque_13.gif

執行結束後,它也被移除宏任務隊列和調用棧。

這時候微任務隊列裏面沒有任務,所以第二個事件循環也結束了。

宏任務也被清空了,所以這段代碼已經執行結束了。

task_queque_14.gif

await

ECMAScript2017中添加了async functionsawait

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")

async_await_1.gif

緊接着是setTimeout,將其回調放入宏任務中,而後繼續執行。

async_await_2.gif

緊接着是調用async1()函數,所以將其函數上下文放置到調用棧。

async_await_3.gif

而後開始執行async1中的console.log("a")

async_await_4.gif

接下來就是await關鍵字語句。

await後面調用的是async2函數,所以咱們將其放入調用棧。

async_await_5.gif

而後開始執行async2中的console.log("c"),並return一個值。

執行完成後,async2就被移出調用棧。

async_await_6.gif

這時候,await會阻塞async2的返回值,先跳出async1進行往下執行。

須要注意的是,如今async1中的res變量,仍是undefined,沒有賦值。

async_await_7.gif

緊接着是執行new Promise

async_await_8.gif

執行console.log("i")

async_await_9.gif

這時,async1外面的同步任務都執行完成了,所以就從新回到前面阻塞的位置,進行往下執行。

async_await_10.gif

這時res成功賦值了async2的結果值,而後往下執行console.log("b")

async_await_11.gif

這時候async1纔算是執行結束,緊接着再將其調用的then()函數放入微任務隊列中。

async_await_12.gif

這時script宏任務已經所有執行完了,開始準備清空微任務隊列了。

第一個被執行的微任務隊列是promise then,也就是將執行其中的console.log("h")語句。

async_await_13.gif

執行完Promise then微任務後,緊接着開始執行async1promise then微任務。

async_await_14.gif

這時候微任務隊列已經清空了,即開始執行下一個宏任務。

async_await_15.gif

頁面渲染

最後來說將事件循環中的頁面更新渲染,這也是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,所以解釋器將其推入宏任務隊列,並開始執行。

render_1.gif

第一個被執行的是console.log("a")

render_2.gif

其次是setTimeout,並將其回調加入宏任務隊列中。

render_3.gif

緊接着執行new Promise

render_4.gif

一樣,將其then()推入微任務隊列中去。

render_5.gif

緊接着執行console.log("e")

render_6.gif

最後,修改DOM節點的文本內容,可是這時候頁面還不會更新渲染。

這時候script宏任務也執行結束了。

render_7.gif

緊接着,開始清空微任務隊列,執行Promise then

render_8.gif

這時候,alert一個通知,而這個語句結束後,則微任務隊列清空,表明第一個事件循環結束,即將要開始渲染頁面了。

render_9.gif

當點擊關閉alert後,事件循環結束,頁面也開始渲染。

render_10.gif

渲染結束後,就開始執行下一個宏任務,即setTimeout callback

render_11.gif

緊接着執行console.log("b")

render_12.gif

這時候宏任務隊列已清空了,可是html文件還沒執行結束,所以進入render2.js繼續執行。

render_13.gif

首先執行console.log('f')

render_14.gif

緊接着,再次修改節點的文本信息,此時依舊不會更新頁面渲染。

接着執行alert語句,當關閉alert通知後,該宏任務結束,微任務隊列也爲空,所以該事件循環也結束了,這時候就開始第二次頁面更新。

render_15.gif

但若是將全部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
複製代碼
相關文章
相關標籤/搜索