JavaScript事件循環及異步原理筆記

前言

先提出一個問題JavaScript 既然是單線程,那爲何瀏覽器或 Node.js 能夠執行異步操做呢?javascript

下面簡短解釋一下:html

一、JavaScript 是單線程的,只有一個主線程;java

二、函數內的代碼是從上到下依次執行,遇到被調用的函數先進入被調用的函數執行,待完成後繼續執行;(這個機制主要是經過函數調用棧實現的)web

三、遇到異步事件,JavaScript 的宿主環境會另開一個線程,主線程繼續執行,待結果返回後,執行回調函數。segmentfault

上述的宿主環境,則是指瀏覽器或 Node.js 環境,在瀏覽器中通常會提供額外的線程,而在 Node.js 中,則是藉助 libuv 來實現不一樣操做系統上的多線程。而且在宿主環境中,這個異步線程又分爲 微任務宏任務瀏覽器

以上內容不明白不要緊,接着往下看。網絡

JavaScript 單線程歷史

咱們知道,JavaScript 剛出來的時候是做爲瀏覽器內的一種腳本語法,負責操做 DOM,與用戶進行互動等,若是是多線程的話,執行順序沒法預知,並且操做以哪一個線程爲準也是個難題。因此爲了不這種局面,JavaScript 便採用單線程設計,這已經成了這門語言的核心特徵,未來也不會改變。數據結構

在 HTML5 時代,瀏覽器爲了充分發揮 CPU 性能優點,容許 JavaScript 建立多個線程,可是即便能額外建立線程,這些子線程仍然是受到主線程控制,並且不得操做 DOM,相似於開闢一個線程來運算複雜性任務,運算好了通知主線程運算完畢,結果給你,這相似異步的處理方式,但並無改變 JavaScript 單線程的本質。多線程

函數調用棧

JavaScript只有一個主線程,因此也只有一個函數調用棧,學過數據結構的同窗應該都知道,棧是一種後進先出(LIFO)的數據結構。併發

在JavaScript中,每當開始執行一個函數時,就會建立一個函數的執行上下文,咱們能夠籠統的將JavaScript中的執行上下文分爲全局上下文函數執行上下文。能夠經過例子理解,以下代碼:

function a(){
    var hello = "Hello";
    var world = "world";
    function b(){
        console.log(hello);
    }
    function c(){
        console.log(world);
    }
    b();
    c();
}

a();
複製代碼

函數的出入棧順序以下圖:

image.png

若是對函數調用棧還不是很瞭解,請參考個人另一篇文章:讀《JavaScript核心技術解密》筆記

從函數調用棧的執行特色中能夠知道,棧內後一個函數必須在前一個函數執行完成以後才能夠開始執行,若是某一個函數任務須要很長時間才能完成的話,例如網絡請求,I/O操做等,後面的函數任務就會一直在等待,那麼整個系統的效率就會特別低。因而你們意識到,這些耗時久的任務徹底能夠先掛起,等主線程上的其餘任務執行完以後,再回頭將這些掛起的任務繼續執行,因此有了任務隊列的概念。

任務隊列

咱們能夠簡單的理解爲一個函數就是一個任務,基本上能夠將任務分爲同步任務異步任務

同步任務就是指在主線程上排隊執行的任務,只有當前一個任務完成以後後一個纔會執行;異步任務則是不進入主線程,而是進入任務對列的任務,只有隊列任務通知了主線程說某個異步任務能夠執行了,該任務纔會進入主線程執行。

因此,咱們思考得知,當執行過程碰到setTimeout等異步操做時,會將其交給瀏覽器或 Node.js 的其餘線程進行處理,當達到setTimeout指定延遲執行的時間後,纔會將回調函數放入任務隊列中。

咱們能夠看一個例子:

function fun() {
    function callback() {
        console.log('執行回調');
    }
    setTimeout(callback, 2000);
    console.log('準備');
}
fun();
複製代碼

調用棧-異步模塊-任務隊列模型中,上述代碼的執行過程以下:

第一步,fun()函數入棧(咱們省略了該代碼全局執行上下文入棧步驟)

第二步,由於fun()函數內執行了setTimeout(),因此setTimeout()入棧,如圖:

第三步,因爲setTimeout()是異步操做,不屬於JavaScript主線程模塊內容,因此setTimeout()進入異步執行模塊執行計時,如圖

第四步,fun()函數內的console.log('準備')函數進入函數調用棧並執行,因此控制檯輸出準備

第五步,因爲fun()函數內部沒有其餘須要繼續執行的函數,因此fun()出棧,隨後全局上下文也沒有須要執行的代碼,因此全局上下文也出棧,如圖:

第六步,假如此時恰好setTimeout()的兩秒計時結束,那麼異步模塊就會將setTimeout()的回調函數放到任務隊列裏面,由於此時函數調用棧已經空閒,因此任務隊列依次將任務函數入棧,如圖:

第七步,進入callback()回調中,將console.log('執行回調')入棧執行,因此在控制檯輸出執行回調,如圖:

第八步,callback()再出棧,整段代碼執行結束。

上面所說的步數並非說必定是有8步,目的是讓你們有個順序瞭解接下來每一步會進行什麼內容,理解JavaScript的函數調用執行,異步模塊和任務隊列之間的關係是最重要的。

那麼,這段代碼總體的過程就是如圖所示,經過這種創建底層模型的方式能夠加深你們的理解。趁熱打鐵,請閱讀以下代碼,想想在「調用棧-異步模塊-任務隊列」模型中,是怎麼樣的一個流程:

setTimeout(() => {
    console.log('1');
}, 32);
setTimeout(() => {
    console.log('2');
}, 30);
function fun() {
    console.log('3');
}
for (var i = 0; i < 100000000; i++) {
    i === 99999999 && console.log('4');
}
fun();
複製代碼

代碼最終輸出的內容順序是4 3 2 1,請思考執行過程。

注意一點,就是兩個setTimeout()都會進入異步模塊,這裏主要進入了異步模塊,這兩個函數實際上是同時執行的,延遲30ms的先完成,先進入隊列(先進先出),延遲32ms的後完成後進入隊列,因此最後的順序是... 2 1,即2在1前面。

上述講到異步模塊,在瀏覽器中,例如 Chrome 瀏覽器,由 webcore 模塊擔任開啓其餘線程角色,其提供了DOM Bindingnetworktimer子模塊,這些均可以理解爲異步模塊,分別對應DOM處理、Ajax、時間處理函數等API。

而在Node.js中,前言裏也說到了,是經過libuv來實如今不一樣操做系統上統一的線程調度API。

宏任務與微任務

前言裏說到任務由宏任務微任務構成,也被稱爲taskjob,咱們看一張網上的事件循環圖:

其中,Task Queue是指宏任務,Microtask Queue則是微任務。

宏任務大概包括主線程代碼setTimeoutsetIntervalsetImmediate(僅Node.js)requestAnimationFrame(僅瀏覽器)I/OUI Rendering

微任務大概包括Promise.then/catch/finallyprocess.nextTick(僅Node.js)MutationObserver(僅瀏覽器)Object.observe(已廢棄)

事件循環中,當主線程的全部任務(函數)執行結束以後,而後順序執行微任務隊列中的全部微任務,當全部的微任務執行完成後,再執行宏任務隊列中的下一個宏任務,當這個宏任務執行完畢,再看微任務隊列是否存在微任務,若是存在,則順序執行全部微任務,一直循環直至全部的任務執行完畢。

注意,瀏覽器在每一次宏任務結束的時候都會進行一次渲染

任務隊列的事件循環能夠用下圖表示:

分析一段代碼:

<script> setTimeout(() => { 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); </script>
<script> console.log(6) new Promise((resolve) => { resolve() }).then(() => { console.log(7); }); </script>
複製代碼

代碼中輸出順序爲:1 2 3 5 6 7 4;簡單分析下:

開始,程序往下走,遇到setTimeout,是異步任務,放到異步模塊執行,執行結束的回調進入宏任務隊列先暫存着,如圖:

繼續往下走,碰到Promise對象。因爲是new操做,其構造函數是一個匿名函數,因此會當即執行Promise構造函數的實參函數任務,因此console.log(1)被執行,控制檯輸出1,接着進入循環,直到執行resolve(),執行完該函數以後,會附帶調用then方法,由於then屬於異步方法,因此then內部的回調console.log(5)被送入微任務隊列,接着執行console.log(2),控制檯輸出2,此時狀態如圖:

程序往下走,緊接着執行console.log(3),因此控制檯輸出3。到如今控制檯輸出順序爲1 2 3。

到這裏,第一段腳本里已經結束了,因此此時在這段<script>腳本中函數調用棧已空,按照以前的事件循環邏輯,微任務隊列裏的任務會依次放到函數調用棧裏面執行,因此接下來控制檯就輸出5,如圖:

當微任務隊列中的全部任務執行完畢(這裏只有一個微任務),函數調用棧爲空會先看程序是否能夠繼續,因爲下一個<script>腳本存在,因此事件循環被打斷,繼續下一個腳本內容,因此先執行console.log(6),控制檯輸出6,此時已輸出順序爲1 2 3 5 6,如圖:

接下來,又將碰到一個Promise,Promise內構造函數的回調參數函數會當即執行,內部執行到resolve()則會調用其then(),因爲then()是異步方法,因此進入異步執行模塊執行以後將console.log(7)放入微任務隊列,如圖:

因爲在這個<script>腳本里沒有其他代碼,因此接下來執行全部的微任務,則繼續執行console.log(7),隨後根據事件循環原理執行下一個宏任務console.log(4),到此全部的代碼執行完畢,因此最終的順序是1 2 3 5 6 7 4

能夠嘗試分析下下面這個題:

setImmediate(() => {
    console.log(1);
},0);
setTimeout(() => {
    console.log(2);
},0);
new Promise((resolve) => {
    console.log(3);
    resolve();
    console.log(4);
}).then(() => {
    console.log(5);
});
console.log(6);
process.nextTick(()=> {
    console.log(7);
});
console.log(8);
複製代碼

剩下的疑問

一、異步執行模塊內到底是怎麼執行的呢? 筆者我的以爲裏面的執行是每個異步函數都分配一個線程去執行,能夠說是將異步函數跟主線程併發執行的,當異步函數執行結束以後,再將異步裏面的回調任務根據宏任務與微任務的劃分劃入不一樣的任務隊列,等待事件循環。

二、若是總體script屬於宏任務,那麼主線程的函數調用棧算不算入宏任務裏面?若是算入,那以下代碼是否順序應該是1 2 3 5 6 7 8 4?結果確定不是,正確順序是1 2 3 5 6 8 7 4;因此筆者以爲在微任務console.log(5)執行結束,即第一次微任務隊列被清空,函數調用棧會先判斷程序是否還有script代碼能夠加載,若能夠則截斷本次事件循環,再次進入順序執行狀態,這樣彷佛說的通一些。

<script> setTimeout(() => { 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); </script>
<script> console.log(6) new Promise((resolve) => { resolve() }).then(() => { console.log(7); }); console.log(8); </script>
複製代碼

參考文章:

梁音.JavaScript 事件循環及異步原理(徹底指北); 代碼題目取自該文章,其文章後面最後還有一個進階題,有興趣夥伴能夠研究下。

segmentfault-js中宏任務與微任務

博客園-js 宏任務和微任務

相關文章
相關標籤/搜索