December 20, 2024Programming

為什麼網頁可以這麼快?前端框架與 Event Loop

以前常常聽說人們如果點進一個不熟悉的網站,在兩秒後還未加載到內容的話就會點上一頁離去。因此,網頁加載速度可以說對流量至關重要。

那麼網頁加載速度取決於什麼?

回答這個問題,首先要知道當你點進一個網站後瀏覽器都幹了些什麼。

網頁加載背後的過程

當你點擊一個網站時,瀏覽器會經歷一系列的步驟來呈現頁面。首先,它會向服務器發出請求,獲取網頁的源代碼。這通常是 HTML 文件,並可能包含對其他資源(如圖片、CSS 和 JS)的引用。隨著網頁的加載,瀏覽器會從上到下解析 HTML,並構建一個叫做 DOM(Document Object Model,文件對象模型)的結構。

如果 HTML 中包含了圖片、影片,會發出異步請求(Asynchronous request)。如果這個網絡資源是外部的 js 代碼文件的話,瀏覽器會立即請求和執行,執行後才繼續處理剩下的 html(這被稱為 blocking)。這會導致頁面渲染的延遲,而一些特殊屬性(如 asyncdefer)則可以讓 js 異步加載,減少對渲染的影響。

在這些過程中,一旦 HTML 被解析並且必要的資源加載完畢,瀏覽器會開始渲染頁面,並將其呈現在屏幕上。然而,這並不是結束。隨著用戶與頁面交互,可能會有更多的動態內容更新,這就涉及到我們所說的“部分頁面更新”,尤其是像 React 這樣的前端 SPA 框架的應用。

性能的三大瓶頸:網絡請求、DOM 操作、JS 計算

在了解加載過程後,我們需要考慮的是,哪些因素會影響網頁的加載速度?可以看出,這個流程涉及性能的要素包括幾個最重要的方面:網絡請求速度、DOM 操作、JS 計算。

對性能的影響可以這樣排名:網絡請求 > DOM 操作 > JS 計算。

  1. 網絡請求
    網絡延遲通常是影響性能的最大瓶頸。當瀏覽器需要從服務器獲取數據時,尤其是在跨越較長地理距離的情況下,延遲可能會非常明顯。

  2. DOM 操作
    直接操作 DOM 是瀏覽器性能的另一大挑戰。每當我們更新 DOM,瀏覽器需要重新計算佈局(Reflow)和重繪(Repaint),這不僅影響頁面渲染的速度,還會造成額外的計算負擔。

  3. JS 計算
    雖然 JS 本身的計算負擔相對較輕,但當頁面包含大量計算或處理時,仍然可能導致性能下降,甚至直接卡死。

React 如何提高性能?

因此要改善網頁性能,減少網絡請求必然是重中之重。Caching(緩存/快取)是減少網絡請求最方便的方法。React 常用的配合數據獲取(data-fetching)的狀態管理(State management)工具如 Tanstack query、Apollo client 等一般都有實現 Caching 功能來減少不必要的網絡請求,開發者開箱即用,不必重造輪子(當然自己實現便能更了解其中原理)。

其次,減少昂貴的 DOM 操作也是改善網頁性能的重要手段。

傳統的不用 SPA 框架的網頁,如果要大量地更新一個數據列表,可能的做法是直接把列表的父元素的innerHTML設置為一段新的 HTML string(也可以是刷新網頁,但比起昂貴的 DOM 操作,網絡請求上的性能影明顯更大)。而這種 DOM 操作對於瀏覽器來說的成本可以說是相當高的,涉及大量的排版、佈局計算等。

React 作為最具代表性的現代框架之一,它通過多種技術手段來提高性能。其核心技術之一就是虛擬 DOM。

虛擬 DOM 是 React 的一個重要特性,它在內存中創建了一個 DOM 樹的副本,並在數據變更時通過一種叫做“Diff 算法”的方式,計算出前後 DOM 之間的差異。React 只會將這些差異更新到真實的 DOM,而不是每次都重新渲染(Re-rendering)整個頁面。這樣做的好處在於,減少了昂貴的重排和重繪操作,進行更為精細的 DOM 操作,提升了性能。

但是,無論再怎麼優化 DOM 操作,有時無可避免還是需要大量的 diff 計算和 DOM 操作,這時可能會導致性能下降甚至卡死。這是因為在 JS 中,所有的代碼都會在 單線程 中執行,也就是說,同一時間內只能處理一個任務。這可能會導致阻塞(Blocking)問題,尤其是在處理耗時的操作(如大量的 JS 計算、大量的 DOM 操作或文件處理)時。

這時便需要適當利用 JS 的事件循環(Event Loop)機制,以瀏覽器的異步處理功能來優化 JS 和渲染 (Rendering) 的協同工作。

事件循環的基本概念:

事件循環(Event Loop)是 JS 執行模型的核心機制。它的主要任務是管理執行棧(Call Stack)和消息隊列(Message Queue)之間的協作。這一過程確保了即使是單線程的 JS,也能夠高效地處理各種 I/O 操作,而不會讓主線程處於阻塞狀態。

基本過程如下:

  1. 執行棧:當 JS 代碼運行時,所有的同步代碼都會被推送到執行棧中進行處理。每次執行一條語句,這條語句就會從棧中移除,直到所有同步代碼都執行完畢。

  2. 消息隊列:當異步操作(例如定時器、網絡請求完成、用戶輸入等)完成後,會將相應的回調函數(Callback Function)放入消息隊列中,等待執行。

  3. 事件循環:事件循環的核心功能是,不斷檢查執行棧是否為空。如果執行棧為空,事件循環會從消息隊列中取出一個回調函數,並將其放入執行棧中執行。這樣,事件循環就確保了異步任務的非阻塞執行。

異步操作與非阻塞執行:

  • 異步操作(如 setTimeoutfetch 請求、事件監聽器等)不會立刻執行,而是被推到消息隊列中,等待執行棧中的同步代碼執行完後再被處理。
  • 通過這種方式,即便是耗時的操作,也不會阻塞主線程,保證了頁面的流暢度和用戶的良好體驗。

簡單示例:

console.log("Start"); setTimeout(() => { console.log("Timeout"); }, 0); console.log("End");

預期輸出:

Start
End
Timeout

解釋:

  • console.log('Start')console.log('End') 是同步代碼,會立即執行並輸出。
  • setTimeout() 是一個異步操作,它將回調函數放入消息隊列中,並在主線程空閒時執行。即使設置的延遲是 0,它仍然會等到同步代碼執行完畢後才執行。

瀏覽器渲染與 JS

瀏覽器的渲染過程會受到 JS 執行的影響。每當 JS 操作 DOM 時,可能會導致瀏覽器重新計算頁面的佈局(reflow)或重繪 (repaint)。這些過程通常是昂貴的操作,尤其是在頁面結構複雜時。

渲染循環與 JS 的交互:

瀏覽器有自己的渲染循環,它與 JS 的事件循環並行工作。這意味著,JS 代碼的執行可能會影響頁面的渲染。舉個例子,如果 JS 代碼更新了 DOM,瀏覽器會需要重新計算 DOM 的佈局並重新渲染頁面。如果這些操作發生得太頻繁,可能會造成頁面卡頓。

為了減少這種情況,瀏覽器會盡量將多個渲染操作合併到同一幀中,並且會有一個預設的每秒刷新率(通常是每秒 60 幀,即 16.67 毫秒)。如果 JS 代碼執行過於耗時,可能會造成頁面渲染延遲,影響用戶體驗。

因此,如果一個 JS 任務的執行時長大於 16.67 毫秒,會導致介面交互出現短暫的延遲。例如,按鈕的 onclick 事件必須等到上一個任務完成後才執行,在用戶看起來就像是按了按鈕卻沒有反應,但實際上只是這個任務還在排隊等待執行。

React 18:Concurrent Mode

為此,React 18 使用了 Concurrent Mode,以避免大量 diff 計算和 DOM 操作導致卡死的現象。在 Concurrent Mode 下,React 開始更新渲染更新到一半的時候如果進來了 onclick 事件,則會先去執行 onclick 事件,待 onclick 事件完成後才繼續執行剩下的更新,保證了即使在耗時的渲染更新下還能保證用戶仍然能及時進行交互。

React 保證即使渲染中斷,UI 也會保持一致。它會等到最後(即評估了整個 DOM 樹之後)才執行 DOM 變更。借助此功能,React 會在後台準備新頁面,而不會阻塞主線程。

當然實際環境下會遇到各種問題,比如説如何只更新一半?如果 onclick 更新又觸發了新的更新,新舊更新的優先度問題?等等。有興趣了解這些深入的技術細節的話可以參考這篇技術博文,這裡我就不班門弄斧了。

如何優化 JS 和渲染的協同工作:

雖然 React 框架解決了耗時的 DOM 操作導致頁面卡頓的問題,但如果 DOM 操作以外的一般 JS 計算過於耗時的話也有機會導致渲染延遲和卡死,因此我們可以利用瀏覽器的計時和異步處理來優化 JS 和渲染的協同工作。

  1. 將長時間的計算操作異步化:將耗時操作拆分為多個小任務(例如使用 setTimeoutrequestAnimationFrame)來讓瀏覽器有空間進行渲染。

  2. 使用 requestAnimationFrame 來同步渲染:這是為動畫和視覺效果優化的一個方法。requestAnimationFrame 會在瀏覽器下一次重繪前執行 JS 代碼,這樣可以確保渲染操作和 JS 代碼執行的同步性,減少重排和重繪的次數。

  3. 避免強制同步渲染:避免在 JS 中使用會強制同步計算佈局的操作(例如 offsetHeightgetBoundingClientRect()),因為這些會觸發瀏覽器的同步渲染,從而影響頁面的流暢性。

JS 的性能優化與事件循環和瀏覽器的渲染機制息息相關。利用瀏覽器提供的異步處理機制,將長時間的計算操作分散到多個小任務中,並利用 requestAnimationFramesetTimeout 等 API 來協調渲染與 JS 的執行,可以大大提高網頁的流暢度和響應速度。

這些異步處理機制和計時器不僅能減少主線程的阻塞,還能讓我們在不妥協性能的情況下,實現更加豐富和高效的用戶體驗。