在本文中,将解释如何通过避免基于路由的懒加载引发的瀑布效应,提升客户端渲染应用的性能。我们会通过注入一个自定义脚本来预加载当前路由的代码块,确保这些代码块能与入口代码块并行下载。我将使用 Rsbuild 来实现脚本注入,但代码可以很容易地适配到 Webpack 和其他打包工具。
代码示例基于一个只有两个页面的小型应用:一个首页(路径为 /
和 /home
)和一个设置页面(路径为 /settings
)。
基于路由的代码拆分
在客户端渲染的应用中,代码拆分是提升整体性能的主要策略之一。通过代码拆分,可以只加载必要的代码块,而不是一次性加载全部代码。
最常见的实现代码拆分的方法是通过懒加载路由(或页面)对应的代码块。这意味着只有当用户访问相应页面时,这些代码块才会加载,而不会提前加载。这不仅减少了加载应用所需的包大小,还优化了缓存:应用包拆分得越细,缓存失效的概率就越小(前提是静态文件正确地使用了哈希处理)。
像 Next.js 和 Remix 这样的服务端渲染框架通常会自动处理代码拆分和懒加载。而对于客户端渲染的单页应用,你可以通过在路由中懒加载需要的组件来实现代码拆分:
const Home = lazy(() => import("./pages/home-page"));
const Settings = lazy(() => import("./pages/settings-page"));
在这种设置下,当用户访问应用的 /
路由时,只有首页对应的代码块(例如 home.[hash].js
)会被下载。设置页面的代码块(例如 settings.[hash].js
)只有在用户导航到设置页面时才会下载。
懒加载的缺点
尽管代码拆分有很多好处,但也存在一些缺点。默认情况下,代码块只有在需要时才会下载,这可能导致以下两种明显的延迟:
初始加载延迟:在应用首次加载时,会存在从加载入口代码块(如顶层应用及客户端路由器)到加载初始页面代码块(如首页)的延迟。这是因为浏览器需要先下载、解析并执行入口代码块,接着应用路由器决定当前是首页路由,然后再提示浏览器下载、解析并执行首页代码。
导航延迟:同样地,每次在页面之间导航时也会有延迟。这是因为浏览器只会在导航开始时下载、解析并执行新的代码块(例如,只有点击“设置”链接时才会加载设置页面的代码块)。
一个稳健的缓存策略(例如,将这些代码块标记为不可变并预缓存它们)和使用支持预加载功能的路由器可以缓解第二点。我可能会在后续文章中更深入地探讨这些话题。而现在,我们将重点解决第一个问题。
预加载异步页面
我们的目标是解决瀑布问题,即在页面可以下载之前,必须等待入口代码块完成请求的情况:
我们已经知道,当用户导航到 /
路径时,应该下载首页代码块。没有必要等到应用完全加载后再开始下载首页代码块,对吧?因此,我们可以让它与入口代码块并行下载。
根据我的经验,最好的方法是通过在 HTML 的 <head>
中注入一个小型脚本,预加载当前访问 URL 的异步代码块。
从高层次来看,这个方法是使用构建工具(本文中是 Rsbuild)将一个小型脚本注入到 HTML 文档的 <head>
中。这个脚本包含每个路由与其需要预加载文件的映射关系。在执行时,它通过手动将这些文件添加为 <link rel="preload">
的形式来预加载当前路径所需的文件。
让我们深入了解具体实现示例。
为异步导入添加 webpackChunkName
注释
由于在构建完成之前,我们无法知道代码块文件的具体名称,因此脚本生成和注入逻辑必须在打包工具层面实现。例如,根据良好的缓存实践,首页代码块的文件名可能包含哈希值(如 page.12ab33.js
),这个名称由打包工具分配。
为了确定是否应该预加载某个代码块,建议维护页面路径与其 webpackChunkName
的映射关系。webpackChunkName
是一个支持多个打包工具的注释,可以用来为 JavaScript 代码块分配一个可读名称,供打包工具访问:
const Home = lazy(() => import(/* webpackChunkName: "home" */ "./pages/home-page"));
const Settings = lazy(() => import(/* webpackChunkName: "settings" */ "./pages/settings-page"));
route-chunk-mapping.ts
// 路径与其 webpackChunkName 的映射关系
export const routeChunkMapping = {
"/": "home",
"/home": "home",
"/settings": "settings",
};
为每个路由构建需要加载的文件列表
在构建了每个路由与其需要预加载页面的映射后,下一步是确定组成该页面代码块的文件列表。我建议创建一个插件(以 Rsbuild 为例,但代码也可以轻松适配 Webpack),用于检查编译输出并确定每个代码块所依赖文件的名称。
需要注意,这里涉及多个文件,因为单个代码块可能依赖其他代码块。例如,假设我们有两个代码块,一个用于首页,一个用于设置页面。如果它们都导入了一个不属于入口代码块的模块(如 lodash
),那么加载这些页面时需要同时加载 lodash.[hash].js
和 home.[hash].js
/settings.[hash].js
。此外,文件的加载顺序也很重要。
幸运的是,打包工具通过其 API 暴露了这些依赖关系,称为 "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 })],
});
插件实现:
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;
// 生成异步代码块名称与其加载所需文件的映射关系
const chunkFilesMapping = {};
for (const chunkGroup of compilation.chunkGroups) {
chunkFilesMapping[chunkGroup.name || "undefined"] =
chunkGroup.getFiles();
}
// 构建 URL 路径与需要预加载文件的映射关系
const pathToFilesToPreloadMapping = {};
for (const [path, chunkName] of Object.entries(routeChunkMapping)) {
const chunkFiles = chunkFilesMapping[chunkName].filter((file) =>
file.endsWith(".js"),
);
pathToFilesToPreloadMapping[path] = chunkFiles;
}
// 后续操作待补充
},
);
},
});
需要注意的是,
api.processAssets
也是 Webpack 中的同名 API。将这个插件移植到 Webpack 基本只需要将api.processAssets
的实现复制粘贴到一个 Webpack 插件中即可 👍。
生成预加载脚本
最后,我们通过让插件将自定义脚本注入 HTML 文件来完成实现。该脚本会在页面加载时(入口代码块之前)执行,并为当前路径(window.location.pathname
)需要预加载的每个文件添加 <link rel="preload">
。
插件实现代码
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;
// 生成异步代码块名称与其加载所需文件的映射关系
const chunkFilesMapping = {};
for (const chunkGroup of compilation.chunkGroups) {
chunkFilesMapping[chunkGroup.name || "undefined"] = chunkGroup.getFiles();
}
// 构建 URL 路径与需要预加载文件的映射关系
const pathToFilesToPreloadMapping = {};
for (const [path, chunkName] of Object.entries(routeChunkMapping)) {
const chunkFiles = chunkFilesMapping[chunkName].filter((file) =>
file.endsWith(".js"),
);
pathToFilesToPreloadMapping[path] = chunkFiles;
}
// 生成用于预加载异步代码块的脚本
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 的预加载脚本
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;
};
现在,当前页面的所有异步代码块会与入口代码块并行加载,提升加载性能。
进一步优化建议
增强路由逻辑
上例中预加载脚本的路径识别逻辑较为简单。可以优化插件的 API,使其支持与 React Router(或其他路由库)一致的配置。实际场景可能涉及更复杂的路径,例如 /user/:user-id
,这需要实现动态路径识别和模式匹配来支持更强大的路由方案。
压缩注入脚本
对于拥有数百个代码块的大型 SPA,硬编码到预加载脚本中的代码块可能会导致脚本过大。你可以通过以下方式优化脚本大小:
- 对脚本代码进行 压缩(minify)。
- 避免重复的代码块 URL(或其子路径)。
将预加载 API 暴露出来
可以通过在全局对象(如 window
)上暴露预加载函数,使预加载执行变得可编程。例如:
// 在预加载脚本中
window.__preloadPathChunks = function (path = window.location.pathname) {
// 脚本逻辑...
};
这样可以在需要时手动调用,比如当用户悬停在某些链接上时预加载页面代码块。
使用 Service Worker 预缓存代码块
另一种优化是使用 Service Worker 将 SPA 的所有代码块预缓存。Google 的 Workbox 是一个常用工具,可帮助实现此目的。
探索其他优化
还可以考虑其他性能优化,例如:
- 确保入口代码块加载优先级仍高于预加载的代码块。
- 在非路由组件级别进行更细粒度的预加载整合。
通过这些改进,可以进一步优化 SPA 的加载性能和用户体验。