瀏覽器中的事件循環機制【看完就懂】


什么是事件循環機制

相信大家看過很多類似下面這樣的代碼:

function printNumber(){
    console.log('printNumber');                                                
}
setTimeout(function(){
    console.log('setTimeout 1000')
}, 1000);

setTimeout(function(){
    console.log('setTimeout 0')
});

printNumber();

new Promise((resolve, reject) => {
    console.log('new promise');
}).then(function(){
    console.log('promise resolve');
})

然后讓我們說出這段代碼的輸出結果,那這段代碼的輸出結果其實就是由事件循環機制決定的。

我們都知道JS引擎線程是專門用來解析JavaScript腳本的,所有的JavaScript代碼都由這一個線程來解析。然而這個JS引擎是單線程的,也就意味着JavaScript程序在執行時,前面的必須處理好,后面的才會執行。

但是JavaScript中除了一些順序執行的邏輯代碼,還有很多異步任務,比如Ajax請求定時器等。如果JS引擎在單線程解析JavaScript時遇到了一個Ajax請求,那就必須等Ajax請求返回結果才能繼續執行后續的代碼,很顯然這樣的行為是非常低效的。

那為了解決這樣的問題,事件循環機制這樣的技術就顯得尤為重要:

"JS引擎"在順序執行"JavaScript"代碼時,如果遇到"同步代碼"立即執行;

如果遇到一些"異步任務"就會將這個"異步任務"交給對應的模塊處理,然后繼續執行后續代碼;

當一個"異步任務"到達觸發條件時就將該"異步任務"的回調放入"任務隊列"中;

當"JS引擎"空閑以后,就會從"任務隊列"讀取和執行異步任務;

補充內容:

1.JS引擎線程也被稱為執行JS代碼的主線程,后續如果出現主線程這樣的描述,指的就是JS引擎線程
2. 任務隊列屬於數據結構中的隊列,特性是先進先出
3. 有關JavaScript同步任務異步任務的分類下面一節會介紹。
4. 本文只討論瀏覽器環境下的事件循環機制,后續的描述和代碼演示均基於瀏覽器環境(Node中的事件循環機制不做分析)。

JavaScript任務的分類

前面我們簡單介紹過事件循環機制執行JS代碼的順序,那首先我們需要知道在JavaScript那些代碼是同步任務,那些是異步任務

接下來我們對JavaScript中的任務做一個分類:

這個分類很重要哦 不同的類型的任務執行順序不同~

任務的執行順序

接着事件循環機制中JS引擎對這些任務的執行順序描述如下:

  • 步驟一: 從<script>代碼開始,到</script>代碼結束,按順序執行所有的代碼。

  • 步驟二: 在步驟一順序執行代碼的過程中,如果遇到同步任務,立即執行,然后繼續執行后續代碼;如果遇到異步任務,將異步任務交給對應的模塊處理(事件交給事件處理線程ajax交給異步HTTP請求線程),當異步任務到達觸發條件以后將異步任務回調函數推入任務隊列宏任務推入宏任務隊列微任務推入微任務隊列)。

  • 步驟三:步驟一結束后,說明同步代碼執行完畢。此時讀取並執行微任務隊列中保存的所有的微任務

  • 步驟四: 步驟三完成后讀取並執行宏任務隊列中的宏任務,每執行完一個宏任務就去查看微任務隊列中是否有新增的微任務,如果存在則重復步驟三;如果不存在,繼續執行下一個宏任務,直到。

一定要看的補充說明 !!!

1.步驟四中描述的新增的微任務和步驟三中描述的微任務是一樣的,因為異步任務只有滿足條件以后才會被推入任務隊列步驟三在執行時,不一定所有的微任務到達觸發條件而被推入任務隊列

2.所謂的到達觸發條件指的是下面這幾種情況:
① 定時器:定時器設置的時間到達,才會將定時器的回調函數推入任務隊列中
② DOM事件:DOM綁定的事件被觸發以后,才會將事件的回調函數推入任務隊列中
③ 異步請求:異步請求返回結果以后,才會將異步請求的回調函數推入任務隊列中
④ 異步任務之間的互相嵌套:比如宏任務A嵌套微任務X,當宏任務A對應的回調函數代碼沒有被執行到的時候,很顯然根本不存在微任務X;只有宏任務A對應的回調函數代碼被執行以后,JS引擎才會解析到微任務X,此時依然是將該微任務X交給對應的線程去處理,當微任務X滿足前面描述的①、②、③的條件,才會將微任務X對應的回調推入任務隊列,等待JS引擎去執行。

3.所有的異步任務都在JS引擎遇到</script>以后才會開始執行。

4.宏任務對應的英文描述為task微任務對應的英文描述為micro task宏任務隊列描述為task quene微任務隊列描述為micro task quene。不過很多文章也會將宏任務描述為macro task,這個沒多大關系。只是有些文章會將micro task描述為微任務隊列,就有些誤導人了,本文為了避免描述上產生的問題,均用中文文字描述。

實踐一波吧

到此事件循環機制的核心內容就講完了,核心內容主要就兩點:JavaScript任務分類任務執行順序。只要牢牢掌握這兩點,就能解決大部分問題。

那接下來我們就來實踐一下。

示例一

console.log('script start');

function printNumber(){
    console.log('同步任務執行:printNumber');          
}

setTimeout(function(){
    console.log('宏任務執行:setTimeout 1000ms')
}, 1000);

printNumber();

new Promise((resolve, reject) => {
    console.log('同步任務執行:new promise');
    resolve();
}).then(function(){
    console.log('微任務執行:promise resolve');
})

console.log('script end');

這段代碼是文章開頭貼出來的代碼,相對來說比較簡單,接下來就分析一下這段代碼的執行順序以及輸出結果

  • 1.首先js引擎從上到下開始執行代碼

  • 2.遇到console.log直接打印script start

  • 3.遇到函數聲明

  • 4.遇到宏任務setTimeout,交給定時器線程去處理(定時器線程會在1000ms后將setTimeout回調函數function(){ console.log('宏任務執行:setTimeout 1000ms') } 推入宏任務隊列,等待JS引擎去執行),之后JS引擎繼續執行后續代碼

  • 5.遇到函數調用:printNumber立即執行並打印同步任務執行:printNumber

  • 6.遇到new Promisenew Promise構造傳入的內容立即執行所以打印console.log('同步任務執行:new promise');

  • 7.遇到resolve執行promise.thenpromise.then屬於微任務,因此將promise.then回調函數function(){ console.log('微任務執行:promise resolve'); }推入微任務隊列

  • 8.再次遇到console.log直接打印script end

  • 9.步驟8完成,即說明同步任務執行完畢。此時就開始讀取並執行微任務隊列中所有的微任務。 在本例中就是執行步驟7中的promise.then即打印微任務執行:promise resolve

  • 10.本例中只有一個微任務,因此步驟9完成以后開始執行宏任務,也就是步驟4setTimeout的回調,即打印宏任務執行:setTimeout 1000ms

注意:setTimeout定時器設置的時間實際是推入任務隊列的時間

經過以上的分析,得出來的打印順序如下:

script start
同步任務執行:printNumber
同步任務執行:new promise
script end
微任務執行:promise resolve 
宏任務執行:setTimeout 1000ms

最后在瀏覽器中驗證一下:

示例二

接下來我們來看看下面這個稍微復雜一些的案例:

console.log('script start');

setTimeout(function(){
    console.log('宏任務執行:setTimeout1 2000ms')
}, 2000);

setTimeout(function(){
    console.log('宏任務執行:setTimeout2 0ms')
}, 0);

new Promise((resolve, reject) => {
    console.log('同步代碼執行: new Promise');
    setTimeout(function(){
        console.log('宏任務執行:setTimeout3 1000ms')
        resolve();
    }, 1000);
}).then(function(){
    console.log('微任務執行:promise resolve')
});

console.log('script end');

分析執行過程:

  • 1.js引擎從上到下開始執行代碼

  • 2.遇到console.log直接打印script start

  • 3.遇到宏任務setTimeout,交給定時器線程處理(定時器線程會在2000ms后將setTimeout回調函數function(){ console.log('宏任務執行:setTimeout1 2000s') } 推入宏任務隊列,等待JS引擎去執行),JS引擎繼續執行后續代碼

  • 4.再次遇到宏任務setTimeout,交給定時器線程處理(定時器線程會在0ms后將setTimeout回調函數function(){ console.log('宏任務執行:setTimeout2 0s') } 推入宏任務隊列,等待JS引擎去執行),JS引擎繼續執行后續代碼

  • 5.遇到new Promisenew Promise構造傳入的內容立即執行所以打印console.log('同步任務執行:new promise');

  • 6.接着發現new Promise的構造函數存在一個宏任務setTimeout,所以依然是交給定時器線程處理(定時器線程會在1000ms后將改setTimeout回調函數function(){ console.log('宏任務執行:setTimeout3 1000ms') } 推入宏任務隊列,等待JS引擎去執行),JS引擎繼續執行后續代碼

  • 7.遇到console.log直接打印script end

  • 8.步驟7完成,即說明同步任務執行完畢。在這個過程中,沒有產生微任務,所以微任務隊列為空;同時在這個過程中產生了三個宏任務:setTimeout,按照定時器設置的時間,這三個宏任務推入宏任務隊列的順序為:setTimeout2 0mssetTimeout3 1000mssetTimeout1 2000ms,所以后續執行宏任務時先推入隊列的任務先執行。(最先推入任務隊列的稱為隊首的任務,任務執行完成后,就會從隊首中移除,下一個任務就會稱為隊首任務)

  • 9.根據步驟8的分析,執行完同步代碼以后,本應該先執行微任務隊列中的所有的微任務,但是因為並沒有微任務存在,所以開始執行宏任務隊列中隊首的任務,即setTimeout2 0ms所以會打印宏任務執行:setTimeout2 0ms

  • 10.步驟9結束以后,也就是執行完一個宏任務了;接下依然是執行微任務隊列中的所有微任務,但是此時依然因為沒有微任務存在,所以執行宏任務隊列中的隊首的那個任務,即setTimeout3 1000ms所以會打印宏任務執行:setTimeout2 0ms; 接着發現定時器setTimeout的回調函數中調用了resolve,因此產生了一個微任務:promise.then,該微任務會被推入微任務隊列。

  • 11.步驟10結束以后,也是執行完一個宏任務了;接下還是執行微任務隊列中的所有微任務,此時微任務隊列中有一個微任務,是步驟9在執行的過程中產生的(這就是我們在前面說的任務之間的嵌套,只有外層任務的回調被執行后,內層的任務才會存在),所以執行該微任務,打印微任務執行:promise resolve

    1. 步驟10完成后,即執行完一個微任務;接着繼續執行宏任務隊列隊首的那個任務,即打印setTimeout1 2000s
  • 13.所有的微任務宏任務執行完畢,代碼結束

最終的打印順序:

script start
同步代碼執行: new Promise
script end
宏任務執行:setTimeout2 0ms
宏任務執行:setTimeout3 1000ms
微任務執行:promise resolve
宏任務執行:setTimeout1 2000ms

瀏覽器在驗證一下:

setImmediate和setTimeout 0

關於setImmediate的作用 MDN Web Docs 是這樣介紹的:

從上面的描述我們可以獲取到兩個有用信息:

  • 1.該方法是非標准的,目前只有最新版的IENodejs 0.10+支持
  • 2.該方法提供的回調函數會在瀏覽器完成后面的其他語句后立即執行

關於第一點非常好理解,我自己也做過嘗試,確實只有IE10以及更高的版本才能使用;

而第二點說的有點含糊,我個人理解為setImmediate的回調應該是在JS引擎執行完所有的同步代碼以后立即執行的。

那不管如何理解,我們在IE瀏覽器中試試應該能得出更准確的結論。

以下所有的示例均在IE11中進行測試

示例一

首先是一個最簡單的示例:

console.log('script start');
setImmediate(function(){
    console.log('宏任務執行:setImmediate');
})
console.log('script end');

這段代碼的輸出順序如下:

從這個示例的結果可以看到setImmediate的回調函數確實是在同步代碼執行完成后才執行的。這個結果能說明前面的理解是正確的嗎?

先不要着急,我們在來看一個示例。

示例二

console.log('script start');
setImmediate(function(){
    console.log('宏任務執行:setImmediate');
})
setTimeout(function(){
    console.log('宏任務執行:setTimeout 0');
},0)
console.log('script end');

在這個示例中,我們寫了一個setTimeout定時器,並且將時間設置為0。根據代碼書寫順序,在將setImmediate推入宏任務隊列以后,緊接着setTimeout的回調也會被推入宏任務隊列,所以最終應該輸出:

script start
script end
宏任務執行:setImmediate
宏任務執行:setTimeout 0

但是瀏覽器的輸出結果並不是這樣的:

示例三

console.log('script start');
setTimeout(function(){
    setImmediate(function(){
        console.log('宏任務執行:setImmediate');
    })
    setTimeout(function(){
        console.log('宏任務執行:setTimeout 0');
    },0)  
}, 0)

console.log('script end');

在這個例子中,我們將setImmediatesetTimeout 0是嵌套在異步任務setTimeout的里面,並且外層的setTimeout設置的時間是0ms

然而令人困惑的是這段代碼在IE瀏覽器中的輸出結果是不確定的:

以上是多次刷新頁面的輸出結果

經過以上三個示例,關於setImmediatesetTimeout 0兩者的執行時機貌似得不出什么合適的結論,所以這個問題先不做總結,后續在研究吧~

setTimeout 0 和setTimeout 1

在學習這個的時候看到一個特別有意思的代碼:

setTimeout(function(){
    console.log('宏任務執行:setTimeout 1ms');
}, 1)

setTimeout(function(){
    console.log('宏任務執行:setTimeout 0ms');
}, 0)

如果按照事件循環機制的說法,理論上以上的代碼輸出結果為:

script start
script end
宏任務執行:setTimeout 0
宏任務執行:setTimeout 1

這段代碼在FirefoxIE中確實是前面我們推測出來的結果:

但是在Chrome中卻是下面這樣的結果:

Chrome瀏覽器的輸出結果貌似有點違背前面總結的事件循環機制,但是實際上並沒有,因為我們有一句非常重要的話:當異步任務到達觸發條件以后將異步任務的回調函數推入任務隊列

所以對於setTimeout 1是在1ms后將回調函數推入任務隊列setTimeout 0則是立即將回調函數推入任務隊列,然而setTimeout 1setTimeout 0的前面,執行完setTimeout 1以后,當執行setTimeout 0的時候1ms的時間已經過去了,那這個時候setTimeout 1的回調函數就比setTimeout 0的回調函數先壓入任務隊列,所以就會出現Chrome中的打印結果。

Ajax和Dom事件的疑惑

前面我們在對JS中的任務分類時,對AjaxDom事件並沒有進行歸類,一個原因是發現很多文章並沒有對這兩個任務進行分類,也有很多文章對這兩個任務的分類都不一致;另外一個原因就是我自己也沒有找到一些合適的例子去證實。

不過關於事件循環機制 HTML Standard有關於 Event Loop 的介紹,不過介於全篇是純英文的,簡單看過之后只get到了下面的這些有效信息:

在經過翻譯和解讀以后,得出來下面這些信息。

每一個任務都有相關的任務源

function fn(){ }
setTimeout(fn, 1000)

在上面的例子中fn就稱為是setTimeout回調函數setTimeout就稱為該回調函數的任務源。

推入任務隊列的是對應的回調函數,執行回調函數的時候可以稱為在執行任務。所以在該示例中就可以說任務fn對應的任務源就是setTimeout

瀏覽器會根據任務源去分類所有的任務

這個就是前面我們第二節中總結的JavaScript中任務的分類

瀏覽器有一個用於鼠標和按鍵事件的任務隊列

關於這個說法的完整翻譯為:瀏覽器可以有一個用於鼠標和按鍵事件的任務隊列(與用戶交互任務源關聯),另一個與所有其他任務源關聯。然后,使用在事件循環處理模型的初始步驟中授予的自由度,它可以使鍵盤和鼠標事件優先於其他任務四分之三的時間,從而保持界面的響應性,但不會耗盡其他任務隊列

看完這段話,我會理解DOM事件是不是區別於前面我們說的宏任務微任務

總而言之呢,關於AjaxDom事件到底是屬於微任務還是宏任務?以及它們兩個和其他異步任務共同存在時的執行順序,我自己還是存疑的,所以就不給出什么結論了,以免誤導大家。當然如果大家有明確的結論或者示例,歡迎提出來~

總結

到此本篇文章就結束了,有關瀏覽器中的事件循環機制就我們總結的兩個核心點:JavaScript任務分類任務執行順序

只要牢牢掌握這兩點,就能解決大部分問題。

然而本篇文章還遺留了兩個問題:

  • 瀏覽器中setTimeout 0setImmediate執行順序
  • ajaxdom事件是宏任務還是微任務

年后有時間在復盤總結這兩個問題吧。

最后提前祝大家在新的一年好運哦~

近期文章

詳解Vue中的computed和watch

記一次真實的Webpack優化經歷

JavaScript的執行上下文,真沒你想的那么難

寫在最后

如果這篇文章有幫助到你,❤️關注+點贊❤️鼓勵一下作者

文章公眾號首發,關注 不知名寶藏程序媛 第一時間獲取最新的文章

筆芯❤️~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM