瀏覽器中的事件迴圈機制【看完就懂】
阿新 • • 發佈:2021-02-07
# 什麼是事件迴圈機制
相信大家看過很多類似下面這樣的程式碼:
```javascript
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`中的任務做一個分類:
![](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/47ef3d9f85e44981a844381d7a40c453~tplv-k3u1fbpfcp-watermark.image)
> 這個分類很重要哦 不同的型別的任務執行順序不同~
# 任務的執行順序
接著事件迴圈機制中`JS`引擎對這些任務的`執行順序`描述如下:
* 步驟一: 從``程式碼結束,按順序執行所有的程式碼。
* 步驟二: 在`步驟一`順序執行程式碼的過程中,如果遇到`同步任務`,立即執行,然後繼續執行後續程式碼;如果遇到`非同步任務`,將`非同步任務`交給對應的模組處理(`事件`交給`事件處理執行緒`,`ajax`交給`非同步HTTP請求執行緒`),當`非同步任務`到達觸發條件以後將`非同步任務`的`回撥函式`推入`任務佇列`(`巨集任務`推入`巨集任務佇列`,`微任務`推入`微任務佇列`)。
* 步驟三:`步驟一`結束後,說明同步程式碼執行完畢。此時讀取並執行`微任務佇列`中儲存的所有的`微任務`。
* 步驟四: `步驟三`完成後讀取並執行`巨集任務佇列`中的`巨集任務`,每執行完一個`巨集任務`就去檢視`微任務佇列`中是否有新增的`微任務`,如果存在則重複`步驟三`;如果不存在,繼續執行下一個`巨集任務`,直到。
![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b992db0e6f4443b8860cdd4899c33bc8~tplv-k3u1fbpfcp-watermark.image)
> **一定要看的補充說明 !!!**
>
> 1.步驟四中描述的`新增的微任務`和步驟三中描述的`微任務`是一樣的,因為`非同步任務`只有滿足條件以後才會被推入`任務佇列`,`步驟三`在執行時,不一定所有的`微任務`都`到達觸發條件`而被推入`任務佇列`;
>
> 2.所謂的`到達觸發條件`指的是下面這幾種情況:
> ① 定時器:定時器設定的時間到達,才會將定時器的回撥函式推入任務佇列中
> ② DOM事件:DOM繫結的事件被觸發以後,才會將事件的回撥函式推入任務佇列中
> ③ 非同步請求:非同步請求返回結果以後,才會將非同步請求的回撥函式推入任務佇列中
> ④ 非同步任務之間的互相巢狀:比如`巨集任務A`巢狀`微任務X`,當`巨集任務A`對應的回撥函式程式碼沒有被執行到的時候,很顯然根本不存在`微任務X`;只有`巨集任務A`對應的`回撥函式`程式碼被執行以後,`JS`引擎才會解析到`微任務X`,此時依然是將該`微任務X`交給對應的執行緒去處理,當`微任務X`滿足前面描述的①、②、③的條件,才會將`微任務X`對應的回撥推入`任務佇列`,等待`JS引擎`去執行。
>
> 3.所有的`非同步任務`都在`JS引擎`遇到` `以後才會開始執行。
>
> 4.`巨集任務`對應的英文描述為`task`,`微任務`對應的英文描述為`micro task`;`巨集任務佇列`描述為`task quene`,`微任務佇列`描述為`micro task quene`。不過很多文章也會將`巨集任務`描述為`macro task`,這個沒多大關係。只是有些文章會將`micro task`描述為`微任務佇列`,就有些誤導人了,本文為了避免描述上產生的問題,均用中文文字描述。
# 實踐一波吧
到此事件迴圈機制的核心內容就講完了,核心內容主要就兩點:`JavaScript任務分類`和`任務執行順序`。只要牢牢掌握這兩點,就能解決大部分問題。
那接下來我們就來實踐一下。
#### 示例一
```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 Promise`,`new Promise`構造傳入的內容`立即執行`,**所以列印**:`console.log('同步任務執行:new promise');`
* 7.遇到`resolve`執行`promise.then`,`promise.then`屬於`微任務`,因此將`promise.then`的`回撥函式`:`function(){ console.log('微任務執行:promise resolve'); }`推入`微任務佇列`
* 8.再次遇到`console.log`**直接列印**`script end`
* 9.`步驟8`完成,即說明`同步任務`執行完畢。此時就開始讀取並執行`微任務佇列`中所有的`微任務`。 在本例中就是執行`步驟7`中的`promise.then`,**即列印**:`微任務執行:promise resolve`。
* 10.本例中只有一個`微任務`,因此`步驟9`完成以後開始執行`巨集任務`,也就是`步驟4`中`setTimeout`的回撥,**即列印**:`巨集任務執行:setTimeout 1000ms`
> 注意:`setTimeout`定時器設定的時間實際是推入任務佇列的時間
經過以上的分析,得出來的列印順序如下:
```!
script start
同步任務執行:printNumber
同步任務執行:new promise
script end
微任務執行:promise resolve
巨集任務執行:setTimeout 1000ms
```
最後在瀏覽器中驗證一下:
![](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/81898ff2e5fa4d6db0a1f9d2272de8ff~tplv-k3u1fbpfcp-watermark.image)
#### 示例二
接下來我們來看看下面這個稍微複雜一些的案例:
```javascript
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 Promise`,`new 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 0ms`、`setTimeout3 1000ms`、`setTimeout1 2000ms`,所以後續執行`巨集任務`時先推入佇列的任務先執行。(最先推入任務佇列的稱為隊首的任務,任務執行完成後,就會從隊首中移除,下一個任務就會稱為隊首任務)
* 9.根據`步驟8`的分析,執行完同步程式碼以後,本應該先執行`微任務佇列`中的所有的`微任務`,但是因為並沒有`微任務`存在,所以開始執行`巨集任務佇列`中隊首的任務,即`setTimeout2 0ms`,**所以會列印**:`巨集任務執行:setTimeout2 0ms`
* 10.`步驟9`結束以後,也就是執行完一個巨集任務了;接下依然是執行`微任務佇列`中的所有`微任務`,但是此時依然因為沒有`微任務`存在,所以執行`巨集任務佇列`中的隊首的那個任務,即`setTimeout3 1000ms`,**所以會列印**:`巨集任務執行:setTimeout2 0ms`; 接著發現`定時器setTimeout`的回撥函式中呼叫了`resolve`,因此產生了一個`微任務:promise.then`,該微任務會被推入微任務佇列。
* 11.`步驟10`結束以後,也是執行完一個巨集任務了;接下還是執行`微任務佇列`中的所有`微任務`,此時`微任務佇列`中有一個微任務,是`步驟9`在執行的過程中產生的(這就是我們在前面說的任務之間的巢狀,只有外層任務的回撥被執行後,內層的任務才會存在),所以執行該微任務,**列印**:`微任務執行:promise resolve`
* 12. `步驟10完成後`,即執行完一個微任務;接著繼續執行`巨集任務佇列`中`隊首`的那個任務,即**列印**:`setTimeout1 2000s`
* 13.所有的`微任務`、`巨集任務`執行完畢,程式碼結束
最終的列印順序:
```!
script start
同步程式碼執行: new Promise
script end
巨集任務執行:setTimeout2 0ms
巨集任務執行:setTimeout3 1000ms
微任務執行:promise resolve
巨集任務執行:setTimeout1 2000ms
```
瀏覽器在驗證一下:
![](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/072a866ea7074aae8e7ab45c76270159~tplv-k3u1fbpfcp-watermark.image)
# setImmediate和setTimeout 0
關於`setImmediate`的作用 [MDN Web Docs](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/setImmediate) 是這樣介紹的:
![](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/1794bf5659184c2a93555b67940afe4e~tplv-k3u1fbpfcp-watermark.image)
從上面的描述我們可以獲取到兩個有用資訊:
* 1.該方法是非標準的,目前只有最新版的`IE`和`Nodejs 0.10+`支援
* 2.該方法提供的回撥函式會在瀏覽器完成後面的其他語句後立即執行
關於第一點非常好理解,我自己也做過嘗試,確實只有`IE10`以及`更高的版本`才能使用;
而第二點說的有點含糊,我個人理解為`setImmediate`的回撥應該是在`JS`引擎執行完所有的同步程式碼以後立即執行的。
那不管如何理解,我們在`IE`瀏覽器中試試應該能得出更準確的結論。
> 以下所有的示例均在`IE11`中進行測試
#### 示例一
首先是一個最簡單的示例:
```javascript
console.log('script start');
setImmediate(function(){
console.log('巨集任務執行:setImmediate');
})
console.log('script end');
```
這段程式碼的輸出順序如下:
![](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/99ece317068645be9fd4da85bb078d3b~tplv-k3u1fbpfcp-watermark.image)
從這個示例的結果可以看到`setImmediate`的回撥函式確實是在同步程式碼執行完成後才執行的。這個結果能說明前面的理解是正確的嗎?
先不要著急,我們在來看一個示例。
#### 示例二
```javascript
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
```
但是瀏覽器的輸出結果並不是這樣的:
![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/34a73557aa6345e9a5460373868135aa~tplv-k3u1fbpfcp-watermark.image)
#### 示例三
```javascript
console.log('script start');
setTimeout(function(){
setImmediate(function(){
console.log('巨集任務執行:setImmediate');
})
setTimeout(function(){
console.log('巨集任務執行:setTimeout 0');
},0)
}, 0)
console.log('script end');
```
在這個例子中,我們將`setImmediate`和`setTimeout 0`是巢狀在非同步任務`setTimeout`的裡面,並且外層的`setTimeout`設定的時間是`0ms`。
然而令人困惑的是這段程式碼在`IE`瀏覽器中的輸出結果是不確定的:
![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0a4ed7bbc6954f739d77a62b43c0a884~tplv-k3u1fbpfcp-watermark.image)
> 以上是多次重新整理頁面的輸出結果
經過以上三個示例,關於`setImmediate`和`setTimeout 0`兩者的執行時機貌似得不出什麼合適的結論,所以這個問題先不做總結,後續在研究吧~
# setTimeout 0 和setTimeout 1
在學習這個的時候看到一個特別有意思的程式碼:
```javascript
setTimeout(function(){
console.log('巨集任務執行:setTimeout 1ms');
}, 1)
setTimeout(function(){
console.log('巨集任務執行:setTimeout 0ms');
}, 0)
```
如果按照事件迴圈機制的說法,理論上以上的程式碼輸出結果為:
```!
script start
script end
巨集任務執行:setTimeout 0
巨集任務執行:setTimeout 1
```
這段程式碼在`Firefox`和`IE`中確實是前面我們推測出來的結果:
![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e456f22c1e6343d695f8c58ee9169d3b~tplv-k3u1fbpfcp-watermark.image)
但是在`Chrome`中卻是下面這樣的結果:
![](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/37ed9711132b4e608c37d87d603abd69~tplv-k3u1fbpfcp-watermark.image)
`Chrome`瀏覽器的輸出結果貌似有點違背前面總結的`事件迴圈機制`,但是實際上並沒有,因為我們有一句非常重要的話:`當非同步任務到達觸發條件以後將非同步任務的回撥函式推入任務佇列`。
所以對於`setTimeout 1`是在`1ms`後將回調函式推入`任務佇列`,`setTimeout 0`則是立即將回調函式推入`任務佇列`,然而`setTimeout 1`在`setTimeout 0`的前面,執行完`setTimeout 1`以後,當執行`setTimeout 0`的時候`1ms`的時間已經過去了,那這個時候`setTimeout 1`的回撥函式就比`setTimeout 0`的回撥函式先壓入任務佇列,所以就會出現`Chrome`中的列印結果。
## Ajax和Dom事件的疑惑
前面我們在對`JS`中的任務分類時,對`Ajax`和`Dom`事件並沒有進行歸類,一個原因是發現很多文章並沒有對這兩個任務進行分類,也有很多文章對這兩個任務的分類都不一致;另外一個原因就是我自己也沒有找到一些合適的例子去證實。
不過關於事件迴圈機制 `HTML Standard`有關於 [Event Loop](https://html.spec.whatwg.org/multipage/webappapis.html#event-loops) 的介紹,不過介於全篇是純英文的,簡單看過之後只`get`到了下面的這些有效資訊:
![](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c72737b8a9354f748ce9abbdbc9b838d~tplv-k3u1fbpfcp-watermark.image)
在經過翻譯和解讀以後,得出來下面這些資訊。
#### 每一個任務都有相關的任務源
```javascript
function fn(){ }
setTimeout(fn, 1000)
```
在上面的例子中`fn`就稱為是`setTimeout`的`回撥函式`,`setTimeout`就稱為該`回撥函式`的任務源。
推入`任務佇列`的是對應的`回撥函式`,執行`回撥函式`的時候可以稱為在執行`任務`。所以在該示例中就可以說`任務fn`對應的`任務源`就是`setTimeout`。
#### 瀏覽器會根據任務源去分類所有的任務
這個就是前面我們第二節中總結的`JavaScript中任務的分類`。
#### 瀏覽器有一個用於滑鼠和按鍵事件的任務佇列
關於這個說法的完整翻譯為:`瀏覽器可以有一個用於滑鼠和按鍵事件的任務佇列(與使用者互動任務源關聯),另一個與所有其他任務源關聯。然後,使用在事件迴圈處理模型的初始步驟中授予的自由度,它可以使鍵盤和滑鼠事件優先於其他任務四分之三的時間,從而保持介面的響應性,但不會耗盡其他任務佇列`。
看完這段話,我會理解`DOM事件`是不是區別於前面我們說的`巨集任務`、`微任務`?
總而言之呢,關於`Ajax`和`Dom`事件到底是屬於`微任務`還是`巨集任務`?以及它們兩個和其他非同步任務共同存在時的執行順序,我自己還是存疑的,所以就不給出什麼結論了,以免誤導大家。當然如果大家有明確的結論或者示例,歡迎提出來~
# 總結
到此本篇文章就結束了,有關瀏覽器中的事件迴圈機制就我們總結的兩個核心點:`JavaScript任務分類`和`任務執行順序`。
只要牢牢掌握這兩點,就能解決大部分問題。
然而本篇文章還遺留了兩個問題:
* 瀏覽器中`setTimeout 0` 和 `setImmediate`執行順序
* `ajax`和`dom`事件是`巨集任務`還是`微任務`
年後有時間在覆盤總結這兩個問題吧。
最後提前祝大家在新的一年好運哦~
# 近期文章
[詳解Vue中的computed和watch](https://juejin.cn/post/6917805693860839431)
[記一次真實的Webpack優化經歷](https://juejin.cn/post/6908897055599509512)
[JavaScript的執行上下文,真沒你想的那麼難](https://juejin.cn/post/6901107803696398349)
# 寫在最後
如果這篇文章有幫助到你,❤️關注+點贊❤️鼓勵一下作者
文章`公眾號`首發,關注 [`不知名寶藏程式媛`](https://mmbiz.qpic.cn/mmbiz_gif/I4j8PCMjMMhl5J9MoqaIsAAeJVfMqYibiaJWpspxGicRiczx0xib35DLPlXvOd6amGPoLxLfnbERpC5TIPDgFBwc8gQ/640?wx_fmt=gif&tp=webp&wxfrom=5&wx_lazy=1) 第一時間獲取最新的文章
筆芯❤️~
![](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7f8160aea85844ba97043844cf574597~tplv-k3u1fbpfcp-watermar