事件循環
事件循環與瀏覽器有關,需要先了解其進程模型。
瀏覽器的進程模型
進程
程序運行需要其專屬的內存空間,用于存儲變量、執行函數等操作,可以將這塊內存空間簡單地理解為進程。
每個應用至少有一個進程,進程之間相互獨立,即使要通信,也需要雙方同意。

線程
有了進程后,就可以運行程序的代碼了,由線程運行代碼。
一個進程至少有一個線程,在進程開啟后會自動創建一個線程來執行代碼,稱為主線程。
如果主線程結束了,那么進程就結束了。
如果程序需要同時執行多塊代碼,主線程就會啟動更多的線程來執行代碼,所以一個進程中可以包含多個線程。
瀏覽器的進程和線程
瀏覽器是一個多進程多線程的應用程序。
為了避免相互影響,為了減少連環崩潰的幾率,當啟動瀏覽器后,它會自動啟動多個進程。
現代瀏覽器已經非常復雜了,復雜程度在向操作系統靠近。

啟動chrome瀏覽器,打開其任務管理器:
可以發現盡管沒有訪問任何網頁,也有一些進程是自動啟動的。
打開一個新的標簽頁,這里打開了百度,可以發現不同的標簽頁屬于不同的進程。
其中,最主要的進程有:
-
瀏覽器進程
瀏覽器進程是最先啟動的進程,其它進程由它啟動。
主要負責界面顯示、用戶交互、子進程管理等。瀏覽器進程內部會啟動多個線程處理不同的任務。
這里的界面顯示不是指網頁的內容渲染,而是指瀏覽器的界面,比如瀏覽器的頭部:
用戶交互是指用戶在瀏覽器上的點擊、鍵盤、滾輪等操作,瀏覽器需要監聽這些用戶交互操作。
子進程管理包含網絡進程、渲染進程等等。
-
網絡進程
負責加載網絡資源。網絡進程內部會啟動多個線程來處理不同的網絡任務。
-
渲染進程
渲染進程啟動后,會開啟一個渲染主線程,主線程負責執行HTML、CSS、JS代碼。
默認情況下,瀏覽器會為每個標簽頁開啟一個新的渲染進程,以保證不同的標簽頁之間不相互影響。
這種模式可能會在以后的版本被替換掉,因為每個標簽頁都開啟一個新渲染進程會導致chrome成為“內存殺手”。——2023.10
Chromium Docs - Process Model and Site Isolation (googlesource.com)
??這個文檔提到了,以后可能會發展成“一個站點對應一個進程”。
例如:用戶在使用淘寶的時候,首先打開了淘寶首頁,然后搜索商品進入了商品搜索頁,點擊商品進入商品詳情頁,盡管有三個標簽頁,但是都是同一站點,只對應一個進程。
當然,這是以后的發展規劃,了解即可。
關于瀏覽器的進程模型就介紹到這里,本文主要是寫事件循環,而事件循環就發生在渲染主線程中。
渲染主線程
渲染主線程是瀏覽器中最繁忙的線程,需要它處理的任務包括但不限于:
- 解析HTML:比如解析標記語言的語法。
- 解析CSS:比如解析樣式表的語法。
- 計算樣式:比如將
em,%等單位換算成px,以及重復的屬性如何考慮其優先級等等。 - 布局:每個元素的寬高、位置,都需要計算,統稱為幾何信息。
- 處理圖層:與
z-index有關,也和元素的內容與其background的繪制有關。 - 每秒把頁面繪制60次:FPS
- 執行全局JS代碼
- 執行事件處理函數
- 執行計時器的回調函數
- ......
為什么渲染進程不適用多個線程來處理這些事情?
答:
瀏覽器渲染進程通常是單線程的,這是因為多線程處理可能會引發很多問題。以下是一些可能的問題:
競態條件:多線程處理會導致訪問共享內存的競爭條件,可能導致數據不一致和死鎖等問題。
同步問題:多線程需要進行同步,避免數據競爭和死鎖,這會增加代碼的復雜度和開銷。
安全問題:多線程可能會存在安全漏洞,如數據泄露、內存溢出等問題。
性能問題:多線程處理可能會導致過多的上下文切換和內存消耗,從而降低程序的性能和穩定性。相比之下,單線程處理有以下優點:
簡單易用:單線程的處理方式更加簡單易用,開發人員不需要考慮多線程處理中的競態條件、同步問題和安全問題。
可靠穩定:單線程處理避免了多線程處理中的死鎖和資源爭用等問題,從而提高了程序的可靠性和穩定性。
高效節省:單線程處理可以避免多線程處理中的上下文切換和內存消耗等問題,從而提高了程序的性能和節省了系統資源。
既然主線程非常繁忙,那么首先需要解決的問題就是:應該如何調度任務?
比如:
- 正在執行一個JS函數,執行到一半的時候用戶點擊了按鈕,是否應該立即去執行點擊事件的處理函數?
- 正在執行一個JS函數,執行到一半的時候某個計時器到達了時間,是否應該立即去執行其回調?
- 當“用戶點擊事件”,與“計時器到達時間”同時發生,應該先處理哪個任務?
- ......
渲染主線程的處理方式是:排隊(消息隊列 Message queue)。


-
在最開始的時候,渲染主線程會進入一個無限循環;
可以在github上找到chromium的源碼,在消息隊列的相關實現代碼中可以看到一個無限循環:
chromium和chrome的關系:
- chrome是基于chromium的、功能更豐富的瀏覽器;
- chrome不開源,chromium開源。
- 每一次循環會檢查消息隊列中是否有任務存在。
- 如果有,就取出第一個任務執行,執行完一個后進入下一次循環;
- 如果沒有,則進入休眠狀態。
- 其它所有線程(包括其它進程的線程)可以隨時向消息隊列添加任務。新任務會加到消息隊列的末尾。在添加新任務時,如果主線程是休眠狀態,則會將其喚醒以繼續循環拿取任務。
這樣一來,就可以讓每個任務有條不紊地、持續地進行下去了。
整個過程,被稱之為事件循環(消息循環)。
- 在w3c的標準中被稱為:
event loop事件循環 - 在google的標準中被稱為:
message loop消息循環
回過頭來看上文提到的一個問題:
正在執行一個JS函數,執行到一半的時候用戶點擊了按鈕,是否應該立即去執行點擊事件的處理函數?
答:此時應該將點擊事件的處理函數推到消息隊列中,不影響JS函數的執行。
相關問題
異步是什么?
代碼在執行過程中,會遇到一些無法理解處理的任務,比如:
- 計時完成后需要執行的任務——
setTimeout、setInterval - 網絡通信完成后需要執行的任務——
XHR、Fetch - 用戶操作后需要執行的任務——
addEventListener
如果讓渲染主線程等待這些任務的時機到達,就會導致主線程長期處于阻塞的狀態,從而導致瀏覽器卡死。

如上圖,在主線程中使用setTimeout(fn, 3000)將回調函數與計時時長告知計時線程,計時線程開始計時等待。
此時,如果主線程跟隨一起等待,則是同步,唯一的好處是時間線同步,但是好處遠遠小于壞處,非常浪費時間。
計時線程的底層實現其實是調用了操作系統提供的接口。其實現十分復雜。
渲染主線程承擔著極其重要的工作,無論如何都不能阻塞。
如果因為上述三種任務阻塞了主線程,那么消息隊列中可能存在著渲染任務無法執行,也可能用戶觸發了點擊事件,但是沒有被執行,從用戶角度來說就是“頁面卡死了”。
因此,瀏覽器選擇了異步來解決這個問題。

如上圖,主線程在調用了setTimeout之后,其實只是起到了一個預約的作用,最后是由計時線程將任務交給消息隊列。
使用異步的方式,渲染主線程不會阻塞,不斷地拿取消息隊列里的任務去執行,遇到計時器、網絡請求、事件監聽,則交給其它線程,渲染主線程從消息隊列中獲取下一個任務繼續工作。
面試題:如何理解JS的異步?
參考答案:
JS是一門單線程的語言,這是因為它運行在瀏覽器的渲染主線程中,而渲染主線程只有一個。
而渲染主線程承擔著諸多的工作,渲染頁面、執行JS都在其中運行。
如果使用同步的方式,就極有可能導致主線程產生阻塞,從而導致消息隊列中的很多其他任務無法得到執行。
這樣一來,一方面會導致繁忙的主線程白白的消耗時間,另一方面導致頁面無法及時更新,給用戶造成卡死現象。
所以瀏覽器采用異步的方式來避免。具體做法是當某些任務發生時,比如計時器、網絡、事件監聽,主線程將任務交給其他線程去處理,自身立即結束任務的執行,轉而執行后續代碼。當其他線程完成時,將事先傳遞的回調函數包裝成任務,加入到消息隊列的未尾排隊,等待主線程調度執行。
在這種異步模式下,瀏覽器永不阻塞,從而最大限度的保證了單線程的流暢運行。
JS為何會阻礙渲染?
首先看示例代碼:
btn.onclick = function(){
// 修改DOM節點的內容
p.innerText = "hello world";
// 模擬很耗時長的JS代碼,這里等了3秒
start = Date.now()
while(Date.now()-start<3000){}
}
- 錯誤的理解:點擊按鈕之后,先修改DOM節點文本,再等待3秒;
- 實際現象:點擊按鈕之后,會先阻塞等待3秒,然后才能看到DOM節點的文本更新。
具體的流程如下:
- 首先,主線程解析全局JS代碼,解析到這一段代碼的時候,檢測到是事件綁定,將函數轉交給交互線程。
- 交互線程監聽按鈕點擊,當監聽到按鈕被點擊之后,將函數包裝成任務推送到消息隊列尾部。
- 當其它任務被渲染主線程執行完成之后,該任務被主線程獲取,執行內部的代碼。
- 修改DOM節點的操作,屬于繪制任務,會被包裝成任務推送到消息隊列尾部。
- 與此同時,上述代碼的這個任務還在執行中,while語句阻塞3秒。
- 3秒之后,該任務執行完成。渲染主線程繼續從消息隊列中獲取任務,當獲取到繪制任務并執行的時候,界面才會更新。
簡單的理解:
-
在瀏覽器上,JS解析和頁面渲染都在渲染主線程上被執行,同一時間只能完成一個任務。
-
在JS中生成的渲染任務,需要JS執行完成之后才會被更新到頁面上。
任務有優先級嗎?
任務沒有優先級,在消息隊列中先進先出。但消息隊列是有優先級的。
根據W3C的最新解釋:
-
每個任務都有一個任務類型,同一個類型的任務必須在一個隊列,不同類型的任務可以分屬于相同的隊列(即一個隊列可以用于存放多種類型的任務)。
在一次事件循環中,瀏覽器可以根據實際情況從不同的隊列中取出任務執行。不同瀏覽器或者同一瀏覽器的不同版本的做法可能不同。
在chromium的源碼中,我們可以找到??task_type.h,其中聲明了許多任務類型:
-
瀏覽器必須準備好一個微隊列(microtask queue),微隊列中的任務優先所有其它任務執行。
隨著瀏覽器的復雜度急劇提升,W3C不再使用宏隊列的說法。
在目前chrome的實現中,至少包含了下面的隊列:
- 延時隊列:用于存放計時器到達后的回調任務,優先級中;
- 交互隊列:用于存放用戶操作后產生的事件處理任務,優先級高;
- 微隊列:用戶存放需要最快執行的任務,優先級最高。
添加任務到微隊列的主要方式主要是使用
Promise、MutationObserver例如:
// 立即把一個函數添加到微隊列 Promise.resolve().then(函數)
瀏覽器還有很多其它的隊列,與前端的開發關系不大。
面試題:闡述一下 JS 的事件循環
參考答案:
事件循環又叫做消息循環,是瀏覽器渲染主線程的工作方式。
在 Chrome 的源碼中,它開啟一個不會結束的for循環,每次循環從消息隊列中取出第一個任務執行,而其他線程只需要在合適的時候將任務加入到隊列未尾即可。
過去把消息隊列簡單分為宏隊列和微隊列,這種說法目前已無法滿足復雜的瀏覽器環境,取而代之的是一種更加靈活多變的處理方式。
根據W3C官方的解釋,每個任務有不同的類型,同類型的任務必須在同一個隊列,不同的任務可以屬于相同的隊列。不同任務隊列有不同的優先級,在一次事件循環中,由瀏覽器自行決定取哪一個隊列的任務。但瀏覽器必須有一個微隊列,微隊列的任務一定具有最高的優先級,必須優先調度執行。
面試題:JS 中的計時器能做到精準計時嗎?為什么?
參考答案:
不行,因為:
- 計算機硬件沒有原子鐘,無法做到精確計時;
- 操作系統的計時函數本身就有少量偏差,由于 JS 的計時器最終調用的是操作系統的函數,也就攜帶了這些偏差;
- 按照W3C的標準,瀏覽器實現計時器時,如果嵌套層級超過5層,則會帶有4毫秒的最少時間,這樣在計時時間少于4毫秒時又帶來了偏差;
- 受事件循環的影響,計時器的回調函數只能在主線程空閑時運行,因此又帶來了偏差。
補充:
-
setTimeout和setInterval的最終實現調用的操作系統的API,不同的操作系統的實現不同。 -
setTimeout嵌套層級(5層)與4毫秒的知識點:在chromium關于計時器的源碼中可以找到:
根據w3c的標準,當嵌套層級超過五層,定時器的時間如果低于4ms,會被提升至4ms。
例題
可以在草稿紙上寫下渲染主線程、微隊列、延時隊列,并模擬任務在其中的變化。
例題1:下述代碼的輸出順序是?
function a(){
console.log(1);
Promise.resolve().then(function(){
console.log(2);
});
}
setTimeout(function(){
console.log(3);
Promise.resolve().then(a);
}, 0);
Promise.resolve().then(function(){
console.log(4);
});
console.log(5);
例題 1 答案:
5
4
3
1
2
例題2:下述代碼的輸出順序是?
function a(){
console.log(1);
Promise.resolve().then(function{
console.log(2);
});
}
setTimeout(function(){
console.log(3);
}, 0);
Promise.resolve().then(a);
console.log(5);
例題 2 答案:
5
1
2
3
總結
- 單線程是異步產生的原因;
- 事件循環是異步的實現方式。

浙公網安備 33010602011771號