前言
最近閱讀《高效能JavaScript》時,第六章談到“通過定時器將JavaScript執行程式碼的控制權先讓給瀏覽器用於更新UI狀態,然後再將控制權交回給JavaScript程式碼,這樣就可以使得頁面更為流暢”,就聯想到了之前理解的事件迴圈。
這篇文章就是為了解釋為什麼這麼做可以提升頁面的流暢度。
事件迴圈(Event Loop)
單執行緒的JavaScript
總所周知,JavaScript語言的一大特點就是單執行緒,也就是說在一個時間段裡,JavaScript只能做一件事情(瀏覽器是多執行緒)。 多執行緒可以實現應用的並行處理,從而以更高的CPU利用率提高整個應用程式的效能和吞吐量。
但是JavaScript卻以單執行緒進行,為什麼呢?
JavaScript是瀏覽器指令碼語言,用於與使用者互動以及操作DOM。 考慮如下情況,如果有兩個併發的操作,對同一個DOM節點分別進行刪除和修改樣式,此時瀏覽器就無法決定到底採用哪個執行緒的操作。類似資料庫,我們可以採用“鎖”來處理併發,但是這會平添複雜度。所以,JavaScript語言沒有支援多執行緒操作。 那又考慮這種情況,既然JavaScript是單執行緒,在某一時刻內只能執行特定的一個任務,並且會阻塞其它任務執行。那麼如果使用者觸發了一個非常耗時的I/O操作,那麼按道理後續的所有操作都得等到I/O操作完成後方可進行。但是,事實上,後續的任務不必等待這個耗時的I/O操作完成,原因就是JavaScript與生俱來的非同步和回撥。
而這背後恰好就是本文的主題——————事件迴圈
定義
事件迴圈包含了至少兩個任務佇列,巨集任務佇列和微任務佇列。
巨集任務
巨集任務包含建立文件物件、解析HTML、執行主線JavaScript程式碼、更改當前URL以及各種事件,例如頁面載入、輸入、網路事件和定時器等等。巨集任務執行完成後,瀏覽器繼續其他的任務排程,如重新渲染頁面或者垃圾回收。
微任務
微任務包括promise、回撥函式、DOM發生變化等。微任務更新應用程式的狀態,必須在瀏覽器任務繼續執行其他任務(渲染UI檢視或者進行下一個巨集任務)之前執行。
兩個基本原則
- 一次處理一個任務
- 一個任務開始直到執行完成,不會被其他任務中斷
在微任務佇列清空後,事件迴圈會檢查當前是否需要重新渲染UI,如果需要則渲染UI檢視。
補充
- 兩個任務佇列都是獨立於事件迴圈的,這意味著任務佇列的新增發生在事件迴圈外。
- 所有微任務都會在下一次渲染前完成,目的是在渲染前更新應用程式狀態。
- 瀏覽器會嘗試以每秒渲染60次頁面,以達到每秒60幀的速度。所以,一次迴圈最理想的時間應該不超過16ms。
- 瀏覽器完成頁面渲染後,進入下一輪事件迴圈迭代後,可能出現3種情況
- 如果事件迴圈執行到“is rendering needed”且瀏覽器處於另一個16ms結束之前(即瀏覽器尚未自動觸發頁面渲染時),瀏覽器可能不會選擇在當前的時間迴圈中執行更新UI操作,因為更新UI是一個複雜且耗效能的操作。
- 如果事件迴圈執行到“is rendering needed”且瀏覽器剛好離上一次渲染16ms左右時(即瀏覽器即將自動觸發頁面渲染時),此時瀏覽器會進行UI更新。
- 執行下一個事件迴圈耗時超過16ms,瀏覽器將無法以目標幀率重新渲染頁面,且UI無法被更新。如果延遲不大是很難察覺到,但是,如果有非常耗時的操作,這個時候使用者會覺得網頁十分卡頓,甚至瀏覽器會提示“無響應指令碼”。
前情回顧
現在,用事件迴圈和簡單的例子來分析《高效能的JavaScript》中的那句話。 需求:給包含1000個數字的陣列中的每個元素取絕對值(假設對一個數字進行需求操作耗時1ms)。
情況1(不使用定時器): 由於JavaScript主執行緒程式碼屬於巨集任務的一種,所以一次事件迴圈需要處理1000個數字,所以1s事件迴圈才進行到UI更新階段,但是由於耗時過長,UI狀態不會被更新,頁面出現卡頓甚至堵塞。
情況2(使用定時器): 將一次處理1000個數字的任務分割為20個每次處理50個數字的任務。由於定時器是巨集任務的一種,所以一次事件迴圈只處理50個數字,由於此時微任務佇列為空,所以50ms後事件迴圈進行到UI更新階段,然後根據情況進行UI渲染,頁面未出現卡頓或者堵塞。
當然,如果只是單純的處理資料,我們可以考慮使用Web Workers。
總結
- JavaScript是單執行緒的,同一時刻是隻能執行一個任務。
- 事件迴圈包含一個巨集任務佇列和至少一個微任務佇列。事件迴圈一次迭代,至多執行一個巨集任務但是會執行完所有的微任務。
- Web應用越複雜,積極主動管理UI執行緒就越重要,即使JavaScript程式碼很重要,也不能影響使用者體驗。