先簡單聊一聊
JavaScript中的“Event Loop”應該是JavaScript中一個非常重要的一個知識點,至少在我以往的面試程序中被問到很多次,初次了解“Event Loop”相關知識的時候我想好多同學,特別是初學或者經驗不是太豐富的同學大概是這樣一個程序:(實際上,不僅僅限于“Event Loop”稍微有深度的知識也大概是這樣)
- 打開瀏覽器,跳轉到百度
- 搜索關鍵字Event Loop
- 點擊第一條搜索結果
- 下拉滾動條,目測文章長度(此處有可能直接跳到第五步),試著閱讀幾分鐘
- 當頭皮感到發麻時,關閉頁面,關閉瀏覽器, 整個學習程序結束,Cool!
言歸正傳,的確我們可能會被那些枯燥無味的概念、原理和理論知識所擊敗,越往下讀越覺得枯燥,概念性的描述無法像代碼那樣直觀的理解一個知識點,但是JavaScript中代碼是怎樣正確執行的恰好就需要“Event Loop”機制做支撐,所以想要完整的掌握這個知識點,確實需要耐心的了解各種涉及到的理論知識,當把一些概念理解清楚時,我們才能懂得JavaScript Runtime是如何實作”Event Loop“,屆時,我們才能夠完成從“看懂代碼”--->”代碼是如何執行“這個程序的轉化,
了解完JavaScript Runtime中“Event Loop”的運行機制,我們可以很好的理解以下幾個問題:
- 當用戶點擊頁面上按鈕到發出回應這個程序中JavaScript做了哪些作業
- setTimeout(callback)是如何作業的
- 某些情況下可能引發頁面block的一些產生原因
- JavaScript中的異步是如何實作的
OK,讓我們來一步步探究這些問題,
首先要了解的幾個問題
- 瀏覽器的多行程和多執行緒
- 常說的JavaScript單執行緒是何含意
- 為什么JavaScript必須單執行緒執行
瀏覽器的多行程和多執行緒
瀏覽器的多行程
瀏覽器也是一個應用程式,當打開瀏覽器時,OS就會為其分配虛擬地址空間,創建對應的行程,我們打開一個瀏覽器,并不是創建了一個行程而是多個,
一般地,瀏覽器的主要行程包含以下幾個:
①Browser行程 瀏覽器的主行程,我們簡單的理解為它是一個“Leader”統領著各項作業,如:瀏覽器界面顯示,頁面管理等等,
②GPU行程 顧名思義是負責圖形繪制渲染的作業,
③插件行程
④渲染行程 我們重點關注下這個,渲染行程即瀏覽器內核,主要用于控制頁面渲染、JavaScript執行、各種event處理等等,
當我們每打開一個Tab時,就會對應創建新的渲染行程(當Tab是空白時,也就是沒有打開任何網頁的時候,會做優化,不會單獨開辟新的行程),
每個Tab在單獨的行程中運行,很好理解,因為每個行程具有獨立的地址空間,一個行程不能訪問另一個行程的代碼和資料,也不能直接的操作OS內核的代碼和資料,在瀏覽器的層面去看,那就可以避免一個Tab中訪問另一個Tab內的資料,同時當一個Tab發生崩潰時,也不會去影響其他Tab,
比如:打開一個Tab的console輸入”while(true){}“,顯然當前Tab顯示的頁面會被block,你無法去點擊和選中頁面上的任何內容,當你切換到其他Tab時,完全沒有受到影響,
瀏覽器內核中的多執行緒
我們都知道,執行緒是隸屬行程之中的,執行緒間共享行程中分配的資源,那么瀏覽器內核或者說是渲染行程中都會維護一組執行緒來進行作業,主要包含以下幾個執行緒:
- GUI渲染執行緒 主要負責頁面的渲染,包括決議Html決議和計算CSS,構建渲染樹,布局和繪制,渲染時也會維護一個Queue,我們后面再去展開討論,
- JavaScript引擎執行緒 顧名思義,決議JavaScript,所有的JavaScript相關代碼都會在JavaScript引擎上去執行,后續詳細討論JavaScript引擎執行緒,
- 事件觸發執行緒 當對應的事件觸發,比如用戶點擊、發送Ajax等等,事件觸發執行緒會把對應的事件的callback加入到Tasks Queue中,等到JavaScript執行緒Stack清空時,會把事件對應的callback壓入JavaScript引擎執行緒Stack中去執行,
- Http請求執行緒 通過瀏覽器發送Http請求,當請求的Status發送變化時,比如請求成功或者請求失敗,會將對應的callback加入到Tasks Queue中,等到JavaScript執行緒Stack清空時,會把事件對應的callback壓入JavaScript引擎執行緒Stack中去執行,
- 定時觸發器執行緒 因為JS引擎是單執行緒的,如果我們把定時器的執行程序直接放在JS引擎中處理,當遇到Stack中有大量Function堆積時,那么定時器或者延時器的計時是不準確的,現在當我們觸發setTimeout之后,事件觸發執行緒會將對應的任務單獨添加到定時器觸發執行緒中去執行等待,當等待結束后,會將對應的callback加入到Tasks Queue中,等到JavaScript執行緒Stack清空時,會把事件對應的callback壓入JavaScript引擎執行緒Stack中去執行,
常說的JavaScript單執行緒是何含意
當我們接觸JavaScript這門語言的時候,我們或多或少都聽過或者看到過“JavaScript是執行緒的”、“JavaScript語言是單執行緒的”這樣的話,那么到底該如何去理解呢?我覺得不能說JavaScript這門語言設計是單執行緒的,這句話原本就讓人讀起來就有點別扭,“執行緒”這個術語就不屬于“JS語言”的單獨范疇,通過前面的描述我們都知道,JS的決議和運行是在JS引擎執行緒中進行的,JS引擎執行緒存在且唯一,所以我覺得正確的理解應該是“JavaScript是單執行緒運行的“或者說是”JavaScript在瀏覽器中運行是單執行緒的”,這與它的運行環境有關,
為什么JavaScript必須單執行緒執行
JavaScript主要職責就是處理頁面與用戶的互動、操作DOM樹、操作CSS樣式以及相關邏輯處理,我們以操作DOM來說,假設有兩個執行緒同時去操作一個DOM元素,那么這個DOM元素就會成為執行緒間競爭的資源,我們就需要去處理執行緒同步,那么事情將變得一步步復雜起來,所以JavaScript要單執行緒執行,即使后續引入Web Worker來提高CPU的利用率,子執行緒受控于主執行緒,子執行緒依然不能操作DOM元素,
JavaScript引擎執行緒
Ok,JS引擎執行緒跟其他執行緒一樣,有一個分配記憶體空間的共享堆和一個堆疊,堆疊中存盤傳遞給方法的區域變數、實參以及記錄堆疊中方法執行位置的一個地址,
我們簡單來說一下,JS中方法是如何在Call Stack中執行的,
function func1() {
return func2();
}
function func2() {
return func3();
}
function func3() {
console.log('over!');
}
func1();
我想上述的程序都不陌生,至少在軟體工程課上好多同學都畫過,整個執行程序很簡單,當有方法需要執行時入堆疊,執行完畢出堆疊,直到執行堆疊清空,如果遇到遞回沒有出口時,整個執行堆疊將會迅速被壓滿溢位,瀏覽器會拋出如下例外:

Event Loop
當我們使用XMLHTTPRequest發送一個請求時,我們依舊可以在頁面上選擇文本;當我們設定一個setTimeout后,頁面并沒有發生阻塞,而是在某個地方為我們默默地倒計時;既然我們前文已經說明了Javascript是單執行緒運行的,那上述的情況背后又是如何作業的呢?我們的JavaScript代碼能夠按照某種“特定”的順序執行,實際上就是“Event Loop”機制在起到關鍵性作用,
簡單認識下“Event Loop”
"Event Loop"一般我們稱之為“事件回圈”(也有稱之為“事件輪詢”),是一種以事件驅動為思想的執行模型或者運行機制,不同JavaScript運行環境對“Event Loop”機制有不同的具體實作,比如”瀏覽器環境“、”Node環境“等等,HTML5標準規范中關于“Event Loop”的相關介紹
后續所討論的“Event Loop”的相關內容,都是基于瀏覽器運行環境下實作的,
我們圍繞以下幾個話題進行討論:
- Call Stack
- Queue
- Event loop的整個事件驅動流程
Call Stack
實際上前文我們已經圖文并茂的介紹了JS引擎執行緒中的Call Stack,當一個個function被呼叫時,按照順序入堆疊執行,執行完畢之后依次出堆疊,我們的JavaScript同步代碼直接在呼叫堆疊中執行,如:變數賦值,console.log等等,異步代碼如setTimeout、send XMLHttpRequest則在呼叫堆疊中觸發,然后呼叫瀏覽器的某個webapi將對應的任務放到某個地方(background threads)中去執行,待執行完畢或者達到某種狀態時將該任務對應的callback加入到某個對應佇列中,待callstack清空后,對應的callback入堆疊執行,
Queue
佇列,Queue是“Event Loop”機制中一個非常重要的組成部分,里面存盤了對應task的callback,當call stack清空時,會讀取任務佇列中的callback加入到stack中等待執行,
實際上,整個運行機制中一共維護了三個佇列,分別是:
- Macro queue / Tasks queue 宏佇列
- Micro queue 微佇列
- Render queue / Animation callbacks 渲染佇列
三個不同的queue有著不同的讀取優先級,同時在每輪“Event Loop”中不同佇列中的callback入堆疊的情況也是不同的,
Macro queue / Tasks queue 宏佇列
以下幾種任務會進入宏佇列:
- setTimeout
- setInterval
- 事件觸發
- I/O
Micro queue 微佇列
以下幾種任務會進入宏佇列:
- Promise
- MutationObserver
Render queue / Animation callbacks 渲染佇列
RAF(requestAnimationFrame)回呼及Render流程
需要注意的點:
- 無論是哪個佇列都必須等到堆疊清空后才能讀取佇列中的callback入堆疊執行
- 三個佇列的優先級分別是:微佇列 > 渲染佇列 >宏佇列
- 若Micro queue不為空,每輪事件回圈會依次將Micro queue中的所有callback入堆疊執行直到Micro queue清空,若期間又有新的微任務產生,則會繼續入佇列,等待壓入堆疊中執行,直到Micro queue佇列清空,進入下一個流程,若微任務的生成速度大于微任務的執行速度,那么Micro queue的出佇列入堆疊程序將一直持續下去,阻塞整個“Event Loop”
- 若Render queue / Animation callbacks不為空,每輪事件回圈會依次將Render queue中的所有callback入堆疊執行直到Render queue清空,若回呼中有新的RAF回呼,則會進入Render queue在下次事件回圈中執行
- 若Tasks queue不為空,每輪事件回圈從Tasks queue中讀取隊頭的callback入堆疊執行,期間新添加的task從Tasks queue隊尾進佇列等待后續事件回圈執行
Event loop完整流程
- 同步代碼在call stack中執行,直到所有同步代碼執行完畢,stack清空,(同步代碼并不說明等同于同步任務,比如同步設定一個4s的setTimeout,實際等待程序是異步的;期間根據不同的任務會加入到不同的佇列中,比如Promise、sendHttp分別進入到微佇列和宏佇列中)
- 讀取微佇列micro queue,若微佇列長度不為0,則從隊首依次讀取隊中元素到stack中執行callback,直到微佇列清空,此步驟才算結束;若期間微任務不斷產生則一直持續入堆疊執行,
- 微佇列處理完成后,讀取render queue或者是RAF callbacks,若渲染佇列長度不為0,則從隊首依次讀取隊中元素到stack中執行callback,直到渲染佇列清空,此步驟才算結束;若期間又有RAF回呼不斷產生,則放到下一輪“Event Loop”執行,【RAF callback是在每次重繪(Style、Layout、Paint)之前更新影片】
- 最后讀取宏佇列macro queue,若微佇列長度不為0,則只讀取隊首元素到stack中執行,queue長度-1,期間有新的宏任務產生則入佇列,等待后續“Event Loop”執行,
- 等待stack中function執行完畢清空,本輪“Event Loop”結束,
- 回圈往復Step2-5
至此,根據上述大篇幅的介紹和分析,我想現在我們應該可以很好的理解上述我畫的這幅圖,
關于setTimeout的探討
- setTimeout(fn,0)的分析
- setTimeout并沒有想象的那么“準確”
- setTimeout非阻塞
setTimeout(fn,0)的分析
首先我們要知道,setTImeout、setInterval是瀏覽器提供給我們的webapi(Web API 介面參考),我們可以用JavaScript代碼去呼叫這些api進行使用,
那么我們先看下面的代碼片
console.log('hello')
setTimeout(function () {
console.log('timer')
}, 0)
console.log('js')
最最開始的時候學習JS的時候,對“setTimeout”的定義應該是“在n毫秒之后執行fn”,那么就覺得第二引數傳0應該就是立即執行啊,實則不然,根據我們前面所講述的,應該很容易分析出答案,

我把上述代碼的運行程序再次畫出來,進一步讓大家理解的更加清晰一點,
所以說setTimeout(fn,0)不能準確的表示fn立即執行,而表示盡快的執行,
因為setTimeout會創建一個異步任務,等待結束后callback加入tasks queue等待入堆疊執行,而tasks queue入堆疊的前提就是stack必須為空,所以即使設定為0,當有大量同步代碼在主執行緒中執行時,就必須等它們執行結束,stack清空后才能去執行setTimeout的回呼,
要強調的是:即使setTimeout的ms引數設定為0,在w3c的標準規范下,也會延長到4ms左右(大概是4.7ms),
setTimeout并沒有想象的那么“準確”
setTimeout(function () {
console.log('s1')
}, 1000)
setTimeout(function () {
console.log('s2')
}, 1000)
setTimeout(function () {
console.log('s3')
}, 1000)
上述代碼設定了三個延時器,那么根據我們上述的分析很容易知道,這三個宏任務會并行等待1s后依次加入tasks queue,又根據我們前文所說的tasks queue的入堆疊執行情況(每次讀取隊頭入堆疊執行)可知,callback2入堆疊執行必須等待callback1執行完畢堆疊清空,callback3入堆疊執行必須等待callback2執行完畢堆疊清空,那么callback1在執行時callback2只能等待,那么對于第二個setTimeout來說,整個程序肯定不止等了1000ms,第三個setTimeout則等了更長,
所以,“setTimeout(fn,n)”應該是fn最快在n毫秒后執行,
setTimeout非阻塞
<button id="btn" style="width: 300px;height: 50px;">while(true)</button>
<script>
document.querySelector('#btn').addEventListener('click',function(){
while(true){}
})
</script>
點擊按鈕后,頁面block,文字不能選中,按鈕不能點擊,因為同步代碼引發死回圈,stack一直不為空,阻塞了UI render,
<button id="btn" style="width: 300px;height: 50px;">while(true)</button>
<script>
document.querySelector('#btn').addEventListener('click', function () {
let foo = () => {
setTimeout(foo, 0);
};
foo();
})
</script>

貌似看來這段代碼也是一個遞回導致的死回圈,但是當我們點擊按鈕之后,頁面就好像什么都沒有發生一樣,并不會阻塞頁面渲染,文字可以選中,按鈕可以正常點擊,
實際上我們稍加分析便知,前文說明了佇列讀取的優先級,渲染佇列的讀取優先級是高于宏佇列的,所以雖然有源源不斷的宏任務產生,入佇列然后入堆疊執行,但整個程序中如果遇到UI Render要執行(60HZ螢屏,一般是16.67ms render一次),則UI Render正常立即執行,所以整個程序并不阻塞頁面渲染,
“Event Loop”對頁面渲染可能帶來的影響
盡量不要阻塞“Event Loop"
我們繼續將上述案例給改寫成如下形式:
<button id="btn" style="width: 300px;height: 50px;">while(true)</button>
<script>
document.querySelector('#btn').addEventListener('click', function () {
let foo = () => {
Promise.resolve().then(foo)
}
foo()
})
</script>
當我們點擊按鈕發現頁面被block,文字無法選中,按鈕無法點擊,在我們前面的分析得出,不斷產生任務不是沒有阻塞JS執行緒的stack嗎?怎么還是會阻塞頁面渲染?
因為Promise是會產生微任務,進入微佇列,我們也說過微佇列的讀取順序的優先級是最高的在渲染之前,同時也說過微佇列在讀取時一直會把佇列清空后才能進入下一環節,上述案例顯然微任務在不斷產生,micro queue永遠不能被清空,一直在阻塞整個“Event Loop”,導致后續UI Render不能被執行,頁面不能重繪,
綜合案例
案例一
el.addEventListener('click', () => {
console.log(1);
new Promise((resolve, _) => {
console.log(2);
resolve()
}).then(() => {
console.log(3);
})
})
el.addEventListener('click', () => {
console.log(4);
setTimeout(() => {
console.log(5);
}, 0);
Promise.resolve().then(() => {
console.log(6);
})
})
頁面手動點擊按鈕后,console該如何輸出?
請先認真思考一下再看正確結果,

應該很容易理解上述輸出,我們簡單理一下執行程序,
首先同步代碼邏輯:
1.獲取dom元素
2.為按鈕添加一個事件監聽listener1
3.為按鈕添加一個事件監聽listener2
按鈕點擊:
目前tasks queue分別存放listener1的 callback、listener2的 callback;micro queue length = 0
- 首先讀取micro queue為空,讀取task queue,取隊頭callback1入堆疊執行
- log(1)執行->出堆疊 輸出 1
- 設定Promise,同步執行log(2),log出堆疊[Promise中then callback、catch callback才是異步回呼的],同時向micro queue 中加入promise callback1,此時堆疊清空,輸出 2
- 堆疊清空,優先讀取micr oqueue,log(3)入堆疊執行,log(3)出堆疊,此時堆疊清空,輸出 3
- 堆疊清空,micro queue為空,tasks queue中隊頭為listener2的 callback,入堆疊執行,tasks queue清空,
- log(4)入堆疊執行,log(4)出堆疊,設定setTimeout,tasks queue中添加setTimeout callback,輸出 4
- 設定Promise,micro queue中添加promise callback2,listener2的callback執行完畢,此時堆疊清空,
- 優先讀取micro queue,promise callback2入堆疊執行,log(6)入堆疊,log(6)執行完畢出堆疊,輸出 6
- 再讀取tasks queue,setTimeout callback入堆疊執行,log(5)入堆疊,log(5)執行完畢出堆疊,輸出 5
故以上代碼片輸出順序為: 1 2 3 4 6 5
案例二
const el1 = document.querySelector('#btn1')
el.addEventListener('click', () => {
console.log(1);
new Promise((resolve, _) => {
console.log(2);
resolve()
}).then(() => {
console.log(3);
})
})
el.addEventListener('click', () => {
console.log(4);
setTimeout(() => {
console.log(5);
}, 0);
Promise.resolve().then(() => {
console.log(6);
})
})
el.click();
請先認真思考一下再看正確結果,

乍一看,代碼邏輯好像是沒有發生變化,一個是手動點擊按鈕,一個是JS代碼觸發點擊事件,那么為什么輸出結果就發生變化了呢?
對比完兩個輸出結果可以發現,listener1 callback同步代碼執行完之后,立即執行了listener2 callback而不是micro queue,
是因為,在讀去任何佇列的前提一定是呼叫堆疊為空,那么最后一句el.click()始終在呼叫堆疊上還未出堆疊,所以說listener1 callback同步代碼執行完之后不能立即讀取佇列,而應該繼續執行click觸發的listener2,然后繼續執行,直到listener2 callback執行完畢后,click出堆疊,開始讀取佇列,目前micro queue中有兩個元素:promise1 callback、promise2 callback,tasks queue中有一個元素:setTimeout callback,按照“Event Loop”的執行順序最后的輸出結果為:1 2 4 3 6 5
寫在最后
至此,基本上把瀏覽器的“Event Loop”實作給闡述清楚了,從瀏覽器的多行程、多執行緒到JS引擎中的堆疊呼叫再到”Event Loop“的引出,同時又講述了”setTimeout的那些事“、阻塞”Event Loop“可能帶來的不良影響等問題,在一些原理性較深,較難理解的問題,我都通過手工繪圖的方式復現整個執行程序可視化的分析,幫助大家快速理解相關的問題,最后給出了兩個綜合案例幫助大家進一步鞏固”Event Loop“的運行機制,循序漸進,希望能從淺入深把”Event Loop“這個重要的知識點牢牢掌握,花了數天時間推敲和揣摩這篇文章,整個寫作程序也讓自己對這個知識點掌握的更加牢固,同時也希望能給大家帶來幫助,我相信如果能夠耐心把這篇文章看完,我相信多多少少都會有自己的識訓,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qianduan/305711.html
標籤:其他
