12k
All articles

前端应用的代码分割策略

比较前端代码拆分策略:拆分路由和大型组件,在交互时预加载代码,并通过网络瀑布图衡量性能效果。

OpenReplay Team
OpenReplay Team
前端应用的代码分割策略

代码分割(code splitting)将一个 JavaScript bundle 拆分为多个独立的 chunk,浏览器只在真正需要其中的代码时才下载对应的 chunk。这样一来,首屏加载只需为首屏本身付出代价,而不必为整个应用买单。

Lighthouse 报告了一大堆未使用的 JavaScript,你也已经给某个组件加上了 React.lazy,但生产环境依然只输出一个庞大的入口 chunk。解决办法并不是到处添加 lazy() 调用。你需要清楚哪些分割值得做、每种分割的代价是什么,以及如何判断它是否真正生效。

本文按照通常的收益顺序介绍五种策略,讲解如何衡量其效果,以及代码分割反而让应用变慢的三种情况。

核心要点

  • 几乎在所有单页应用中,基于路由的分割都应当是第一步:每个路由成为独立的 chunk,访问者只需下载其进入的那个路由。
  • webpack 5 和 Rolldown(Vite 8 所用的打包工具)已经会把懒加载 chunk 之间的共享代码提取到公共 chunk 中,因此如今手动构建 vendor.js 的主要意义在于缓存稳定性,而非减小体积。
  • 如果在 hover 或 focus 时就触发 import(),而不是等到 click,那么 chunk 会在用户还在犹豫是否点击时就开始下载。
  • 入口 chunk 变小本身说明不了任何问题。真正的证据是:在网络节流条件下的瀑布图中,首屏所需代码比之前更早完成加载。
  • 对首屏必需的代码进行分割并不能消除等待,只是把等待推迟了。

为什么单一 bundle 会拖慢首屏?

不做分割时,单页应用只会输出一个入口 bundle,其中包含应用可能用到的所有路由、弹窗和第三方库。一个进入营销页的访问者,也要下载、解析并执行设置页、管理后台和图表库的代码,哪怕他根本不会打开这些页面。这些未使用的代码会与真正负责渲染页面的代码争抢带宽和主线程时间。

下面五种代码分割策略各自有不同的触发条件、不同的代价,以及不同的验证方式:

策略适用场景代价如何验证效果
路由分割任何多路由 SPA每次导航时显示 fallback入口 chunk 中不再包含其他路由
组件分割需要交互才显示或位于首屏以下的重型 UI一个加载状态和一次额外请求chunk 仅在触发时加载
手动 vendor chunk希望长期缓存的稳定依赖配置维护成本,以及可能改变执行顺序多次部署之间 hash 保持不变
交互预取点击概率较高的操作偶尔会获取无人使用的代码请求在点击之前就已发起
不分割首屏必需的代码无没有新增的请求链

首先做基于路由的代码分割

几乎在所有单页应用中,基于路由的分割都是第一个值得做的分割。每个路由成为独立的 chunk,访问者只需下载其进入的那个路由。它只需少量代码,而且路由切换本身就是展示短暂加载状态的自然时机。理解 React 动态导入一文详细介绍了如何结合 React.lazy 与 Suspense 实现这一模式。Next.js 和 SvelteKit 等元框架会自动按路由进行分割。

将需要交互才出现的重型组件拆分出去

组件级分割适用于体积大且在首次渲染时不可见的 UI:弹窗、富文本编辑器、图表组件、首屏以下的评论区等。代价是需要一个加载状态,以及在用户想使用该功能时发起一次网络请求。

各主流框架都基于动态 import() 提供了相同的基础能力:

// React
import { lazy } from 'react';
const Chart = lazy(() => import('./Chart'));
// Next.js
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./Chart'));
// Vue
import { defineAsyncComponent } from 'vue';
const Chart = defineAsyncComponent(() => import('./Chart.vue'));

React 的懒加载组件需要在 Suspense 边界内渲染,chunk 加载期间由 Suspense 显示 fallback。在 Next.js 中,next/dynamic 将 React.lazy() 和 Suspense 封装成一次调用;Vue 则提供了 defineAsyncComponent。Svelte 直接使用 import() 配合条件渲染。

对于首屏以下的内容,可以在元素接近视口时触发加载。创建一个 Intersection Observer,在首次相交后断开它,然后渲染懒加载组件。JavaScript 中的懒加载一文介绍了相关的 observer 模式。

打包工具已经替你做了哪些分割

webpack 5 默认的 splitChunks 配置,以及 Rollup(Vite 8 中为 Rolldown),都会自动将多个懒加载 chunk 共享的代码提取到公共 chunk 中。默认情况下,webpack 只对异步 chunk(chunks: 'async')执行此操作,并且只在新 chunk 超过最小体积阈值时才会提取。其内置的 defaultVendors 缓存组已经会匹配 node_modules。JavaScript 打包工具现状一文概述了各打包工具目前的发展情况。

由于打包工具已经会提取共享代码,手动构建单个 vendor.js 在很大程度上只是重复了 webpack 或 Rolldown 的工作。如今手动定义 vendor chunk 的主要理由是缓存稳定性:如果很少变动的依赖在多次部署之间保持相同的 content hash,回访用户就无需重复下载它们。

// webpack 5
module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        framework: {
          test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
          name: 'framework',
          chunks: 'all',
        },
      },
    },
  },
};

这里必须设置 chunks: 'all'。否则,被入口导入的框架代码会留在入口 chunk 中,导致入口的 hash 在每次部署时都发生变化。

在 Vite 7 及更早版本中,同样的思路通过 build.rollupOptions.output.manualChunks 实现。Vite 8 改用 Rolldown 构建,不再支持对象形式的 manualChunks,并弃用了函数形式,转而推荐使用 codeSplitting groups:

// Vite 8 (Rolldown)
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [{ name: 'vendor', test: /node_modules[\\/](react|react-dom)[\\/]/ }],
        },
      },
    },
  },
});

分组范围应尽量收窄。Rolldown 文档提醒,手动创建的分组可能会改变模块的执行顺序,这对带有副作用的模块影响很大。

在 hover 时开始加载,而不是等到 click

交互预取是指在指针移入按钮或按钮获得焦点时就触发 import(),而不是等到点击时才触发,这样 chunk 会在用户仍在犹豫时就开始下载。下载过程与”产生意图到实际操作”之间的间隔重叠,因此 fallback 只会短暂出现,甚至完全不出现。

import { lazy, Suspense, useState } from 'react';

const loadEditor = () => import('./RichTextEditor');
const RichTextEditor = lazy(loadEditor);

function ReplyBox() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onMouseEnter={loadEditor} onFocus={loadEditor} onClick={() => setOpen(true)}>
        Reply
      </button>
      {open && <Suspense fallback={<p>Loading editor…</p>}><RichTextEditor /></Suspense>}
    </>
  );
}

lazy 和事件处理函数共用同一个加载函数。模块系统会缓存第一次 import() 的结果,因此点击后的渲染会复用已经在进行中的请求。代价是:用户 hover 但没有点击时,会浪费一些带宽。

webpackPrefetch 之类的提示属于 webpack magic comments。Next.js 中的 Turbopack 能识别少量与 webpack 兼容的 magic comments,但其文档只列出了 webpackIgnore 等用于控制导入的注释,并未包含 webpackPrefetch;其他打包工具通常也不识别这些注释。依赖它们之前,请先查阅所用打包工具的文档。而从事件处理函数中触发 import() 的做法适用于所有打包工具。

衡量瀑布图,而不是 bundle 体积

入口 chunk 变小本身说明不了任何问题。证明分割有效的依据是:在网络节流条件下的瀑布图中,首屏所需代码比之前更早完成加载。

  1. 使用适合你所用打包工具的 bundle 可视化工具,确认每个 chunk 包含哪些内容。对于 webpack,Webpack Bundle Analyzer 使用指南介绍了如何找出重型模块和页面未使用的代码。
  2. 打开 Chrome DevTools 的 Network 面板,筛选 JS 请求,并应用移动网络节流配置。
  3. 对比分割前后的结果:检查首次渲染所需的最后一个 chunk 何时完成加载,以及是否出现了新的串行请求链。

请求链看起来像一段阶梯:每个 chunk 请求都要等上一个请求结束后才开始。嵌套的懒加载导入就会产生这种模式。

代码分割什么时候会让应用变慢?

代码分割会以三种可预见的方式适得其反。

chunk 过多过碎。 每个 chunk 都是一次独立请求。拆分小组件会增加请求数和往返次数,其开销超过了节省下来的字节数。

分割了关键路径。 对首屏必需的代码进行分割并不能消除等待,只是把等待挪了位置。浏览器现在必须先下载并执行入口 chunk,执行到动态 import(),然后才去请求负责渲染内容的 chunk。

原本是内容的地方变成了 spinner。 如果 fallback 替换掉了用户以前可以立即看到的内容,即使总加载时间缩短了,应用也可能让人感觉更慢。

为每个懒加载边界都配上一个 error boundary。chunk 请求可能失败,而在部署前就已打开的标签页,可能会请求已不复存在的带 hash 文件。对代码分割应用进行会话回放(session replay),可以看到 bundle 报告无法呈现的问题:用户卡在 Suspense fallback 上,或在会话中途发生部署后遇到 ChunkLoadError。

如何决定保留哪些分割

先从路由开始,再拆分需要交互才出现的重型组件。在 hover 或 focus 时进行预取;除非你明确需要稳定的缓存 hash,否则让打包工具处理共享代码。对于每一处分割,都要在节流条件下对比前后的瀑布图。如果首屏没有更早渲染,或交互没有更快响应,就撤销这处分割。

常见问题

React.lazy 支持具名导出吗?

不能直接支持。React.lazy 要求其加载函数返回的 promise 最终 resolve 为一个以该组件作为默认导出的模块,因此具名导出需要重新映射:lazy(() => import('./Charts').then((m) => ({ default: m.BarChart })))。chunk 的分割方式不受影响。另一种做法是在一个小的包装文件中将该组件重新作为默认导出,这样也能让 lazy 调用保持简洁。

部署后懒加载 chunk 加载失败,该如何恢复?

重新加载页面,让浏览器获取新的入口 chunk 以及当前有效的 chunk URL。React 会保留懒加载函数返回的第一个 promise,并且不会再次调用该加载函数,因此仅靠重新渲染无法实现重试。当动态导入失败时,Vite 会在 window 上触发 'vite:preloadError' 事件,在监听器中调用 event.preventDefault() 可以抑制该错误。请限制自动重新加载的次数,以避免陷入重新加载循环。

webpackPrefetch 和 webpackPreload 有什么区别?

webpackPrefetch 会在父 chunk 加载完成后,利用浏览器空闲时间下载 chunk,适用于稍后可能需要的代码,例如下一个页面。webpackPreload 会与父 chunk 同时以中等优先级获取 chunk,适用于父 chunk 会立即请求的代码。二者都是会生成 link 提示的 webpack magic comments。Preload 较少使用,因为滥用它反而会损害性能。

Vite 会自动避免 chunk 瀑布吗?

只能部分避免。当某个异步 chunk 依赖一个共享的公共 chunk 时,Vite 会改写动态导入,加入预加载步骤,使两者并行获取而不是先后获取。它还会为入口 chunk 及其直接导入生成 modulepreload 链接。但这只覆盖静态依赖。如果懒加载组件在渲染时再次调用 import(),仍然会产生一个串行请求,并在瀑布图中显现出来。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.