【优化】2266- 优化单页应用 (SPA) 加载时间:异步代码块预加载

科技   2024-12-09 07:36   福建  

在本文中,将解释如何通过避免基于路由的懒加载引发的瀑布效应,提升客户端渲染应用的性能。我们会通过注入一个自定义脚本来预加载当前路由的代码块,确保这些代码块能与入口代码块并行下载。我将使用 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)只有在用户导航到设置页面时才会下载。

懒加载的缺点

尽管代码拆分有很多好处,但也存在一些缺点。默认情况下,代码块只有在需要时才会下载,这可能导致以下两种明显的延迟:

  1. 初始加载延迟:在应用首次加载时,会存在从加载入口代码块(如顶层应用及客户端路由器)到加载初始页面代码块(如首页)的延迟。这是因为浏览器需要先下载、解析并执行入口代码块,接着应用路由器决定当前是首页路由,然后再提示浏览器下载、解析并执行首页代码。

  2. 导航延迟:同样地,每次在页面之间导航时也会有延迟。这是因为浏览器只会在导航开始时下载、解析并执行新的代码块(例如,只有点击“设置”链接时才会加载设置页面的代码块)。

一个稳健的缓存策略(例如,将这些代码块标记为不可变并预缓存它们)和使用支持预加载功能的路由器可以缓解第二点。我可能会在后续文章中更深入地探讨这些话题。而现在,我们将重点解决第一个问题

预加载异步页面

我们的目标是解决瀑布问题,即在页面可以下载之前,必须等待入口代码块完成请求的情况:

我们已经知道,当用户导航到 / 路径时,应该下载首页代码块。没有必要等到应用完全加载后再开始下载首页代码块,对吧?因此,我们可以让它与入口代码块并行下载。

根据我的经验,最好的方法是通过在 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 的加载性能和用户体验。

前端自习课
每日清晨,享受一篇前端优秀文章。
 最新文章