优化单页应用加载时间与异步块预加载

原文信息: 查看原文查看原文

Optimizing SPA load times with async chunks preloading

- Mazzarolo Matteo

大家好!在这篇文章中,我将解释如何通过避免基于路由的延迟加载引起的瀑布效应来提高客户端渲染应用的性能。我们将通过注入一个自定义脚本来预加载当前路由的块,确保它们与入口点块并行下载。我将使用 Rsbuild 进行脚本注入,但其代码也可以很容易地适应Webpack和其他打包器。

代码片段基于一个只有两个页面的小型应用程序:一个主页(位于//home)和一个设置页面(位于/settings)。

基于路由的代码拆分

在客户端渲染的应用中,代码拆分是您可以使用的主要策略之一,以提高整体性能。代码拆分使您只加载必要的代码块,而不是一开始就加载所有内容。

实现代码拆分的最常见方法是通过延迟加载路由(或页面)块。这意味着这些块仅在用户访问相应页面时才加载,而不是提前加载。这不仅减少了加载应用程序所需的捆绑包的大小,还改善了缓存:您的应用程序捆绑包越拆分成块,发生的缓存失效就越少(只要静态文件适当地进行了哈希处理)。

像Next.js和Remix这样的服务器端渲染框架通常会为您处理代码拆分和延迟加载。对于客户端渲染的单页应用程序,您可以通过延迟加载您将在路由器中使用的路由组件来实现这一点:

const Home = lazy(() => import("./pages/home-page"));
const Settings = lazy(() => import("./pages/settings-page"));

有了这个设置,当用户访问您应用程序的/路由时,只会下载主页块(例如,home.[hash].js)。设置页面块在需要时(例如,当您导航到设置页面时)才会被下载。

延迟加载的缺点

虽然代码拆分提供了多种好处,但它也有一些缺点。默认情况下,块仅在需要时下载,这可以在两个方面引入明显的延迟:

  1. 初始加载延迟:当应用程序首次加载时,加载入口点块(例如,带有客户端路由器的顶级应用程序)和加载初始页面(例如,主页)之间存在延迟。这是因为浏览器首先下载、解析并运行应用程序的入口点。然后应用程序路由器确定它在需要加载主页的路由上,并提示浏览器下载、解析并运行主页代码。
  2. 导航延迟:同样,每次您在不同页面之间导航时都存在延迟。这是因为浏览器仅在开始导航时下载、解析并运行新块(例如,设置页面块仅在从主页点击“设置”链接时加载)。

一个坚实的缓存策略(例如,将这些块标记为不可变并预先缓存它们)和使用具有预加载能力的路由器可以缓解第二点。我可能会在后续文章中更深入地探讨这些主题。现在,让我们专注于解决第一点。

预加载异步页面

我们的目标是解决页面必须等待入口点块请求它们才能下载的瀑布问题:

我们已经知道,如果用户导航到“/”,应该下载主页块。没有理由等待应用程序完全加载才开始下载主页块,对吧?所以,我们应该/可以与入口点块并行下载它。

根据我的经验,实现这一点的最佳方法是在HTML的头部注入一个小脚本,以预加载当前访问URL的异步块。

从非常高的层面来看,这个想法是使用构建工具(这里,Rsbuild)将一个小脚本注入到文档的头部。这个脚本包含每个路由和应该为该路由预加载的文件之间的映射。执行时,它通过手动将它们作为link rel="preload"添加到HTML页面中,来预加载当前路径所需的文件。

让我们更深入地了解一个实现示例。

在异步导入中添加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:

// 路径和它们的webpackChunkNames之间的映射
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;
        }
        // TBD — 见下一部分
      },
    );
  },
});

注意 api.processAssets 是在Webpack中也可用的API。将这个插件移植到Webpack主要是将api.processAssets实现复制粘贴到Webpack插件中 👍。

生成预加载脚本

最后,我们通过使插件注入一个自定义脚本到HTML文件中来完成插件。该脚本在页面加载时执行入口点块之前,并为当前路径应该预加载的每个文件添加一个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;
        }
        // 生成负责预加载异步块文件的(字符串化的)脚本(基于当前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关联的块的每个文件添加预加载链接。
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),所以考虑实现动态路径识别和模式匹配,以获得更强大的路由解决方案。

压缩注入的脚本

较大的单页应用程序可能有数百个块。由于块被硬编码到预加载脚本中,所以重要的是确保它不会变得太大并成为瓶颈。您可以采用策略来压缩脚本大小,例如,压缩脚本代码并避免重复块URL(或它们的子路径)。

从脚本公开预加载API

您可以进一步扩展脚本,通过使预加载执行程序化,允许在运行时调用它。这可以通过将预加载函数暴露在window对象上并将路径作为一个参数而不是总是使用当前的来实现,例如:

// 在预加载脚本中
window.__preloadPathChunks = function (path = window.location.pathname) {
  // ...脚本代码
}`

这使得在需要时,例如当鼠标悬停在URL上时,可以从您的SPA调用该函数。

使用Service Worker预缓存所有SPA块

我将在这里简要提及这一点,尽管它可能值得单独发表。作为前面提到的子点的替代方案,以及作为解决“延迟加载缺点”部分中提到的第一个缺点的解决方案,我建议使用Service Worker预缓存所有应用程序块Google的Workbox 是我的首选解决方案进行预缓存。

探索其他优化

最后但同样重要的是,也许考虑其他性能优化,如确保入口点块仍然以比预加载路由更高的优先级加载,将预加载集成到更细粒度的非基于路由的组件中等等。

分享于 2024-08-26

访问量 16

预览图片