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

恍然小悟發表於2018-09-07

棧和佇列

在計算機記憶體中存取資料,基本的資料結構分為棧和佇列。

棧(Stack)是一種後進先出的資料結構,注意,有時候也管棧叫做“堆疊”,但是“堆”又是另一種複雜的資料結構,它和棧完全是兩碼事。棧的特點是操作只在一端進行,一般來說,棧的操作只有兩種:進棧和出棧。第一個進棧的資料總是最後一個才出來。

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

佇列(Queue)和棧類似,但它是先進先出的資料結構,插入資料的操作從佇列的一端進行,而刪除的操作在另一端。

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

通俗的比喻棧就像是一個立好的桶,先放入棧的資料會放在桶底,出棧時會在桶口一一將資料取出,所以最先放入棧的資料總是最後一個才能取出。而佇列就像是一個水管,最先放入佇列的資料會第一個從佇列的另一端流出,這是它們最大的區別。

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

function fun1() {
    function fun2() {
        function fun3() {
            console.log('do it');
        }
        fun3();
    }
    fun2();
}
fun1();
複製程式碼

在程式執行時,首先將fun1,fun2,fun3依次入棧,而在呼叫函式時,是先將fun3呼叫(出棧),再是fun2和fun1,試想一下,如果fun1先出棧,那麼函式fun2和fun3必將丟失。

單執行緒和非同步

在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步,直到非同步佇列都清空,程式執行結束

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

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

通過以上的步驟可以看到,不論是同步還是非同步,只要是執行的時候都是要在棧裡執行的,而一遍又一遍的回頭檢查非同步佇列,這種執行方式 就是所謂的“事件環”。

明白了javascript的執行原理,我們就不難理解之前的第二段程式碼,為什麼setTimeout為0時會最後執行,因為setTimeout是非同步程式碼,必須要等所有的同步程式碼都執行完,才會執行非同步佇列。即使setTimeout執行得再快,它也不可能在同步程式碼之前執行。

瀏覽器中的事件環

聊了這麼多,我們好像還沒有說巨集任務和微任務的話題呢,上面說了,非同步任務又分為微任務和巨集任務,那它們又是一個怎樣的執行機制呢?

注意!微任務和巨集任務的執行方式在瀏覽器和Node中有差異,有差異!重要的事我們多說幾遍,以下我們討論的是在瀏覽器的環境裡。

在瀏覽器的執行環境中,總是先執行小的、微任務,再執行大的、巨集任務,回過頭再看看第三段程式碼,為什麼Promise的then方法在setTimeout之前執行?其根本原理就是因為Promise的then方法是一個微任務,而setTimeout是一個巨集任務。

接下來我們借用阮一峰老師的一張圖來說明:

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

其實,以上這張圖示我們可以再將它細化一點,這個圖上的非同步佇列只畫了一個,也就是說沒有區分微任務佇列和巨集任務佇列。我們可以腦補一下,在此圖上多加一個微任務佇列,當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的官方文件中有專門的描述,其中文件中有一張圖,詳細的說明了它的事件環機制,我們把它拿出來:

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

可以看到,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

相關文章