天天看點

JavaScript 事件循環及異步原理(完全指北)

引言

最近面試被問到,JS 既然是單線程的,為什麼可以執行異步操作?

當時腦子蒙了,思維一直被困在 單線程 這個問題上,一直在思考單線程為什麼可以額外運作任務,其實在我很早以前寫的部落格裡面有寫相關的内容,隻不過時間太長給忘了,是以要經常溫習啊:(淺談 Generator 和 Promise 的原理及實作)

JS 是單線程的,隻有一個主線程

函數内的代碼從上到下順序執行,遇到被調用的函數先進入被調用函數執行,待完成後繼續執行

遇到異步事件,浏覽器另開一個線程,主線程繼續執行,待結果傳回後,執行回調函數

其實 JS 這個語言是運作在宿主環境中,比如 浏覽器環境,nodeJs環境

在浏覽器中,浏覽器負責提供這個額外的線程

在 Node 中,Node.js 借助 libuv 來作為抽象封裝層, 進而屏蔽不同作業系統的差異,Node可以借助libuv來實作多線程。

而這個異步線程又分為 微任務 和 宏任務,本篇文章就來探究一下 JS 的異步原理以及其事件循環機制

為什麼 JavaScript 是單線程的

JavaScript 語言的一大特點就是單線程,也就是說,同一個時間隻能做一件事。這樣設計的方案主要源于其語言特性,因為 JavaScript 是浏覽器腳本語言,它可以操縱 DOM ,可以渲染動畫,可以與使用者進行互動,如果是多線程的話,執行順序無法預知,而且操作以哪個線程為準也是個難題。

是以,為了避免複雜性,從一誕生,JavaScript就是單線程,這已經成了這門語言的核心特征,将來也不會改變。

在 HTML5 時代,浏覽器為了充分發揮 CPU 性能優勢,允許 JavaScript 建立多個線程,但是即使能額外建立線程,這些子線程仍然是受到主線程控制,而且不得操作 DOM,類似于開辟一個線程來運算複雜性任務,運算好了通知主線程運算完畢,結果給你,這類似于異步的處理方式,是以本質上并沒有改變 JavaScript 單線程的本質。

函數調用棧與任務隊列

函數調用棧

JavaScript 隻有一個主線程和一個調用棧(call stack),那什麼是調用棧呢?

這類似于一個乒乓球桶,第一個放進去的乒乓球會最後一個拿出來。

舉個栗子:

function a() {

console.log("I'm a!");

};

function b() {

a();

console.log("I'm b!");

b();

執行過程如下所示:

第一步,執行這個檔案,此檔案會被壓入調用棧(例如此檔案名為 main.js)

call stack

main.js

第二步,遇到 b() 文法,調用 b() 方法,此時調用棧會壓入此方法進行調用:

b()

第三步:調用 b() 函數時,内部調用的 a() ,此時 a() 将壓入調用棧:

a()

第四步:a() 調用完畢輸出 I'm a!,調用棧将 a() 彈出,就變成如下:

第五步:b()調用完畢輸出I'm b!,調用棧将 b() 彈出,變成如下:

第六步:main.js 這個檔案執行完畢,調用棧将 b() 彈出,變成一個空棧,等待下一個任務執行:

這就是一個簡單的調用棧,在調用棧中,前一個函數在執行的時候,下面的函數全部需要等待前一個任務執行完畢,才能執行。

但是,有很多任務需要很長時間才能完成,如果一直都在等待的話,調用棧的效率極其低下,這時,JavaScript 語言設計者意識到,這些任務主線程根本不需要等待,隻要将這些任務挂起,先運算後面的任務,等到執行完畢了,再回頭将此任務進行下去,于是就有了 任務隊列 的概念。

任務隊列

所有任務可以分成兩種,一種是 同步任務(synchronous),另一種是 異步任務(asynchronous) 。

同步任務指的是,在主線程上排隊執行的任務,隻有前一個任務執行完畢,才能執行後一個任務。

異步任務指的是,不進入主線程、而進入"任務隊列"(task queue)的任務,隻有 "任務隊列"通知主線程,某個異步任務可以執行了,該任務才會進入主線程執行。

是以,當在執行過程中遇到一些類似于 setTimeout 等異步操作的時候,會交給浏覽器的其他子產品進行處理,當到達 setTimeout 指定的延時執行的時間之後,回調函數會放入到任務隊列之中。

當然,一般不同的異步任務的回調函數會放入不同的任務隊列之中。等到調用棧中所有任務執行完畢之後,接着去執行任務隊列之中的回調函數。

用一張圖來表示就是:

上圖中,調用棧先進行順序調用,一旦發現異步操作的時候就會交給浏覽器核心的其他子產品進行處理,對于 Chrome 浏覽器來說,這個子產品就是 webcore 子產品,上面提到的異步API,webcore 分别提供了 DOM Binding 、 network、timer 子產品進行處理。等到這些子產品處理完這些操作的時候将回調函數放入任務隊列中,之後等棧中的任務執行完之後再去執行任務隊列之中的回調函數。

我們先來看一個有意思的現象,我運作一段代碼,大家覺得輸出的順序是什麼:

setTimeout(() => {

console.log('setTimeout')           

}, 22)

for (let i = 0; i++ < 2;) {

i === 1 && console.log('1')           

}

setTimeout(() => {

console.log('set2')           

}, 20)

for (let i = 0; i++ < 100000000;) {

i === 99999999 && console.log('2')           

沒錯!結果很量子化:

那麼這實際上是一個什麼過程呢?那我就拿上面的一個過程解析一下:

首先,檔案入棧

開始執行檔案,讀取到第一行代碼,當遇到 setTimeout 的時候,執行引擎将其添加到棧中。(由于字型太細我調粗了一點。。。)

調用棧發現 setTimeout 是 Webapis中的 API,是以将其交給浏覽器的 timer 子產品進行處理,同時處理下一個任務。

第二個 setTimeout 入棧

同上所示,異步請求被放入 異步API 進行處理,同時進行下一個入棧操作:

在進行異步的同時,app.js 檔案調用完畢,彈出調用棧,異步執行完畢後,會将回調函數放入任務隊列:

任務隊列通知調用棧,我這邊有任務還沒有執行,調用棧則會執行任務隊列裡的任務:

上面的流程解釋了浏覽器遇到 setTimeout 之後究竟如何執行的,其實總結下來就是以下幾點:

調用棧順序調用任務

當調用棧發現異步任務時,将異步任務交給其他子產品處理,自己繼續進行下面的調用

異步執行完畢,異步子產品将任務推入任務隊列,并通知調用棧

調用棧在執行完目前任務後,将執行任務隊列裡的任務

調用棧執行完任務隊列裡的任務之後,繼續執行其他任務

這一整個流程就叫做 事件循環(Event Loop)。

那麼,了解了這麼多,小夥伴們能從事件循環上面來解析下面代碼的輸出嗎?

for (var i = 0; i < 10; i++) {

setTimeout(() => {
  console.log(i)
}, 1000)           

console.log(i)

解析:

首先由于 var 的變量提升,i 在全局作用域都有效

再次,代碼遇到 setTimeout 之後,将該函數交給其他子產品處理,自己繼續執行 console.log(i) ,由于變量提升,i 已經循環10次,此時 i 的值為 10 ,即,輸出 10

之後,異步子產品處理好函數之後,将回調推入任務隊列,并通知調用棧

1秒之後,調用棧順序執行回調函數,由于此時 i 已經變成 10 ,即輸出10次 10

用下圖示意:

現在小夥伴們是否已經恍然大悟,從底層了解了為什麼這個代碼會輸出這個内容吧:

那麼問題又來了,我們看下面的代碼:

console.log(4)           

}, 0);

new Promise((resolve) =>{

console.log(1);
for (var i = 0; i < 10000000; i++) {
  i === 9999999 && resolve();
}
console.log(2);           

}).then(() => {

console.log(5);           

});

console.log(3);

大家覺得這個輸出是多少呢?

有小夥伴就開始分析了,promise 也是異步,先執行裡面函數的内容,輸出 1 和 2,然後執行下面的函數,輸出 3 ,但 Promise 裡面需要循環999萬次,setTimeout 卻是0毫秒執行,setTimeout 應該立即推入執行棧, Promise 後推入執行棧,結果應該是下圖:

實際上答案是 1,2,3,5,4 噢,這是為什麼呢?這就涉及到任務隊列的内部,宏任務和微任務。

宏任務和微任務

什麼是宏任務和微任務

任務隊列又分為 macro-task(宏任務) 與 micro-task(微任務) ,在最新标準中,它們被分别稱為 task 與 jobs 。

macro-task(宏任務)大概包括:script(整體代碼), setTimeout, setInterval, setImmediate(NodeJs), I/O, UI rendering。

micro-task(微任務)大概包括: process.nextTick(NodeJs), Promise, Object.observe(已廢棄), MutationObserver(html5新特性)

來自不同任務源的任務會進入到不同的任務隊列。其中 setTimeout 與 setInterval 是同源的。

事實上,事件循環決定了代碼的執行順序,從全局上下文進入函數調用棧開始,直到調用棧清空,然後執行所有的micro-task(微任務),當所有的micro-task(微任務)執行完畢之後,再執行macro-task(宏任務),其中一個macro-task(宏任務)的任務隊列執行完畢(例如setTimeout 隊列),再次執行所有的micro-task(微任務),一直循環直至執行完畢。

解析

現在我就開始解析上面的代碼。

第一步,整體代碼 script 入棧,并執行 setTimeout 後,執行 Promise:

第二步,執行時遇到 Promise 執行個體,Promise 構造函數中的第一個參數,是在new的時候執行,是以不會進入任何其他的隊列,而是直接在目前任務直接執行了,而後續的.then則會被分發到micro-task的Promise隊列中去。

第三步,調用棧繼續執行宏任務 app.js,輸出3并彈出調用棧,app.js 執行完畢彈出調用棧:

第四步,這時,macro-task(宏任務)中的 script 隊列執行完畢,事件循環開始執行所有的 micro-task(微任務):

第五步,調用棧發現所有的 micro-task(微任務) 都已經執行完畢,又跑去macro-task(宏任務)調用 setTimeout 隊列:

第六步,macro-task(宏任務) setTimeout 隊列執行完畢,調用棧又跑去微任務進行查找是否有未執行的微任務,發現沒有就跑去宏任務執行下一個隊列,發現宏任務也沒有隊列執行,此次調用結束,輸出内容1,2,3,5,4。

那麼上面這個例子的輸出結果就顯而易見。大家可以自行嘗試體會。

總結

不同的任務會放進不同的任務隊列之中。

先執行macro-task,等到函數調用棧清空之後再執行所有在隊列之中的micro-task。

等到所有micro-task執行完之後再從macro-task中的一個任務隊列開始執行,就這樣一直循環。

宏任務和微任務的隊列執行順序排列如下:

macro-task(宏任務):script(整體代碼), setTimeout, setInterval, setImmediate(NodeJs), I/O, UI rendering。

micro-task(微任務): process.nextTick(NodeJs), Promise, Object.observe(已廢棄), MutationObserver(html5新特性)

進階舉例

那麼,我再來一些有意思一點的代碼:

console.log(4)           

new Promise((resolve) => {

console.log(1);
for (var i = 0; i < 10000000; i++) {
  i === 9999999 && resolve();
}
console.log(2);           
console.log(5);           
resolve()           
console.log(7);           

這一段代碼輸出的順序是什麼呢?

其實,看明白上面流程的同學應該知道整個流程,為了防止一些同學不明白,我再簡單分析一下:

首先,script1 進入任務隊列(為了友善起見,我把兩塊script 命名為script1,script2):

第二步,script1 進行調用并彈出調用棧:

第三步,script1執行完畢,調用棧清空後,直接調取所有微任務:

第四步,所有微任務執行完畢之後,調用棧會繼續調用宏任務隊列:

第五步,執行 script2,并彈出:

第六步,調用棧開始執行微任務:

第七步,調用棧調用完所有微任務,又跑去執行宏任務:

至此,所有任務執行完畢,輸出 1,2,3,5,6,7,4

了解了上面的内容,我覺得再複雜一點異步調用關系你也能搞定:

setImmediate(() => {

console.log(1);           

},0);

console.log(2);           

new Promise((resolve) => {

console.log(3);
resolve();
console.log(4);           
console.log(5);           

console.log(6);

process.nextTick(()=> {

console.log(7);           

console.log(8);

//輸出結果是3 4 6 8 7 5 2 1

終極測試

console.log('to1');
process.nextTick(() => {
    console.log('to1_nT');
})
new Promise((resolve) => {
    console.log('to1_p');
    setTimeout(() => {
      console.log('to1_p_to')
    })
    resolve();
}).then(() => {
    console.log('to1_then')
})           

})

console.log('imm1');
process.nextTick(() => {
    console.log('imm1_nT');
})
new Promise((resolve) => {
    console.log('imm1_p');
    resolve();
}).then(() => {
    console.log('imm1_then')
})           

process.nextTick(() => {

console.log('nT1');           
console.log('p1');
resolve();           
console.log('then1')           
console.log('to2');
process.nextTick(() => {
    console.log('to2_nT');
})
new Promise((resolve) => {
    console.log('to2_p');
    resolve();
}).then(() => {
    console.log('to2_then')
})           
console.log('nT2');           
console.log('p2');
resolve();           
console.log('then2')           
console.log('imm2');
process.nextTick(() => {
    console.log('imm2_nT');
})
new Promise((resolve) => {
    console.log('imm2_p');
    resolve();
}).then(() => {
    console.log('imm2_then')
})           

// 輸出結果是:?

大家可以在評論裡留言結果喲~

繼續閱讀