JavaScript 事件循環及異步原理(徹底指北)



引言

最近面試被問到,JS 既然是單線程的,爲何能夠執行異步操做? 當時腦子蒙了,思惟一直被困在 單線程 這個問題上,一直在思考單線程爲何能夠額外運行任務,其實在我很早之前寫的博客裏面有寫相關的內容,只不過期間太長給忘了,因此要常常溫習啊:(淺談 Generator 和 Promise 的原理及實現)javascript

  1. JS 是單線程的,只有一個主線程
  2. 函數內的代碼從上到下順序執行,遇到被調用的函數先進入被調用函數執行,待完成後繼續執行
  3. 遇到異步事件,瀏覽器另開一個線程,主線程繼續執行,待結果返回後,執行回調函數

其實 JS 這個語言是運行在宿主環境中,好比 瀏覽器環境nodeJs環境html

  • 在瀏覽器中,瀏覽器負責提供這個額外的線程
  • Node 中,Node.js 藉助 libuv 來做爲抽象封裝層, 從而屏蔽不一樣操做系統的差別,Node能夠藉助libuv來實現多線程。

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

爲何 JavaScript 是單線程的

JavaScript 語言的一大特色就是單線程,也就是說,同一個時間只能作一件事。這樣設計的方案主要源於其語言特性,由於 JavaScript 是瀏覽器腳本語言,它能夠操縱 DOM ,能夠渲染動畫,能夠與用戶進行互動,若是是多線程的話,執行順序沒法預知,並且操做以哪一個線程爲準也是個難題。java

因此,爲了不復雜性,從一誕生,JavaScript就是單線程,這已經成了這門語言的核心特徵,未來也不會改變。node

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

函數調用棧與任務隊列

函數調用棧

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

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

舉個栗子:面試

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

function b() {  
  a();
  console.log("I'm b!");
};

b();
複製代碼

執行過程以下所示:ajax

  • 第一步,執行這個文件,此文件會被壓入調用棧(例如此文件名爲 main.js

    call stack

    main.js
  • 第二步,遇到 b() 語法,調用 b() 方法,此時調用棧會壓入此方法進行調用:

    call stack

    b()
    main.js
  • 第三步:調用 b() 函數時,內部調用的 a() ,此時 a() 將壓入調用棧:

    call stack

    a()
    b()
    main.js
  • 第四步:a() 調用完畢輸出 I'm a!,調用棧將 a() 彈出,就變成以下:

    call stack

    b()
    main.js
  • 第五步:b()調用完畢輸出I'm b!,調用棧將 b() 彈出,變成以下:

    call stack

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

    call stack

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

可是,有不少任務須要很長時間才能完成,若是一直都在等待的話,調用棧的效率極其低下,這時,JavaScript 語言設計者意識到,這些任務主線程根本不須要等待,只要將這些任務掛起,先運算後面的任務,等到執行完畢了,再回頭將此任務進行下去,因而就有了 任務隊列 的概念。

任務隊列

全部任務能夠分紅兩種,一種是 同步任務(synchronous),另外一種是 異步任務(asynchronous)

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

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

因此,當在執行過程當中遇到一些相似於 setTimeout 等異步操做的時候,會交給瀏覽器的其餘模塊進行處理,當到達 setTimeout 指定的延時執行的時間以後,回調函數會放入到任務隊列之中。

固然,通常不一樣的異步任務的回調函數會放入不一樣的任務隊列之中。等到調用棧中全部任務執行完畢以後,接着去執行任務隊列之中的回調函數。

用一張圖來表示就是:

image-20181011232324823

上圖中,調用棧先進行順序調用,一旦發現異步操做的時候就會交給瀏覽器內核的其餘模塊進行處理,對於 Chrome 瀏覽器來講,這個模塊就是 webcore 模塊,上面提到的異步API,webcore 分別提供了 DOM Bindingnetworktimer 模塊進行處理。等到這些模塊處理完這些操做的時候將回調函數放入任務隊列中,以後等棧中的任務執行完以後再去執行任務隊列之中的回調函數。

咱們先來看一個有意思的現象,我運行一段代碼,你們以爲輸出的順序是什麼:

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') } 複製代碼

沒錯!結果很量子化:

QQ20181012-101019-HD

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

  • 首先,文件入棧

    image-20181012102607896

  • 開始執行文件,讀取到第一行代碼,當遇到 setTimeout 的時候,執行引擎將其添加到棧中。(因爲字體太細我調粗了一點。。。)

    image-20181012103026018

  • 調用棧發現 setTimeoutWebapis中的 API,所以將其交給瀏覽器的 timer 模塊進行處理,同時處理下一個任務。

image-20181012134531903

  • 第二個 setTimeout 入棧

    image-20181012134755318

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

    image-20181012135048171

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

    image-20181012140221038

  • 任務隊列通知調用棧,我這邊有任務尚未執行,調用棧則會執行任務隊列裏的任務:

    image-20181012140632679

    image-20181012140723756

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

  1. 調用棧順序調用任務
  2. 當調用棧發現異步任務時,將異步任務交給其餘模塊處理,本身繼續進行下面的調用
  3. 異步執行完畢,異步模塊將任務推入任務隊列,並通知調用棧
  4. 調用棧在執行完當前任務後,將執行任務隊列裏的任務
  5. 調用棧執行完任務隊列裏的任務以後,繼續執行其餘任務

這一整個流程就叫作 事件循環(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

用下圖示意:

image-20181012152514598

如今小夥伴們是否已經恍然大悟,從底層瞭解了爲何這個代碼會輸出這個內容吧:

image-20181012152640173

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

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);
複製代碼

你們以爲這個輸出是多少呢?

有小夥伴就開始分析了,promise 也是異步,先執行裏面函數的內容,輸出 12,而後執行下面的函數,輸出 3 ,但 Promise 裏面須要循環999萬次,setTimeout 倒是0毫秒執行,setTimeout 應該當即推入執行棧, Promise 後推入執行棧,結果應該是下圖:

image-20181012161137385

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

宏任務和微任務

什麼是宏任務和微任務

任務隊列又分爲 macro-task(宏任務)micro-task(微任務) ,在最新標準中,它們被分別稱爲 taskjobs

  • macro-task(宏任務)大概包括:script(總體代碼), setTimeout, setInterval, setImmediate(NodeJs), I/O, UI rendering
  • micro-task(微任務)大概包括: process.nextTick(NodeJs), Promise, Object.observe(已廢棄), MutationObserver(html5新特性)
  • 來自不一樣任務源的任務會進入到不一樣的任務隊列。其中 setTimeoutsetInterval 是同源的。

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

解析

如今我就開始解析上面的代碼。

  • 第一步,總體代碼 script 入棧,並執行 setTimeout 後,執行 Promise

    image-20181013144141327

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

    image-20181013144638756

    image-20181013144902587

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

    image-20181013145222565

    image-20181013145713234

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

    image-20181013150040555

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

    image-20181013150354612

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

那麼上面這個例子的輸出結果就顯而易見。你們能夠自行嘗試體會。

總結

  1. 不一樣的任務會放進不一樣的任務隊列之中。
  2. 先執行macro-task,等到函數調用棧清空以後再執行全部在隊列之中的micro-task
  3. 等到全部micro-task執行完以後再從macro-task中的一個任務隊列開始執行,就這樣一直循環。
  4. 宏任務和微任務的隊列執行順序排列以下:
  5. macro-task(宏任務)script(總體代碼), setTimeout, setInterval, setImmediate(NodeJs), I/O, UI rendering
  6. micro-task(微任務): process.nextTick(NodeJs), Promise, Object.observe(已廢棄), MutationObserver(html5新特性)

進階舉例

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

<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>
複製代碼

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

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

  • 首先,script1 進入任務隊列(爲了方便起見,我把兩塊script 命名爲script1script2):

    image-20181013152218883

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

    image-20181013152506563

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

    image-20181013152844991

  • 第四步,全部微任務執行完畢以後,調用棧會繼續調用宏任務隊列:

    image-20181013153031374

  • 第五步,執行 script2,並彈出:

    image-20181013153426912

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

    image-20181013153503105

  • 第七步,調用棧調用完全部微任務,又跑去執行宏任務:

    image-20181013153654938

至此,全部任務執行完畢,輸出 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);
//輸出結果是3 4 6 8 7 5 2 1
複製代碼

image-20181013163225154

終極測試

setTimeout(() => {
    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')
    })
})

setImmediate(() => {
    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');
})
new Promise((resolve) => {
    console.log('p1');
    resolve();
}).then(() => {
    console.log('then1')
})

setTimeout(() => {
    console.log('to2');
    process.nextTick(() => {
        console.log('to2_nT');
    })
    new Promise((resolve) => {
        console.log('to2_p');
        resolve();
    }).then(() => {
        console.log('to2_then')
    })
})

process.nextTick(() => {
    console.log('nT2');
})

new Promise((resolve) => {
    console.log('p2');
    resolve();
}).then(() => {
    console.log('then2')
})

setImmediate(() => {
    console.log('imm2');
    process.nextTick(() => {
        console.log('imm2_nT');
    })
    new Promise((resolve) => {
        console.log('imm2_p');
        resolve();
    }).then(() => {
        console.log('imm2_then')
    })
})
// 輸出結果是:?
複製代碼

你們能夠在評論裏留言結果喲~


  • 2018年10月15日更新

P.S. 有同窗問我 ajax 是屬於哪一種任務,我發現我忘了寫了,ajax 屬於 宏任務 ,具體排序爲

script > DOM(onclick,onscroll...) > ajax > setTimeout > setInterval > setImmediate(NodeJs)> I/O > UI rendering

最後很差意思推廣一下我基於 Taro 框架寫的組件庫:MP-ColorUI

能夠順手 star 一下我就很開心啦,謝謝你們。

點這裏是文檔

點這裏是 GitHUb 地址

相關文章
相關標籤/搜索