包阅导读总结
1.
关键词:异步 chunk 预加载、SPA 加载时间、代码拆分、客户端渲染、路由
2.
总结:本文介绍了通过异步 chunk 预加载优化 SPA 加载时间,阐述了代码拆分的优势与缺点,给出了预加载的实现示例,并探讨了进一步改进的方向和其他优化策略。
3.
主要内容:
– 优化 SPA 加载时间
– 介绍通过异步代码块预加载来优化单页应用程序(SPA)加载时间
– 分享者和翻译者
– 代码拆分及缺点
– 小型应用代码拆分示例
– 延迟加载路由的优点
– 延迟加载的缺点,包括初始加载延迟和导航延迟
– 预加载异步页面
– 目标是解决瀑布流问题
– 注入小脚本预加载当前访问 URL 的异步 chunk
– 实现示例,包括添加魔法注释、生成和注入脚本逻辑等
– 进一步改进
– 巩固路由逻辑
– 压缩注入脚本
– 暴露预加载 API
– 使用 Service Worker 预缓存
– 探索其他优化
思维导图:
文章地址:https://mp.weixin.qq.com/s/S8GlnKlnecdp6byufDCDTg
文章来源:mp.weixin.qq.com
作者:Mazzarolo??Matteo
发布时间:2024/8/27 0:01
语言:中文
总字数:3276字
预计阅读时间:14分钟
评分:84分
标签:前端优化,SPA,异步加载,性能优化,Webpack
以下为原文内容
本内容来源于用户推荐转载,旨在分享知识与观点,如有侵权请联系删除 联系邮箱 media@ilingban.com
前言
介绍了如何通过异步代码块预加载来优化单页应用程序(SPA)的加载时间。今日前端早读课文章由 @Mazzarolo Matteo 分享,@飘飘翻译。
正文从这开始~~
大家好!在这篇文章中,我将解释如何通过避免基于路由的延迟加载引起的瀑布效应来提升客户端渲染应用的性能。我们将通过注入一个自定义脚本来预加载当前路由的 chunk,确保它们与入口 chunk 并行下载。我将使用 Rsbuild 来进行脚本注入,但其代码也可以轻松适配 Webpack 及其他打包工具。
【第3342期】大型单页应用中的灵活网络数据预加载
代码示例基于一个仅有两个页面的小型应用:一个位于/
和/home
路径下的主页,以及一个位于/settings
路径下的设置页。
基于路由的代码拆分
在客户端渲染的应用中,代码拆分是提高整体性能的主要策略之一。代码拆分可以使你只加载必要的代码 chunk,而不是一次性加载所有内容。
最常见的代码拆分实现方式是通过延迟加载路由(或页面)chunk。这意味着这些 chunk 只会在用户访问相应页面时加载,而不是提前加载。这不仅减少了加载应用所需的 bundle 大小,还可以提高缓存性能:你的应用 bundle 拆分得越多,缓存失效的几率就越小(只要静态文件被适当哈希化)。
像 Next.js 和 Remix 这样的服务端渲染框架通常会为你处理代码拆分和延迟加载。对于客户端渲染的单页应用(SPA),你可以通过延迟加载路由组件来实现这一点:
const Home = lazy(() => import("./pages/home-page"));
const Settings = lazy(() => import("./pages/settings-page"));
通过这种设置,当用户访问你的应用的/
路由时,只有主页的 chunk(例如home.[hash].js
)会被下载。设置页的 chunk 直到需要时才会被下载(例如,当你导航到设置页时)。
延迟加载的缺点
虽然代码拆分有多重优势,但它也有一些缺点。默认情况下,chunk 只有在需要时才会下载,这会在两个方面引入明显的延迟:
-
初始加载延迟:当应用首次加载时,从下载入口 chunk(例如,顶层应用与客户端路由器)到加载初始页面(例如,主页)之间存在延迟。这是因为浏览器首先下载、解析并运行应用入口点,然后应用路由器确定它处于需要加载主页的路由上,并促使浏览器下载、解析并运行主页代码。
-
导航延迟:同样,每次在不同页面之间导航时也会有延迟。这是因为浏览器仅在导航开始时下载、解析并运行新的 chunk(例如,设置页面的 chunk 只会在点击主页的 “设置” 链接时加载)。
一个有效的缓存策略(例如,将这些 chunk 标记为不可变并预缓存它们)以及使用具有预加载功能的路由器,可以缓解第二点的影响。我可能会在后续文章中深入探讨这些主题。现在,让我们专注于解决第一点。
预加载异步页面
我们的目标是解决瀑布流问题,即页面必须等待入口 chunk 请求它们之后才能开始下载:
我们已经知道,如果用户导航到/
,则应该下载主页部分。因此,没有必要等到应用完全加载后才开始下载主页 chunk,对吗?因此,我们应该 / 可以让它与入口 chunk 并行下载。
根据我的经验,实现这一目标的最佳方式是在 HTML 的 head 中注入一个小脚本,以预加载当前访问的 URL 的异步 chunk。
【第3043期】不一样的”代码拆分”+”预加载”实现应用性能及体验兼得
从一个非常高的层次上讲,这个想法是使用构建工具(在这里是 Rsbuild)将一个小脚本注入文档的 head 中。这个脚本保存了每个路由与应为该路由预加载的文件之间的映射。当脚本执行时,它通过手动将它们添加到 HTML 页面中,以link rel="preload"
形式预加载当前路径所需的文件。
让我们深入了解一个实现示例。
在异步导入中添加webpackChunkName
魔法注释。
脚本生成和注入逻辑必须在打包工具层面进行,因为在构建完成之前我们无法知道 chunk 文件名。例如,如果我们遵循良好的缓存实践,主页 chunk 可能会在其名称中包含哈希(例如page.12ab33.js
),这是由打包工具分配的。
为了确定是否应预加载某个 chunk,我建议维护一个页面路径与其webpackChunkName
之间的映射。webpackChunkName
是一个 magic comment(魔术注释),被多个打包工具支持,可以用来为 JavaScript chunk 分配一个可读名称,打包工具可以访问这个名称:
const Home = lazy(
() => import(/* webpackChunkName: "home" */ "./pages/home-page"),
);
const Settings = lazy(
() => import(/* webpackChunkName: "settings" */ "./pages/settings-page"),
);
route-chunk-mapping.ts
// 路径与其 webpackChunkNames 之间的映射
export const routeChunkMapping = {
"/": "home",
"/home": "home",
"/settings": "settings",
};
构建每个路由所需加载的文件列表
有了每个路由与我们想要预加载的页面之间的映射,接下来的步骤是确定组成该页面 chunk 的文件。我建议创建一个插件(适用于 Rsbuild,但代码也可以轻松适配 Webpack)来检查编译输出,以确定每个 chunk 依赖的文件名称。
请注意,我们说的是多个文件,因为单个 chunk 可能依赖其他 chunk。例如,假设我们有两个 chunk,一个用于主页,一个用于设置页面。如果它们都导入了同一个模块(比如 lodash),而该模块不属于入口 chunk,那么要加载它们,我们需要加载 chunk:lodash.[hash].js
和home.[hash].js
/settings.[hash].js
。同时,请注意顺序很重要。
幸运的是,打包工具在其 API 中将这些依赖项作为 “分组” 进行暴露。
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;
// 生成异步 chunk 名称与其所需加载的文件之间的映射
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;
}
// 生成预加载异步 chunk 文件的脚本(基于当前 URL)
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 的脚本
// 它会检查当前的 URL,并为与该 URL 关联的 chunk 的每个文件添加预加载链接
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("无法运行脚本预加载。");
}
`;
const script = `<script>${scriptContent}</script>`;
return script;
};
注意, api.processAssets 与 Webpack 提供的相同 API。将此插件移植到 Webpack 主要是将 api.processAssets 实现复制粘贴到 Webpack 插件中👍。
生成预加载脚本
最后,我们通过让插件向 HTML 文件注入自定义脚本来完成插件的编写。该脚本在加载页面之前执行,并在入口点代码块之前添加一个用于预加载当前路径上每个文件的link rel="preload"
标记(window.location.pathname
)。
如此一来,当前页面的所有异步 chunk 都将与入口 chunk 并行加载。
进一步改进
与任何模式一样,有许多方法可以改进此流程。为了简洁起见,我将一些实现细节留给读者。
如果你打算在生产环境中使用这种模式,你可能至少需要改进以下几个方面。
巩固路由逻辑
上面示例中预加载脚本使用的路径识别相当基础,因此我建议调整插件 API,以接受与 React Router(或你使用的任何路由器)相同的配置。在示例中,我们只使用了顶级路径,但现实世界中的场景更复杂,需要子路径检查(例如,/user/:user-id
),因此考虑实现动态路径识别和模式匹配,以获得更健壮的路由解决方案。
压缩注入的脚本
较大的 SPA 可能有数百个 chunk。由于 chunk 是硬编码到预加载脚本中的,因此必须确保它不会变得太大而成为瓶颈。你可以采用压缩脚本大小的策略,例如压缩脚本代码并避免重复 chunk URL(或其子路径)。
从脚本中暴露预加载 API
你还可以进一步扩展脚本,使预加载执行过程可编程化,使其在运行时被调用。可以通过在window
对象上暴露预加载函数并将路径作为参数,而不是始终使用当前路径来实现,例如:
// 在预加载脚本中
window.__preloadPathChunks = function (path = window.location.pathname) {
// ...脚本代码
}
这样就可以在需要的时候从 SPA 中调用该函数,例如在鼠标悬停在 URL 上时。
使用 Service Worker 预缓存所有 SPA 的 chunk
我在这里简单提一下,虽然它可能值得单独写一篇文章。作为对前一小节的替代方案,并作为解决 “延迟加载缺点” 部分中提到的第一个缺点的解决方案,我建议使用 Service Worker 预缓存你所有应用的 chunk。Google 的 Workbox 是我首选的预缓存解决方案。
探索其他优化
最后但同样重要的是,也许可以考虑其他性能优化,例如确保入口 chunk 仍然以比预加载路由更高的优先级加载,在更细粒度的层次上集成预加载以处理非基于路由的组件,等等。
关于本文
译者:@飘飘
作者:@Mazzarolo Matteo
原文:https://mmazzarolo.com/blog/2024-08-13-async-chunk-preloading-on-load
这期前端早读课
对你有帮助,帮”赞“一下,
期待下一期,帮”在看” 一下 。