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

在本文中,将解释如何通过避免基于路由的懒加载引发的瀑布效应,提升客户端渲染应用的性能。我们会通过注入一个自定义脚本来预加载当前路由的代码块,确保这些代码块能与入口代码块并行下载。我将使用 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].jshome.[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 的加载性能和用户体验。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容