React.lazy 與 Suspense 造成的 300 ms 延遲:如何透過預載與取消懸掛提升新分頁載入速度
React.lazy, Suspense and the Hidden 300 ms Delay
本篇文章說明 Tab Widgets 擴充套件在開啟新分頁時因 React.lazy 與 Suspense 造成的 300 ms 延遲,並提供預載與取消懸掛的實作方式。
文章詳細說明 React.lazy 與 Suspense 造成 300 ms 延遲的機制,並提供預載與取消懸掛的實作,對想最佳化新分頁載入速度的開發者很有參考價值。
新的分頁擴充功能出現了不尋常的效能問題:每次開啟分頁時都從頭開始,沒有熱頁可供回復。因此,當儀錶板開始顯示填充所需的明顯延遲時,我們假設是因為捆綁包太大。
並非如此。最大的修正並未移除一個位元組。以下是我們發現的內容,包括我們必須重新思考的修正。
TL;DR: 在無頭 Chromium 中,我們的 widget 程式碼在約 100 ms 內載入,但 widget 直到約 380 ms 才顯示,因為懶載入元件只會暫停一次,而 React 會將暫停內容延遲 300 ms。若我們自己渲染佔位符而非暫停,第一個 widget 的內容會提前約 240 ms 顯示,整個儀錶板也會提前約 200 ms,這是在 Windows 桌面版 Chrome 上。縮小主捆綁包並未產生可測量的差異。
目錄
- 設定
- 位元組不是瓶頸
- React 目的性地延遲 widget 的顯示
- 第一個修正:在第一次渲染前載入 widget
- The fix that held up: don't suspend at all
- 我們學到的
設定
Tab Widgets 是一個使用 Rspack 建置的 React 19 應用程式。每個 widget(時鐘、筆記、天氣、Gmail、Spotify 等十數個)都存在於自己的程式碼區塊,並以 React.lazy 載入,因此新分頁只會載入你實際使用的 widget 程式碼。每個 widget 均位於自己的 Suspense 邊界內,邊界會在 widget 準備好前顯示載入佔位符。
當新分頁開啟時,應用程式會:
- 載入主捆綁包,
- 讀取你儲存的頁面與 widget,
- 載入應用程式殼與可用 widget 列表,
- 渲染頁面,並
- 在渲染時載入每個 widget 的程式碼。
位元組不是瓶頸
我們先從明顯的嫌疑犯開始:啟動捆綁包中包含什麼?我們發現實際的浪費。共用的 ~/ui 索引檔重新匯出了每個 modal,因而 Add widget、Settings 與 Upgrade modal(含價格表與付款卡徽章)即使你只有在點選按鈕時才會看到,也被捆綁進啟動程式碼。移除該重匯出將約 100 KB 從啟動路徑移除。
我們還注意到日期時間函式庫(luxon)因為一個未使用它的單一輔助程式而被捆綁進主啟動檔。將其移出後,283 KB 的主檔明顯變小,但對任何內容何時顯示並未產生可測量的差異。該函式庫只是移到另一個仍在啟動時被時鐘 widget 需要的檔案中,執行時間幾乎無需。於是我們還原。只有當檔案改變瀏覽器等待的內容時,縮小檔案才會有幫助。
接著我們在無頭 Chromium 上對正式版本進行分析。每個指令碼在開啟分頁約 100 ms 內載入並評估,但 widget 直到約 380 ms 才出現。在這段時間大部分時段,瀏覽器什麼也沒做。之後多個計時器同時觸發,widget 才被渲染。
擴充功能的檔案是從本機磁碟讀取,而非網路,因而多餘的數百 kilobyte 成本極低。我們的網頁版本則是透過網路載入相同程式碼,捆綁包大小會更重要,但以下的節流修正同樣適用。
進一步縮小捆綁包並不會有幫助,因為有某些東西是刻意等待的。
等待源於兩種 React 行為在啟動時結合不良。
1. 延遲載入的元件在第一次渲染時總是暫停,即使其程式碼已經載入。React.lazy 只在第一次渲染時呼叫import(),並且會透過 Promise 回傳,該 Promise 只會在那次渲染之後才解析。因此第一次渲染總是顯示 Suspense 回退內容。
提前自己呼叫 import() 並不能幫忙:延遲元件在渲染時仍會自行啟動匯入,取得尚未解析的 Promise,並且會立即暫停。
2. React 限制暫停內容顯示的速度。 當暫停內容準備好時,React 只會在最後一次回退出現或被替換後過了 300 毫秒 才顯示它(React 原始碼中的 FALLBACK_THROTTLE_MS)。在那段時間內準備好的任何內容都會一起顯示。Suspense 限制條件 說明「最多每 300 毫秒顯示一次,計算方式從上一次顯示開始」。這是有意為之:它能避免頁面在許多小片段幾乎同時完成載入時閃爍。對於像我們這樣內容幾乎立即就準備好的情況會造成不便。GitHub 上有長篇討論 關於將其設定化,迄今為止 React 的維護者仍保持固定的啟發式做法。
將上述結合後,每個新分頁的元件都必定會先顯示其佔位符,然後至少等待 300 ms,無論其程式碼到達速度多快。由於該 300 ms 計時器是全頁共享的,元件也會以批次顯示。
將渲染包裝在 startTransition 中也無法解決此問題。過渡可阻止已在畫面上的內容回到回退,但第一次掛載的 Suspense 邊界仍會顯示其回退。(在 React 的開發版中,過渡可在匯入已解析時跳過回退,請在正式版檢查此行為。)
React 本身的內部機制揭示了延遲元件如何啟動:它呼叫 component._init(component._payload)。若我們在 React 渲染前自行呼叫該函式,匯入就會提前開始,並且在完成後延遲元件不再暫停。此 preloadLazy 函式已延續至我們的最終修正,值得一看。它還會追蹤哪些元件已完成(載入或失敗),稍後會用到:
type LazyInternals = {
_init?: (payload: unknown) => unknown;
_payload?: { _status?: number };
};
const settled = new WeakSet<object>();
export const preloadLazy = (component: object): Promise<void> => {
const { _init, _payload } = component as LazyInternals;
const settle = () => {
settled.add(component);
};
if (typeof _init !== "function") {
settle(); // not lazy (or React changed): render it as usual
return Promise.resolve();
}
try {
_init(_payload); // starts the import, and returns if it has already loaded
settle();
return Promise.resolve();
} catch (thrown) {
// still loading: React throws the promise our import() returned. Already failed: it
// throws the error, which the widget's error boundary will show
if (typeof (thrown as PromiseLike<unknown>)?.then === "function") {
return Promise.resolve(thrown).then(settle, settle);
}
settle();
return Promise.resolve();
}
};
// React's lazy status: 1 loaded, 2 failed
export const isLazySettled = (component: object) =>
settled.has(component) ||
[1, 2].includes((component as LazyInternals)._payload?._status ?? -1);_init、_payload 與 _payload._status 為私有欄位,因此我們採取溫和回退:若 React 之後更改它們,元件會立即視為已完成,並且渲染與暫停行為與以往相同。
我們曾考慮為自己的載入模組對映移除 React.lazy,因為不需要私有欄位(我們在下方示範一個版本)。但每個元件的元件、預覽與設定已經是延遲元件,並從儀錶板、畫中畫、加入元件目錄以及設定對話方塊渲染。因此我們保留它們,並將私有欄位隔離在一個小型且經測試的檔案中。單元測試會在預載後渲染一個真實的 React.lazy 元件,因此若 React 升級破壞此行為,測試將失敗,而非悄悄失去速度提升。
啟動時,我們先判斷分頁開啟的頁面,預載其元件,並在渲染前等待最多 100 ms。於無頭 Chromium 中,元件的載入時間從約 380 ms 降至約 95 ms。
但限制等待時間有一個弱點。任何程式碼執行時間超過上限的元件仍會被暫停,且因為其後備內容現在會較晚顯示,React 的 300 毫秒視窗也會延後開始與結束。在磁碟較慢或機器繁忙的情況下,那個元件會比未設定等待時間時多延遲高達 100 毫秒。
途中發現一個回歸。由於對話方塊不再包含在啟動程式碼中,它們會在需要時載入,並伴隨小型載入轉輪。該轉輪佔用了鍵盤焦點,導致關閉「新增元件」對話方塊時不再把焦點還給開啟它的按鈕。我們的端到端測試捕捉到此問題,現在在頁面閒置時預先載入這些對話方塊,讓它們的開啟方式與之前完全相同。
阻礙的解決方案:完全不使用暫停
我們不再競速計時器,而是讓元件不會因自身程式碼而自動暫停。
每個元件現在會檢查其程式碼是否已載入。若未載入,便以常規內容呈現一般佔位符並開始載入。程式碼到達時,正常的狀態更新會將真實元件替換進來。這是一個小型鉤子,建立在 preloadLazy 上:
export const useLazyReady = (component: object) => {
const [, rerender] = useReducer((count: number) => count + 1, 0);
const ready = isLazySettled(component);
useEffect(() => {
if (ready) return;
let active = true;
preloadLazy(component).then(() => {
if (active) rerender();
});
return () => {
active = false;
};
}, [component, ready]);
return ready;
};isLazySettled 在 preloadLazy 完成處理元件後為真,無論程式碼是否載入成功。失敗的載入仍會呈現元件,因此其錯誤邊界可顯示失敗。它在 React 自行載入元件時也為真(例如在畫中畫視窗中)。每個元件接著選擇要呈現的內容:
const isReady = useLazyReady(WidgetComponent);
return isReady ? <WidgetComponent {...config} /> : <WidgetLoading />;將元件的 Suspense 與錯誤邊界保留在此。它們仍能捕捉失敗的匯入,以及如果 React 的私有欄位發生變動時會暫停的任何情況。
React 的 300 毫秒延遲僅在顯示已暫停的內容時適用,而此處沒有任何暫停,因此每個元件在程式碼就緒時即刻顯示。啟動仍會在首次渲染前開始載入起始頁面的元件,但不再等待它們。
這確實放棄了節流的目的:元件現在會隨程式碼到達逐一顯示,而非一次性批次。對我們來說沒問題。每個元件的框架在格線上尺寸固定,佔位符填滿它,因此當元件切換進來時不會有任何位移。
我們將相同的思路應用於許可權檢查,該檢查在計時器、倒數、番茄鐘、歷史和書籤元件渲染前執行。它使用了暫停查詢,因而這些元件也被節流阻擋,儘管檢查只需幾毫秒。現在它以普通查詢執行,並在完成前顯示佔位符。
結果
我們在 Chrome 的 Windows 桌面上測量了修正前後的建置:每個測試都開啟 4 個全新分頁,並載入一個繁忙的 20 個元件儀錶板(包含 Gmail、Google Drive、Tasks、Spotify、RSS 閱讀器與寵物遊戲;16 個可見,已登出),一張純灰色牆紙,且關閉牆紙的進場動畫以免模糊截圖。後續建置即為整體變更:對話方塊已移出啟動 bundle,起始頁元件已預載,程式碼門檻與許可權檢查修正。時間取自追蹤截圖,測量自分頁開始載入時起,因此每一行代表該階段最先可見最終頁面元件細節的截圖(5% 為「第一個元件內容」、85% 與 97% 分別為「全部」)。測試帳號為登出狀態,故七個 Pro 元件(Gmail、Calendar、Drive 等)顯示「登入 / 升級」覆蓋層,而非其內容。我們將這些元件排除於數值之外,因為 Pro 使用者永遠不會看到該覆蓋層。它們與上方的無頭數值不可比。
在 Windows 11 桌面(i9-14900KF, 64GB 6000 MT/s, T-FORCE TM8FPL2000G NVME SSD)上使用 Chrome 154.0.8037.98 進行了追蹤紀錄。
| 4 次執行的平均值 | 修正前 | 修正後 | 變化 |
|---|---|---|---|
| 導航列可見 | 274 ms | 326 ms | +52 ms |
| 第一個 widget 內容可見 | 540 ms (all at once) | 298 ms | −242 ms |
| 85% 的 widget 內容可見 | 540 ms | 336 ms | −204 ms |
| 所有 widget 內容可見 | 540 ms | 544 ms | +4 ms |
有一件事比這些都早出現:Pro widget 的登入覆蓋層,大約在 200 ms 時顯示。它曾經位於 widget 的 Suspense 邊界內,因此回退機制會在顯示前將其隱藏。現在沒有任何掛起,於是它直接在佔位符上方即時繪製。
需要解釋兩行。導航列首次偵測大約比前者晚 50 ms,但它並未在後續渲染:它屬於與 widget 佔位符同一次首次渲染。它會在 150 ms 內淡入,我們的截圖測量只捕捉到淡入過程的一部分。修正後,widget 在淡入期間渲染,而非在後續一次性批次渲染,因而顯示它的幀落在稍後。根據我們基於 DOM 的無頭執行,導航列在修正後大約晚 11 ms 到達(平均 157 和 169 ms),屬於執行間噪音。
最後的 widget 沒有移動:Weather 與 Word of the Day 會載入其程式碼,然後在抓取資料時再次掛起,因此 React 的 300 ms 緩衝仍然阻礙它們。修正前,它們與其他專案同一批次顯示。修正將程式碼載入移出了 Suspense,但未處理資料載入,因此這兩個仍是下一步。
以下是每個版本的典型執行(最接近中位數的那一次),已畫素化以隱藏儀錶板內容:

未使用 React 的私有欄位
如果你從頭開始,或不想觸碰 React 的內部機制,可以跳過 React.lazy,並將已載入的元件儲存在自己的快取中。相同概念:提前啟動匯入,渲染佔位符直到匯入完成,再透過正常的 state 更新渲染元件:
type Loader<P> = () => Promise<{ default: ComponentType<P> }>;
const loaded = new Map<Loader<any>, ComponentType<any>>();
// call this before the first render to start loading early
export const preload = <P,>(load: Loader<P>) =>
load().then((module) => {
loaded.set(load, module.default);
return module.default;
});
export const useLoadedComponent = <P,>(load: Loader<P>) => {
const [Component, setComponent] = useState(
() => loaded.get(load) as ComponentType<P> | undefined,
);
useEffect(() => {
if (Component) return;
let active = true;
preload(load).then((Loaded) => {
if (active) setComponent(() => Loaded);
});
return () => {
active = false;
};
}, [load, Component]);
return Component;
};將每個載入器一次性在模組層級定義,確保每次渲染時都是相同的函式,並像上方的 gate 一樣使用:
const loadNotes = () => import("./NotesWidget");
const Notes = (props: NotesProps) => {
const Widget = useLoadedComponent(loadNotes);
return Widget ? <Widget {...props} /> : <WidgetLoading />;
};實際版本還需要處理匯入失敗,例如將錯誤儲存並在渲染時丟擲,以便錯誤邊界顯示。不同於 React.lazy,後者會永遠保留失敗的匯入,這種方式可以再次嘗試,例如在小工具再次掛載時。
我們學到
- 測量事物出現的時間,而非僅僅關注檔案大小。 將主要檔案縮小(將 luxon 移出)並未改變任何內容出現的時間。
- 尋找閒置時間。 最大的效益來自於主執行緒無任何工作時的空隙,而非任何程式碼加速。
- 瞭解框架的預設行為。 React 的顯示節流是為避免閃爍而設的合理預設,但在每次從頭渲染且從本機檔案載入程式碼的頁面上,內容幾乎即時準備好,固定 300 ms 的等待成為我們最大的延遲。
- 在實驗室中找出機制,並在使用者環境中確認。 無頭 Chromium 使閒置空隙易於發現與說明;我們隨後在 Windows 上的 Chrome 中確認了結果。
這些改進已隨 Tab Widgets v2 發布。
親自試試
目前每個 widget 都使用本貼文中的 gate。開啟幾個新分頁,觀察儀錶板的填充。
- 將分頁小工具加入 Chrome
- 將分頁小工具加入 Edge
- 試用網頁版,不需要安裝(它會從網路載入,因此上述啟動資料僅針對擴充功能)
免費。