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

 

1、什麼是事件循環

JS的代碼執行是基於一種事件循環的機制,之因此稱做事件循環,MDN給出的解釋爲css

由於它常常被用於相似以下的方式來實現html

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

若是當前沒有任何消息queue.waitForMessage 會等待同步消息到達html5

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

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

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

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

 

 2、Macrotask 與 Microtask

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

每一個事件循環有至少一個任務隊列(Task Queue,也能夠稱做Macrotask宏任務),各個任務隊列中放置着不一樣來源(或者不一樣分類)的任務,可讓瀏覽器根據本身的實現來進行優先級排序api

以及一個微任務隊列(Microtask Queue),主要用於處理一些狀態的改變,UI渲染工做以前的一些必要操做(能夠防止屢次無心義的UI渲染)promise

主線程的代碼執行時,會將執行程序置入執行棧(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,畢竟規範也沒有這樣的說明,也能夠將其視爲主線程上的同步任務,不在主線程上的其餘部分爲異步任務

 

3、在瀏覽器中的實現

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

            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進行優化,不會重複添加監聽回調

 

 

 4、在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

 

 5、用好事件循環

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

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

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

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

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

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

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

7. 不要糾結

相關文章
相關標籤/搜索