在本文中略吨,將解釋如何通過(guò)避免基于路由的懶加載引發(fā)的瀑布效應(yīng),提升客戶端渲染應(yīng)用的性能考阱。我們會(huì)通過(guò)注入一個(gè)自定義腳本來(lái)預(yù)加載當(dāng)前路由的代碼塊翠忠,確保這些代碼塊能與入口代碼塊并行下載。我將使用 Rsbuild 來(lái)實(shí)現(xiàn)腳本注入乞榨,但代碼可以很容易地適配到 Webpack 和其他打包工具秽之。
代碼示例基于一個(gè)只有兩個(gè)頁(yè)面的小型應(yīng)用:一個(gè)首頁(yè)(路徑為 /
和 /home
)和一個(gè)設(shè)置頁(yè)面(路徑為 /settings
)。
基于路由的代碼拆分
在客戶端渲染的應(yīng)用中姜凄,代碼拆分是提升整體性能的主要策略之一政溃。通過(guò)代碼拆分,可以只加載必要的代碼塊态秧,而不是一次性加載全部代碼。
最常見(jiàn)的實(shí)現(xiàn)代碼拆分的方法是通過(guò)懶加載路由(或頁(yè)面)對(duì)應(yīng)的代碼塊扼鞋。這意味著只有當(dāng)用戶訪問(wèn)相應(yīng)頁(yè)面時(shí)申鱼,這些代碼塊才會(huì)加載,而不會(huì)提前加載云头。這不僅減少了加載應(yīng)用所需的包大小捐友,還優(yōu)化了緩存:應(yīng)用包拆分得越細(xì),緩存失效的概率就越欣;薄(前提是靜態(tài)文件正確地使用了哈希處理)匣砖。
像 Next.js 和 Remix 這樣的服務(wù)端渲染框架通常會(huì)自動(dòng)處理代碼拆分和懶加載。而對(duì)于客戶端渲染的單頁(yè)應(yīng)用昏滴,你可以通過(guò)在路由中懶加載需要的組件來(lái)實(shí)現(xiàn)代碼拆分:
const Home = lazy(() => import("./pages/home-page"));
const Settings = lazy(() => import("./pages/settings-page"));
在這種設(shè)置下猴鲫,當(dāng)用戶訪問(wèn)應(yīng)用的 /
路由時(shí),只有首頁(yè)對(duì)應(yīng)的代碼塊(例如 home.[hash].js
)會(huì)被下載谣殊。設(shè)置頁(yè)面的代碼塊(例如 settings.[hash].js
)只有在用戶導(dǎo)航到設(shè)置頁(yè)面時(shí)才會(huì)下載拂共。
懶加載的缺點(diǎn)
盡管代碼拆分有很多好處,但也存在一些缺點(diǎn)姻几。默認(rèn)情況下宜狐,代碼塊只有在需要時(shí)才會(huì)下載,這可能導(dǎo)致以下兩種明顯的延遲:
初始加載延遲:在應(yīng)用首次加載時(shí)蛇捌,會(huì)存在從加載入口代碼塊(如頂層應(yīng)用及客戶端路由器)到加載初始頁(yè)面代碼塊(如首頁(yè))的延遲抚恒。這是因?yàn)闉g覽器需要先下載、解析并執(zhí)行入口代碼塊络拌,接著應(yīng)用路由器決定當(dāng)前是首頁(yè)路由俭驮,然后再提示瀏覽器下載、解析并執(zhí)行首頁(yè)代碼盒音。
導(dǎo)航延遲:同樣地表鳍,每次在頁(yè)面之間導(dǎo)航時(shí)也會(huì)有延遲馅而。這是因?yàn)闉g覽器只會(huì)在導(dǎo)航開(kāi)始時(shí)下載、解析并執(zhí)行新的代碼塊(例如譬圣,只有點(diǎn)擊“設(shè)置”鏈接時(shí)才會(huì)加載設(shè)置頁(yè)面的代碼塊)瓮恭。
一個(gè)穩(wěn)健的緩存策略(例如,將這些代碼塊標(biāo)記為不可變并預(yù)緩存它們)和使用支持預(yù)加載功能的路由器可以緩解第二點(diǎn)厘熟。我可能會(huì)在后續(xù)文章中更深入地探討這些話題屯蹦。而現(xiàn)在,我們將重點(diǎn)解決第一個(gè)問(wèn)題绳姨。
預(yù)加載異步頁(yè)面
我們的目標(biāo)是解決瀑布問(wèn)題登澜,即在頁(yè)面可以下載之前,必須等待入口代碼塊完成請(qǐng)求的情況:
我們已經(jīng)知道飘庄,當(dāng)用戶導(dǎo)航到 /
路徑時(shí)脑蠕,應(yīng)該下載首頁(yè)代碼塊。沒(méi)有必要等到應(yīng)用完全加載后再開(kāi)始下載首頁(yè)代碼塊跪削,對(duì)吧谴仙?因此,我們可以讓它與入口代碼塊并行下載碾盐。
根據(jù)我的經(jīng)驗(yàn)晃跺,最好的方法是通過(guò)在 HTML 的 <head>
中注入一個(gè)小型腳本,預(yù)加載當(dāng)前訪問(wèn) URL 的異步代碼塊毫玖。
從高層次來(lái)看掀虎,這個(gè)方法是使用構(gòu)建工具(本文中是 Rsbuild)將一個(gè)小型腳本注入到 HTML 文檔的 <head>
中。這個(gè)腳本包含每個(gè)路由與其需要預(yù)加載文件的映射關(guān)系付枫。在執(zhí)行時(shí)烹玉,它通過(guò)手動(dòng)將這些文件添加為 <link rel="preload">
的形式來(lái)預(yù)加載當(dāng)前路徑所需的文件。
讓我們深入了解具體實(shí)現(xiàn)示例励背。
為異步導(dǎo)入添加 webpackChunkName
注釋
由于在構(gòu)建完成之前春霍,我們無(wú)法知道代碼塊文件的具體名稱(chēng),因此腳本生成和注入邏輯必須在打包工具層面實(shí)現(xiàn)叶眉。例如址儒,根據(jù)良好的緩存實(shí)踐,首頁(yè)代碼塊的文件名可能包含哈希值(如 page.12ab33.js
)衅疙,這個(gè)名稱(chēng)由打包工具分配莲趣。
為了確定是否應(yīng)該預(yù)加載某個(gè)代碼塊,建議維護(hù)頁(yè)面路徑與其 webpackChunkName
的映射關(guān)系饱溢。webpackChunkName
是一個(gè)支持多個(gè)打包工具的注釋?zhuān)梢杂脕?lái)為 JavaScript 代碼塊分配一個(gè)可讀名稱(chēng)喧伞,供打包工具訪問(wèn):
const Home = lazy(() => import(/* webpackChunkName: "home" */ "./pages/home-page"));
const Settings = lazy(() => import(/* webpackChunkName: "settings" */ "./pages/settings-page"));
route-chunk-mapping.ts
// 路徑與其 webpackChunkName 的映射關(guān)系
export const routeChunkMapping = {
"/": "home",
"/home": "home",
"/settings": "settings",
};
為每個(gè)路由構(gòu)建需要加載的文件列表
在構(gòu)建了每個(gè)路由與其需要預(yù)加載頁(yè)面的映射后,下一步是確定組成該頁(yè)面代碼塊的文件列表。我建議創(chuàng)建一個(gè)插件(以 Rsbuild 為例潘鲫,但代碼也可以輕松適配 Webpack)翁逞,用于檢查編譯輸出并確定每個(gè)代碼塊所依賴(lài)文件的名稱(chēng)。
需要注意溉仑,這里涉及多個(gè)文件挖函,因?yàn)閱蝹€(gè)代碼塊可能依賴(lài)其他代碼塊。例如浊竟,假設(shè)我們有兩個(gè)代碼塊怨喘,一個(gè)用于首頁(yè),一個(gè)用于設(shè)置頁(yè)面振定。如果它們都導(dǎo)入了一個(gè)不屬于入口代碼塊的模塊(如 lodash
)必怜,那么加載這些頁(yè)面時(shí)需要同時(shí)加載 lodash.[hash].js
和 home.[hash].js
/settings.[hash].js
。此外后频,文件的加載順序也很重要梳庆。
幸運(yùn)的是,打包工具通過(guò)其 API 暴露了這些依賴(lài)關(guān)系徘郭,稱(chēng)為 "chunk groups"靠益。
示例配置:
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { chunksPreloadPlugin } from "./rsbuild-chunks-preload-plugin";
import { routeChunkMapping } from "./src/router-chunk-mapping.ts";
export default defineConfig({
plugins: [pluginReact(), chunksPreloadPlugin({ routeChunkMapping })],
});
插件實(shí)現(xiàn):
import type { RsbuildPlugin } from "@rsbuild/core";
type RouteChunkMapping = { [path: string]: string };
type PluginParams = {
routeChunkMapping: RouteChunkMapping;
};
export const chunksPreloadPlugin = (params: PluginParams): RsbuildPlugin => ({
name: "chunks-preload-plugin",
setup: (api) => {
api.processAssets(
{ stage: "report" },
({ assets, sources, compilation }) => {
const { routeChunkMapping } = params;
// 生成異步代碼塊名稱(chēng)與其加載所需文件的映射關(guān)系
const chunkFilesMapping = {};
for (const chunkGroup of compilation.chunkGroups) {
chunkFilesMapping[chunkGroup.name || "undefined"] =
chunkGroup.getFiles();
}
// 構(gòu)建 URL 路徑與需要預(yù)加載文件的映射關(guān)系
const pathToFilesToPreloadMapping = {};
for (const [path, chunkName] of Object.entries(routeChunkMapping)) {
const chunkFiles = chunkFilesMapping[chunkName].filter((file) =>
file.endsWith(".js"),
);
pathToFilesToPreloadMapping[path] = chunkFiles;
}
// 后續(xù)操作待補(bǔ)充
},
);
},
});
需要注意的是,
api.processAssets
也是 Webpack 中的同名 API残揉。將這個(gè)插件移植到 Webpack 基本只需要將api.processAssets
的實(shí)現(xiàn)復(fù)制粘貼到一個(gè) Webpack 插件中即可 ??。
生成預(yù)加載腳本
最后芋浮,我們通過(guò)讓插件將自定義腳本注入 HTML 文件來(lái)完成實(shí)現(xiàn)抱环。該腳本會(huì)在頁(yè)面加載時(shí)(入口代碼塊之前)執(zhí)行,并為當(dāng)前路徑(window.location.pathname
)需要預(yù)加載的每個(gè)文件添加 <link rel="preload">
纸巷。
插件實(shí)現(xiàn)代碼
import type { RsbuildPlugin } from "@rsbuild/core";
type RouteChunkMapping = { [path: string]: string };
type PluginParams = {
routeChunkMapping: RouteChunkMapping;
};
export const chunksPreloadPlugin = (params: PluginParams): RsbuildPlugin => ({
name: "chunks-preload-plugin",
setup: (api) => {
api.processAssets(
{ stage: "report" },
({ assets, sources, compilation }) => {
const { routeChunkMapping } = params;
// 生成異步代碼塊名稱(chēng)與其加載所需文件的映射關(guān)系
const chunkFilesMapping = {};
for (const chunkGroup of compilation.chunkGroups) {
chunkFilesMapping[chunkGroup.name || "undefined"] = chunkGroup.getFiles();
}
// 構(gòu)建 URL 路徑與需要預(yù)加載文件的映射關(guān)系
const pathToFilesToPreloadMapping = {};
for (const [path, chunkName] of Object.entries(routeChunkMapping)) {
const chunkFiles = chunkFilesMapping[chunkName].filter((file) =>
file.endsWith(".js"),
);
pathToFilesToPreloadMapping[path] = chunkFiles;
}
// 生成用于預(yù)加載異步代碼塊的腳本
const scriptToInject = generatePreloadScriptToInject(pathToFilesToPreloadMapping);
// 將生成的腳本注入到 index.html 的 <head> 中镇草,在其他腳本之前
const indexHTML = assets["index.html"];
if (!indexHTML) {
return;
}
const oldIndexHTMLContent = indexHTML.source();
const firstScriptInIndexHTMLIndex = oldIndexHTMLContent.indexOf("<script");
const newIndexHTMLContent = `${oldIndexHTMLContent.slice(
0,
firstScriptInIndexHTMLIndex,
)}${scriptToInject}${oldIndexHTMLContent.slice(
firstScriptInIndexHTMLIndex,
)}`;
const source = new sources.RawSource(newIndexHTMLContent);
compilation.updateAsset("index.html", source);
},
);
},
});
// 生成注入到 HTML 的預(yù)加載腳本
const generatePreloadScriptToInject = (pathToFilesToPreloadMapping: {
[path: string]: Array<string>;
}): string => {
const scriptContent = `
try {
(function () {
const pathToFilesToPreloadMapping = ${JSON.stringify(pathToFilesToPreloadMapping)};
const filesToPreload = pathToFilesToPreloadMapping[window.location.pathname];
if (!filesToPreload) return;
for (const fileToPreload of filesToPreload) {
const preloadLinkEl = document.createElement("link");
preloadLinkEl.setAttribute("href", fileToPreload);
preloadLinkEl.setAttribute("rel", "preload");
preloadLinkEl.setAttribute("as", "script");
document.head.appendChild(preloadLinkEl);
}
})();
} catch (err) {
console.warn("Unable to run the scripts preloading.");
}
`;
const script = `<script>${scriptContent}</script>`;
return script;
};
現(xiàn)在,當(dāng)前頁(yè)面的所有異步代碼塊會(huì)與入口代碼塊并行加載瘤旨,提升加載性能梯啤。
進(jìn)一步優(yōu)化建議
增強(qiáng)路由邏輯
上例中預(yù)加載腳本的路徑識(shí)別邏輯較為簡(jiǎn)單〈嬲埽可以?xún)?yōu)化插件的 API因宇,使其支持與 React Router(或其他路由庫(kù))一致的配置。實(shí)際場(chǎng)景可能涉及更復(fù)雜的路徑祟偷,例如 /user/:user-id
察滑,這需要實(shí)現(xiàn)動(dòng)態(tài)路徑識(shí)別和模式匹配來(lái)支持更強(qiáng)大的路由方案。
壓縮注入腳本
對(duì)于擁有數(shù)百個(gè)代碼塊的大型 SPA修肠,硬編碼到預(yù)加載腳本中的代碼塊可能會(huì)導(dǎo)致腳本過(guò)大贺辰。你可以通過(guò)以下方式優(yōu)化腳本大小:
- 對(duì)腳本代碼進(jìn)行 壓縮(minify)。
- 避免重復(fù)的代碼塊 URL(或其子路徑)饲化。
將預(yù)加載 API 暴露出來(lái)
可以通過(guò)在全局對(duì)象(如 window
)上暴露預(yù)加載函數(shù)莽鸭,使預(yù)加載執(zhí)行變得可編程。例如:
// 在預(yù)加載腳本中
window.__preloadPathChunks = function (path = window.location.pathname) {
// 腳本邏輯...
};
這樣可以在需要時(shí)手動(dòng)調(diào)用吃靠,比如當(dāng)用戶懸停在某些鏈接上時(shí)預(yù)加載頁(yè)面代碼塊硫眨。
使用 Service Worker 預(yù)緩存代碼塊
另一種優(yōu)化是使用 Service Worker 將 SPA 的所有代碼塊預(yù)緩存。Google 的 Workbox 是一個(gè)常用工具撩笆,可幫助實(shí)現(xiàn)此目的捺球。
探索其他優(yōu)化
還可以考慮其他性能優(yōu)化,例如:
- 確保入口代碼塊加載優(yōu)先級(jí)仍高于預(yù)加載的代碼塊夕冲。
- 在非路由組件級(jí)別進(jìn)行更細(xì)粒度的預(yù)加載整合氮兵。
通過(guò)這些改進(jìn),可以進(jìn)一步優(yōu)化 SPA 的加載性能和用戶體驗(yàn)歹鱼。