泡杯茶,咱們坐下聊聊Javascript的事件環

棧和隊列

在計算機內存中存取數據,基本的數據結構分爲棧和隊列。javascript

棧(Stack)是一種後進先出的數據結構,注意,有時候也管棧叫作「堆棧」,可是「堆」又是另外一種複雜的數據結構,它和棧徹底是兩碼事。棧的特色是操做只在一端進行,通常來講,棧的操做只有兩種:進棧和出棧。第一個進棧的數據老是最後一個纔出來。html

clipboard.png

隊列(Queue)和棧相似,但它是先進先出的數據結構,插入數據的操做從隊列的一端進行,而刪除的操做在另外一端。java

clipboard.png

通俗的比喻棧就像是一個立好的桶,先放入棧的數據會放在桶底,出棧時會在桶口一一將數據取出,因此最早放入棧的數據老是最後一個才能取出。而隊列就像是一個水管,最早放入隊列的數據會第一個從隊列的另外一端流出,這是它們最大的區別。node

在javascript中,函數的執行就一個典型的入棧與出棧的過程:面試

function fun1() {
    function fun2() {
        function fun3() {
            console.log('do it');
        }
        fun3();
    }
    fun2();
}
fun1();

在程序執行時,首先將fun1,fun2,fun3依次入棧,而在調用函數時,是先將fun3調用(出棧),再是fun2和fun1,試想一下,若是fun1先出棧,那麼函數fun2和fun3必將丟失。chrome

單線程和異步

在javascript這門語言中程序是單線程的,只有一個主線程,這是爲何?由於不難想像,最初javascript的設計是跑在瀏覽器中的腳本語言,若是設計成多線程,兩個線程同時修改DOM那以誰的爲準呢?因此javascript爲單線程,在一個線程中代碼會一句一句向下走,直到程序跑完,若中間有較爲費時的操做,那也只能等着。瀏覽器

單線程的設計使得語言的執行效率不好,爲了利用多核心CPU的性能,javascript語言支持異步代碼,當有較爲費時的操做時,可將任務寫爲異步執行,當一個異步任務尚未執行完時,主線程會將異步任務掛起,繼續執行後面的同步代碼,以後再回過頭來看,若是有異步任務運行完了再執行它。數據結構

這種執行代碼的方式其實很符合咱們生活中的不少場景,好比小明同窗下班回家了,他很渴,想燒水泡茶,若是是同步的執行方式那就是燒水,在水沒開時小明像個傻子似的等着,等水開了再泡茶;如果異步執行,小明先開始燒水,而後就去幹點別的事,好比看會電視、聽聽音樂,等水燒開了再去泡茶。明顯第二種異步方式效率更高。多線程

常見的異步操做都有哪些?有不少,咱們能夠羅列幾個常見的:異步

  • Ajax
  • DOM的事件操做
  • setTimeout
  • Promise的then方法
  • Node的讀取文件

咱們先來看一段代碼:

//示例1
console.log(1);
setTimeout(function () {
    console.log(2);
}, 1000);
console.log(3);

這段代碼很是簡單,把它們放在瀏覽器中執行結果以下:

1
3
2

由於setTimeout函數延時了1000毫秒執行,所以先輸出1和3,而2是過了1000毫秒以後再輸出,這很合邏輯。

咱們稍稍改動一下代碼,將setTimeout的延時時間改成0:

//示例2
console.log(1);
setTimeout(function () {
    console.log(2);
}, 0); //0毫秒,不延時
console.log(3);

運行結果:

1
3
2

爲何延時了0毫秒仍是最後輸出的2?先別急,咱們再來看一段代碼:

//示例3
console.log(1);
setTimeout(function () {
    console.log(2);
}, 0);
Promise.resolve().then(function(){
    console.log(3);
});
console.log(4);

運行結果:

1
4
3
2

以上三段代碼,若是你能正確的寫出結果,而且能說明白爲何這樣輸出,說明你對javascript的事件環理解的很清楚,若是講不出來,咱們就一塊兒聊聊這裏面發生了什麼,其實頗有意思。

javascript是怎麼執行的?

一開始先簡單聊了聊基本的數據結構,它和咱們如今說的事件環有什麼關係麼?固然有,首先要明確的一點是,javascript代碼的執行全都在棧裏,不管是同步代碼仍是異步代碼,這個必定要清楚。

而代碼咱們大致上分爲了同步代碼和異步代碼,其實異步代碼還能夠再分爲兩類:宏任務微任務

先別管什麼是宏任務和微任務,每每這種高大上的術語不利於咱們理解,咱們先這麼認爲:宏,便是宏觀的、大的;微即微觀的、小的。

javascript是解釋型語言,它的執行過程是這樣的:

  1. 從上到下依次解釋每一條js語句
  2. 如果同步任務,則壓入一個棧(主線程);若是是異步任務,就放到一個任務隊列裏
  3. 開始執行棧裏的同步任務,直到將棧裏的全部任務都走完,此時棧清空了
  4. 回過頭看異步隊列裏若是有異步任務完成了,就生成一個事件並註冊回調,壓入棧中
  5. 再返回第3步,直到異步隊列都清空,程序運行結束

語言描述的費勁,不如看圖:

clipboard.png

經過以上的步驟能夠看到,不管是同步仍是異步,只要是執行的時候都是要在棧裏執行的,而一遍又一遍的回頭檢查異步隊列,這種執行方式 就是所謂的「事件環」。

明白了javascript的執行原理,咱們就不難理解以前的第二段代碼,爲何setTimeout爲0時會最後執行,由於setTimeout是異步代碼,必需要等全部的同步代碼都執行完,纔會執行異步隊列。即便setTimeout執行得再快,它也不可能在同步代碼以前執行。

瀏覽器中的事件環

聊了這麼多,咱們好像尚未說宏任務和微任務的話題呢,上面說了,異步任務又分爲微任務和宏任務,那它們又是一個怎樣的執行機制呢?

注意!微任務和宏任務的執行方式在瀏覽器和Node中有差別,有差別!重要的事咱們多說幾遍,如下咱們討論的是在瀏覽器的環境裏。

在瀏覽器的執行環境中,老是先執行小的、微任務,再執行大的、宏任務,回過頭再看看第三段代碼,爲何Promise的then方法在setTimeout以前執行?其根本原理就是由於Promise的then方法是一個微任務,而setTimeout是一個宏任務。

接下來咱們借用阮一峯老師的一張圖來講明:

clipboard.png

其實,以上這張圖示咱們能夠再將它細化一點,這個圖上的異步隊列只畫了一個,也就是說沒有區分微任務隊列和宏任務隊列。咱們能夠腦補一下,在此圖上多加一個微任務隊列,當javascript執行時再多加一個判斷,若是是微任務就加到微任務隊列裏,宏任務就加到宏任務隊列裏,在清空隊列時,瀏覽器總會優先清空「微任務」。這樣就把瀏覽器的事件環撤底說全了。

最後來一個大考,如下代碼的運行結果是什麼:

<script type="text/javascript">
    setTimeout(function () {
        console.log(1);
        Promise.resolve().then(function () {
            console.log(2);
        });
    });
    setTimeout(function () {
        console.log(3);
    });
    Promise.resolve().then(function () {
        console.log(4);
    });
    console.log(5);
</script>

將此代碼拷到chrome中跑一下,結果是:

5
4
1
2
3

不妨咱們試着分析一下爲何是這個結果,首先輸出5,由於console.log(5)是同步代碼,這沒什麼可說的。

以後將前兩個setTimeout和最後一個Promise放入異步隊列,注意它們的區分,此時執行完了同步代碼以後發現微任務和宏任務隊列中都有代碼,按瀏覽器的事件環機制,優先執行微任務,此時輸出4。

而後執行宏任務隊列裏的第一個setTimeout,輸出1。

此時,setTimeout中又有一個Promise,放入微任務隊列。

再次清空微任務隊列,輸出2。

最後宏任務隊列裏還有最後一個setTimeout,輸出3。

Node中的事件環

而Node中的事件環又和瀏覽器有些許的不一樣,在node.js的官方文檔中有專門的描述,其中文檔中有一張圖,詳細的說明了它的事件環機制,咱們把它拿出來:

node EventLoop

能夠看到,node.js中的事件環機制分爲了6個階段,其中最重要的3個階段我在上面作了註明:

  • timer階段,指的就是setTimeout等宏任務
  • poll輪詢階段,如讀取文件等宏任務
  • check階段,setImmediate宏任務

圖中每個階段都表明了一個宏任務隊列,在Node事件環中,微任務的運行時機是在每個「宏任務隊列」清空以後,在進入下一個宏任務隊列之間執行。這是和瀏覽器的最大區別。

仍是用代碼說話吧,有一道經典的Node.js事件環面試題:

const fs = require('fs');

fs.readFile('./1.txt', (err, data) => {
    setTimeout(() => {
        console.log('timeout');
    });
    setImmediate(() => {
        console.log('immediate');
    });
    Promise.resolve().then(() => {
        console.log('Promise');
    });
});

運行結果:

Promise
immediate
timeout

代碼並不複雜,首先使用fs模塊讀取了一個文件,在回調的內部有兩個宏任務和一個微任務,微任務老是優於宏任務執行的,所以先輸出Promise。

可是以後的區別爲何先輸出immdiate?緣由就在於fs讀取文件的宏任務在上圖中的第4個輪詢階段,當第4個階段清空隊列以後,就該進入第5個check階段,也就是setImmediate這個宏任務所在的階段,而不會跳回第1個階段,所以先輸出immedate。

尾巴

最後總結一下,分析完瀏覽器和Node的事件環發現它們並不簡單,但只要記住了它們之間的區別就能夠分析出結果。

瀏覽器事件環是運行完一個宏任務立刻清空微任務隊列
Node事件環是清空完一個階段的宏任務隊列以後再清空微任務隊列

最後,總結一下常見的宏任務和微任務:

宏任務 微任務
setTimeout Promise的then方法
setInterval process.nextTick
setImmediate MutationObserver
MessageChannel
相關文章
相關標籤/搜索