1. 程式人生 > >深入理解JavaScript的事件循環(Event Loop)

深入理解JavaScript的事件循環(Event Loop)

out star event ron 來看 runt 針對 我們 ==

一、什麽是事件循環

JS的代碼執行是基於一種事件循環的機制,之所以稱作事件循環,MDN給出的解釋為

因為它經常被用於類似如下的方式來實現

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

如果當前沒有任何消息queue.waitForMessage 會等待同步消息到達

我們可以把它當成一種程序結構的模型,處理的方案。更詳細的描述可以查看 這篇文章

而JS的運行環境主要有兩個:瀏覽器Node

在兩個環境下的Event Loop實現是不一樣的,在瀏覽器中基於 規範 來實現,不同瀏覽器可能有小小區別。在Node中基於 libuv 這個庫來實現

JS是單線程執行的,而基於事件循環模型,形成了基本沒有阻塞(除了alert或同步XHR等操作)的狀態

二、Macrotask 與 Microtask

根據 規範,每個線程都有一個事件循環(Event Loop),在瀏覽器中除了主要的頁面執行線程 外,Web worker是在一個新的線程中運行的,所以可以將其獨立看待。

每個事件循環有至少一個任務隊列(Task Queue,也可以稱作Macrotask宏任務),各個任務隊列中放置著不同來源(或者不同分類)的任務,可以讓瀏覽器根據自己的實現來進行優先級排序

以及一個微任務隊列(Microtask Queue),主要用於處理一些狀態的改變,UI渲染工作之前的一些必要操作(可以防止多次無意義的UI渲染)

主線程的代碼執行時,會將執行程序置入執行棧(Stack)中,執行完畢後出棧,另外有個堆空間(Heap),主要用於存儲對象及一些非結構化的數據

技術分享圖片

一開始

宏任務與微任務隊列裏的任務隨著:任務進棧、出棧、任務出隊、進隊之間交替著進行

從macrotask隊列中取出一個任務處理,處理完成之後(此時執行棧應該是空的),從microtask隊列中一個個按順序取出所有任務進行處理,處理完成之後進入UI渲染後續工作

需要註意的是:microtask並不是在macrotask完成之後才會觸發,在回調函數之後,只要執行棧是空的,就會執行microtask。也就是說,macrotask執行期間,執行棧可能是空的(比如在冒泡事件的處理時)

然後循環繼續

常見的macrotask有:

  • run <script>(同步的代碼執行)

  • setTimeout
  • setInterval

  • setImmediate (Node環境中)

  • requestAnimationFrame

  • I/O

  • UI rendering

常見的microtask有:

  • process.nextTick (Node環境中)

  • Promise callback

  • Object.observe (基本上已經廢棄)

  • MutationObserver

macrotask種類很多,還有 dispatch event事件派發等

run <script>這個可能看起來比較奇怪,可以把它看成一段代碼(針對單個<script>標簽)的同步順序執行,主要用來描述執行程序的第一步執行

dispatch event主要用來描述事件觸發之後的執行任務,比如用戶點擊一個按鈕,觸發的onClick回調函數。需要註意的是,事件的觸發是同步的,這在下文有例子說明

註:

當然,也可認為 run <script>不屬於macrotask,畢竟規範也沒有這樣的說明,也可以將其視為主線程上的同步任務,不在主線程上的其他部分為異步任務

三、在瀏覽器中的實現

先來看看這段蠻復雜的代碼,思考一下會輸出什麽

            console.log(‘start‘);

            var intervalA = setInterval(() => {
                console.log(‘intervalA‘);
            }, 0);

            setTimeout(() => {
                console.log(‘timeout‘);

                clearInterval(intervalA);
            }, 0);

            var intervalB = setInterval(() => {
                console.log(‘intervalB‘);
            }, 0);

            var intervalC = setInterval(() => {
                console.log(‘intervalC‘);
            }, 0);

            new Promise((resolve, reject) => {
                console.log(‘promise‘);

                for (var i = 0; i < 10000; ++i) {
                    i === 9999 && resolve();
                }

                console.log(‘promise after for-loop‘);
            }).then(() => {
                console.log(‘promise1‘);
            }).then(() => {
                console.log(‘promise2‘);

                clearInterval(intervalB);
            });

            new Promise((resolve, reject) => {
                setTimeout(() => {
                    console.log(‘promise in timeout‘);
                    resolve();
                });

                console.log(‘promise after timeout‘);
            }).then(() => {
                console.log(‘promise4‘);
            }).then(() => {
                console.log(‘promise5‘);

                clearInterval(intervalC);
            });

            Promise.resolve().then(() => {
                console.log(‘promise3‘);
            });

            console.log(‘end‘);    

上述代碼結合了常規執行代碼,setTimeout,setInterval,Promise

答案為

技術分享圖片

在解釋為什麽之前,先看一個更簡單的例子

            console.log(‘start‘);

            setTimeout(() => {
                console.log(‘timeout‘);
            }, 0);

            Promise.resolve().then(() => {
                console.log(‘promise‘);
            });

            console.log(‘end‘);    

大概的步驟,文字有點多

1. 運行時(runtime)識別到log方法為一般的函數方法,將其入棧,然後執行輸出 start 再出棧

2. 識別到setTimeout為特殊的異步方法(macrotask),將其交由其他內核模塊處理,setTimeout的匿名回調函數被放入macrotask隊列中,並設置了一個 0ms的立即執行標識(提供後續模塊的檢查)

3. 識別到Promise的resolve方法為一般的方法,將其入棧,然後執行 再出棧

4. 識別到then為Promise的異步方法(microtask),將其交由其他內核模塊處理,匿名回調函數被放入microtask隊列中

5. 識別到log方法為一般的函數方法,將其入棧,然後執行輸出 end 再出棧

6. 主線程執行完畢,棧為空,隨即從microtask隊列中取出隊首的項,

這裏隊首為匿名函數,匿名函數裏面有 console的log方法,也將其入棧(如果執行過程中識別到特殊的方法,就在這時交給其他模塊處理到對應隊列尾部),

輸出 promise後出棧,並將這一項從隊列中移除

7. 繼續檢查microtask隊列,當前隊列為空,則將當前macrotask出隊,進入下一步(如果不為空,就繼續取下一個microtask執行)

8.檢查是否需要進行UI重新渲染等,進行渲染...

9. 進入下一輪事件循環,檢查macrotask隊列,取出一項進行處理

所以最終的結果是

技術分享圖片

再看上面那個例子,對比起來只是代碼多了點,混入了setInterval,多個setTimeout與promise的函數部分,按照上面的思路,應該不難理解

需要註意的三點:

1. clearInterval(intervalA); 運行的時候,實際上已經執行了 intervalA 的macrotask了
2. promise函數內部是同步處理的,不會放到隊列中,放入隊列中的是它的then或catch回調
3. promise的then返回的還是promise,所以在輸出promise4後,繼續檢測到後續的then方法,馬上放到microtask隊列尾部,再繼續取出執行,馬上輸出promise5;

而輸出promise1之後,為什麽沒有馬上輸出promise2呢?因為此時promise1所在任務之後是promise3的任務,1和3在promise函數內部返回後就添加至隊列中,2在1執行之後才添加

再來看個例子,就有點微妙了

<script>
        console.log(start);

        setTimeout(() => {
            console.log(timeout1);
        }, 0);

        Promise.resolve().then(() => {
            console.log(promise1);
        });
    </script>
    <script>
        setTimeout(() => {
            console.log(timeout2);
        }, 0);

        requestAnimationFrame(() => {
            console.log(requestAnimationFrame);
        });

        Promise.resolve().then(() => {
            console.log(promise2);
        });

        console.log(end);
    </script>

輸出結果

技術分享圖片

requestAnimationFrame是在setTimeout之前執行的,start之後並不是直接輸出end,也許這兩個<script>標簽被獨立處理了

來看一個關於DOM操作的例子,Tasks, microtasks, queues and schedules

<style type="text/css">
    .outer {
        width: 100px;
        background: #eee;
        height: 100px;
        margin-left: 300px;
        margin-top: 150px;
        display: flex;
        align-items: center;
        justify-content: center;
    }

    .inner {
        width: 50px;
        height: 50px;
        background: #ddd;
    }
</style>

<script>
        var outer = document.querySelector(.outer),
            inner = document.querySelector(.inner),
            clickTimes = 0;

        new MutationObserver(() => {
            console.log(mutate);
        }).observe(outer, {
            attributes: true
        });

        function onClick() {
            console.log(click);

            setTimeout(() => {
                console.log(timeout);
            }, 0);

            Promise.resolve().then(() => {
                console.log(promise);
            });

            outer.setAttribute(data-click, clickTimes++);
        }

        inner.addEventListener(click, onClick);
        outer.addEventListener(click, onClick);

        // inner.click();

        // console.log(‘done‘);
    </script>

技術分享圖片

點擊內部的inner塊,會輸出什麽呢?

技術分享圖片

MutationObserver優先級比promise高,雖然在一開始就被定義,但實際上是觸發之後才會被添加到microtask隊列中,所以先輸出了promise

兩個timeout回調都在最後才觸發,因為click事件冒泡了,事件派發這個macrotask任務包括了前後兩個onClick回調,兩個回調函數都執行完之後,才會執行接下來的 setTimeout任務

期間第一個onClick回調完成後執行棧為空,就馬上接著執行microtask隊列中的任務

如果把代碼的註釋去掉,使用代碼自動 click(),思考一下,會輸出什麽?

技術分享圖片

可以看到,事件處理是同步的,done在連續輸出兩個click之後才輸出

而mutate只有一個,是因為當前執行第二個onClick回調的時候,microtask隊列中已經有一個MutationObserver,它是第一個回調的,因為事件同步的原因沒有被及時執行。瀏覽器會對MutationObserver進行優化,不會重復添加監聽回調

四、在Node中的實現

在Node環境中,macrotask部分主要多了setImmediate,microtask部分主要多了process.nextTick,而這個nextTick是獨立出來自成隊列的,優先級高於其他microtask

不過事件循環的的實現就不太一樣了,可以參考 Node事件文檔 libuv事件文檔

Node中的事件循環有6個階段

  • timers:執行setTimeout()setInterval()中到期的callback
  • I/O callbacks:上一輪循環中有少數的I/Ocallback會被延遲到這一輪的這一階段執行
  • idle, prepare:僅內部使用
  • poll:最為重要的階段,執行I/O callback,在適當的條件下會阻塞在這個階段
  • check:執行setImmediate的callback
  • close callbacks:執行close事件的callback,例如socket.on("close",func)

技術分享圖片

每一輪事件循環都會經過六個階段,在每個階段後,都會執行microtask

技術分享圖片

比較特殊的是在poll階段,執行程序同步執行poll隊列裏的回調,直到隊列為空或執行的回調達到系統上限

接下來再檢查有無預設的setImmediate,如果有就轉入check階段,沒有就先查詢最近的timer的距離,以其作為poll階段的阻塞時間,如果timer隊列是空的,它就一直阻塞下去

而nextTick並不在這些階段中執行,它在每個階段之後都會執行

看一個例子

setTimeout(() => console.log(1));

setImmediate(() => console.log(2));

process.nextTick(() => console.log(3));

Promise.resolve().then(() => console.log(4));

console.log(5);

根據以上知識,應該很快就能知道輸出結果是 5 3 4 1 2

修改一下

process.nextTick(() => console.log(1));

Promise.resolve().then(() => console.log(2));

process.nextTick(() => console.log(3));

Promise.resolve().then(() => {
    process.nextTick(() => console.log(0));
    console.log(4);
});

輸出為 1 3 2 4 0,因為nextTick隊列優先級高於同一輪事件循環中其他microtask隊列

修改一下

process.nextTick(() => console.log(1));

console.log(0);

setTimeout(()=> {
    console.log(‘timer1‘);

    Promise.resolve().then(() => {
        console.log(‘promise1‘);
    });
}, 0);

process.nextTick(() => console.log(2));

setTimeout(()=> {
    console.log(‘timer2‘);

    process.nextTick(() => console.log(3));

    Promise.resolve().then(() => {
        console.log(‘promise2‘);
    });
}, 0);

輸出為

技術分享圖片

與在瀏覽器中不同,這裏promise1並不是在timer1之後輸出,因為在setTimeout執行的時候是出於timer階段,會先一並處理timer回調

setTimeout是優先於setImmediate的,但接下來這個例子卻不一定是先執行setTimeout的回調

setTimeout(() => {
    console.log(‘timeout‘);
}, 0);

setImmediate(() => {
    console.log(‘immediate‘);
});

技術分享圖片

因為在Node中識別不了0ms的setTimeout,至少也得1ms.

所以,如果在進入該輪事件循環的時候,耗時不到1ms,則setTimeout會被跳過,進入check階段執行setImmediate回調,先輸出 immediate

如果超過1ms,timer階段中就可以馬上處理這個setTimeout回調,先輸出 timeout

修改一下代碼,讀取一個文件讓事件循環進入IO文件讀取的poll階段

    let fs = require(‘fs‘);

    fs.readFile(‘./event.html‘, () => {
        setTimeout(() => {
            console.log(‘timeout‘);
        }, 0);

        setImmediate(() => {
            console.log(‘immediate‘);
        });
    });

這麽一來,輸出結果肯定就是 先 immediate 後 timeout

五、用好事件循環

知道JS的事件循環是怎麽樣的了,就需要知道怎麽才能把它用好

1. 在microtask中不要放置復雜的處理程序,防止阻塞UI的渲染

2. 可以使用process.nextTick處理一些比較緊急的事情

3. 可以在setTimeout回調中處理上輪事件循環中UI渲染的結果

4. 註意不要濫用setInterval和setTimeout,它們並不是可以保證能夠按時處理的,setInterval甚至還會出現丟幀的情況,可考慮使用 requestAnimationFrame

5. 一些可能會影響到UI的異步操作,可放在promise回調中處理,防止多一輪事件循環導致重復執行UI的渲染

6. 在Node中使用immediate來可能會得到更多的保證

7. 不要糾結

深入理解JavaScript的事件循環(Event Loop)