從一個案例,細說瀏覽器的事件迴圈

2023-12-04 18:01:31

我們知道,使用者鍵盤輸入的事件有3個:keydown、keypress、keyup。可這三位各有各的缺點,沒一個讓人省心的。

keypress,無法拿到使用者最新的輸入值,在輸入中文時還不觸發。keyup,能拿到最新輸入值了,但已經無法通過 preventDefault() 阻止輸入。

比如這個場景:把使用者輸入的小寫字母即時的轉換成大寫

我們分別在keypress和keyup的監聽函數裡執行:this.value = this.value.toUpperCase(),得到的畫面是這樣的:

 

keypress:  keyup:

 

 

可以看到,keypress無法轉換最後一個字母,keyup有一個從小寫跳到大寫的動畫,體驗並不好。

我終於在阮一峰老師的《Javascript教學》裡看到了完美的解決方案:

<input type="text" id="haha" />
<script>
	document.getElementById('haha').onkeypress = function(e) {
		setTimeout(() => {
			this.value = this.value.toUpperCase();
		}, 0);
	};
</script>

好了,本文到此為止!(狗頭)

問題已經解決了,可如果我們就此打住,我們的收穫也僅僅是這個問題而已。我們必須弄明白:為什麼會這樣?

帶著這個疑問,我們把瀏覽器的事件迴圈好好捋一下:

 

一、程序和執行緒

現代瀏覽器都是多程序的,主要包括:1個瀏覽器程序、1個網路程序、1個GPU程序、多個渲染程序、多個外掛程序。

其中,每個頁面標籤各自一個渲染程序(有時幾個標籤會共用一個渲染程序,詳情請看李兵老師的《瀏覽器工作原理與實踐》)。

每個渲染程序裡,會有多個執行緒。我們說JS是單執行緒的,其實是說一個渲染程序裡只有一個主執行緒。

一個渲染程序,主要有以下幾個執行緒:

(1)主執行緒:執行 JavaScript 程式碼、計算 CSS 樣式、構建和更新 DOM 樹、處理使用者互動等。

(2)渲染執行緒:負責將構建好的幀繪製到螢幕上。

(3)合成執行緒:負責頁面的捲動和動畫等操作,可以在不需要主執行緒參與的情況下獨立完成部分工作,以提高效能。

(4)GPU 執行緒:處理 GPU 任務,例如 WebGL 或者 CSS 3D 變換等。

(5)I/O 執行緒:處理磁碟和網路 I/O。

(6)工作執行緒(Worker Thread):執行 Web Worker 或 Service Worker 的程式碼。

(7)定時器執行緒:setTimeout、setInterval的計時在這上面進行。

 這裡面最重要的是主執行緒和I/O執行緒,渲染程序與瀏覽器其他程序的互動,都是通過I/O執行緒來完成的。比如網路請求、頁面點選,就分別是瀏覽器程序、網路程序,通過I/O執行緒來通知主執行緒的。

 

二、什麼是事件迴圈?

只有一個主執行緒,同步任務沒問題,一行行往下執行就是了。那非同步任務呢?網路請求、定時器都那麼耗時,如果都放在主執行緒執行,後果不堪設想。瀏覽器是通過什麼方式,在只有一個主執行緒的情況下,讓頁面可以流暢執行的呢?

答案就是:主執行緒執行同步任務,非同步任務則交給其他執行緒來執行。比如:網路請求由IO執行緒負責,setTimeout的計時由定時器執行緒負責等。

同步任務、非同步任務都安排好了,但是,非同步任務一般都會有回撥函數,也就是非同步任務執行結束後的回撥。它們呢,瀏覽器如何對待?

非同步任務的回撥,還有事件監聽函數、Promise的then等,它們則是放入到一個叫「訊息佇列」的東東里。瀏覽器執行完同步任務後,就開始從訊息佇列裡取任務,執行完一個再取下一個。比如setTimeout在計時結束後,回撥函數就進入訊息佇列排隊,等待被取出執行。Ajax請求也是一樣,請求返回後,回撥函數就進入訊息佇列排隊,等待被取出執行。訊息佇列是一個佇列結構,先進先出。如果訊息佇列是空的,事件迴圈就進入等待狀態,直到新的任務被放入訊息佇列裡。

總結就是:主執行緒執行同步任務,其他執行緒執行非同步任務,非同步任務的回撥則進入訊息佇列排隊待命。

這個過程是迴圈進行的,你可以簡單理解為是一個無限的for迴圈,這就是瀏覽器的事件迴圈。

 

三、宏任務佇列和微任務佇列

事件迴圈的關鍵,就是這個「訊息佇列」。

其實訊息佇列是我們的習慣叫法,它還被叫做任務佇列或者事件佇列。具體來說,一個渲染程序有兩個訊息佇列:一個宏任務佇列和一個微任務佇列。我們平時說的訊息佇列,其實是指宏任務佇列。

哪些任務屬於宏任務?

(1)整體的 script 程式碼(也就是一開始的全域性程式碼)

(2)`setTimeout` 和 `setInterval` 的回撥

(3)`setImmediate` 的回撥(Node.js 環境)

(4)I/O 操作(如網路請求、檔案讀寫等,主要在 Node.js 環境)

(5)使用者互動事件(如 click、keydown 等)

(6)UI 渲染更新

(7)postMessage、MessageChannel

(8)WebWorker 的 message 事件

哪些任務屬於微任務?

(1)`Promise` 的 `then` 和 `catch` 的回撥

(2)`process.nextTick` 的回撥(Node.js 環境)

(3)`MutationObserver` 的回撥

(4)`queueMicrotask` 方法的回撥

(5)`async/await`(實際上是通過 `Promise` 實現的)

為什麼要有微任務佇列呢?

你可以理解為,都是回撥,但是有一些的優先順序要比其他的更高,所以被單獨放入了一個佇列裡,並把這些任務定義為微任務。

它們的執行順序是這樣的:主執行緒的同步程式碼執行完後,就去檢查微任務佇列,先把微任佇列裡的任務都清空,之後,再從宏任務佇列裡取出一個宏任務,開始下一輪事件迴圈。

注意,我們要區分「同步任務」和「宏任務」的概念。雖然主執行緒只執行同步程式碼,並且宏任務被取出後回到主執行緒執行。但並不是說宏任務都是同步程式碼,這是倆不同的概念。不管宏任務還是微任務,本質都是一個回撥函數,裡面既可以寫同步程式碼,也可以寫非同步程式碼。當執行到它們裡面的非同步程式碼時,也是會交給其他執行緒執行,執行結束後把回撥放入宏任務佇列或微任務佇列的。

還有一點值得注意,那就是Promise物件或者async函數,裡面的程式碼是同步執行的,在開發中我們也有這個體驗。那為什麼總感覺Promise是非同步的呢?那是因為我們一般都會在Promise裡寫網路請求,網路請求是非同步的。由網路程序完成請求後,通過I/O執行緒,把回撥函數放入到宏任務佇列裡。如果Promise裡沒有非同步任務的話,它就完全是同步的。但是它們的回撥,也就是then或者catch函數,卻是被放入到微任務佇列裡,等待同步程式碼都執行完後再執行。

其實,宏任務佇列也有好幾種,比如:使用者互動佇列、定時器佇列、網路事件佇列。因為即便都是宏任務,也有不同的優先順序。比如使用者互動佇列的宏任務,優先順序就比定時器佇列要高,因為使用者體驗是要首先保證的。但我們一般無需深入到這個程度,簡單的理解為只有一個宏任務佇列,也沒什麼問題。

 

四、一個完整的事件迴圈是什麼樣的?

(1)主執行緒先執行同步程式碼,包括一開始的全域性程式碼,或者後面從宏任務佇列取出的宏任務。

(2)同步程式碼執行完後,檢查微任務佇列,把微任務佇列清空。

(3)嘗試重新渲染頁面。

瀏覽器會在每一輪事件迴圈結束的時候,嘗試重新渲染頁面。如果頁面沒有變化,什麼都不做。反之,也不一定立刻重新渲染,瀏覽器為了提高渲染效率,可能會把幾次渲染合併進行。另外,瀏覽器的渲染,考慮因素還有更多,比如顯示器的重新整理率等。

(4)從宏任務佇列取出下一個宏任務,進入下一輪事件迴圈。

當宏任務佇列和微任務佇列都為空時,瀏覽器可能會進入一個「空閒」狀態,等待新的任務被新增到佇列中。這個狀態通常被稱為「事件迴圈的空閒階段」。

 

五、再解釋這個案例

好了,現在是時候解釋為什麼 setTimeout(fn,0) 這麼神奇了!

瀏覽器的事件監聽函數,和setTimeout的回撥函數,都是被放入宏任務佇列裡的,瀏覽器把它們取出來放到主執行緒執行,就是開始一個事件迴圈。

1、為什麼能拿到最新的輸入結果?

前文說到,瀏覽器在每個事件迴圈結束的時候,會嘗試重新渲染頁面,這個過程就包括更新DOM。setTimeout把回撥函數放入宏任務佇列,也就會在最快下一個事件迴圈執行。此時JS存取的,就是在上個事件迴圈結束後更新的DOM,因此就能拿到最新的輸入了。

2、為什麼輸入框直接顯示大寫,而不是像keyup那樣,先顯示小寫然後跳成大寫?

這兒我認為有兩種可能:

(1)keypress事件的回撥,和setTimeout的回撥,是相鄰的兩個事件迴圈,瀏覽器把它倆結束後的渲染合併了,先變成大寫,然後更新DOM,渲染到頁面中。

(2)keypress事件回撥這一輪事件迴圈結束後,其實是渲染了,但是接著進行setTimeout回撥的這一輪事件迴圈,馬上把小寫變成了大寫。肉眼根本反應不過來,看上去就是直接顯示的大寫。

至於哪一種是對的,我無法確定,有大佬能指點一下嗎?

 

本人水平非常有限,寫作主要是為了把自己學過的東西捋清楚。如有錯誤,還請指正,感激不盡。