知海

代码分割

webpackjsorg-main指南-教程

代码分割

T> 本指南扩展了起步中提供的示例。请确保你至少熟悉其中的示例以及输出管理章节。

代码分割是 webpack 最引人注目的特性之一。此特性允许你将代码拆分成各种 bundle,然后可以按需加载或并行加载。它可以用来实现更小的 bundle,并控制资源加载优先级,如果使用得当,将对加载时间产生重大影响。

webpack 提供三种常用的代码分割方式:

  • 入口起点:通过 entry 配置手动分割代码。
  • 防止重复:使用入口依赖SplitChunksPlugin 去重和拆分 chunk。
  • 动态导入:通过模块中的内联函数调用来分割代码。

入口起点(Entry Points)

这是迄今为止最简单、最直观的代码分割方式。但这种方式较为手动,且存在一些需要注意的陷阱。让我们看看如何从主 bundle 中拆分另一个模块:

项目结构

diff 复制代码
 webpack-demo
  ├── package.json
  ├── package-lock.json
  ├── webpack.config.js
  ├── /dist
  ├── /src
  │   ├── index.js
+ │   └── another-module.js
  └── /node_modules

another-module.js

js 复制代码
import _ from "lodash";

console.log(_.join(["Another", "module", "loaded!"], " "));

webpack.config.js

diff 复制代码
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';

 const __filename = fileURLToPath(import.meta.url);
 const __dirname = path.dirname(__filename);

 export default {
-  entry: './src/index.js',
+  mode: 'development',
+  entry: {
+    index: './src/index.js',
+    another: './src/another-module.js',
+  },
   output: {
-    filename: 'main.js',
+    filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
 };

这将产生如下构建结果:

bash 复制代码
...
[webpack-cli] Compilation finished
asset index.bundle.js 553 KiB [emitted] (name: index)
asset another.bundle.js 553 KiB [emitted] (name: another)
runtime modules 2.49 KiB 12 modules
cacheable modules 530 KiB
  ./src/index.js 257 bytes [built] [code generated]
  ./src/another-module.js 84 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 245 ms

如前所述,这种方式存在一些陷阱:

  • 如果入口 chunk 之间存在重复的模块,它们会被同时包含在两个 bundle 中。
  • 它不够灵活,无法利用核心应用程序逻辑动态分割代码。

上述两点中的第一点对我们的示例来说确实是个问题,因为 lodash 也在 ./src/index.js 中被导入,因此会在两个 bundle 中重复。让我们在下一节中移除这个重复。

防止重复(Prevent Duplication)

入口依赖(Entry dependencies)

dependOn 选项允许在 chunk 之间共享模块:

webpack.config.js

diff 复制代码
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';

 const __filename = fileURLToPath(import.meta.url);
 const __dirname = path.dirname(__filename);

 export default {
   mode: 'development',
   entry: {
-    index: './src/index.js',
-    another: './src/another-module.js',
+    index: {
+      import: './src/index.js',
+      dependOn: 'shared',
+    },
+    another: {
+      import: './src/another-module.js',
+      dependOn: 'shared',
+    },
+    shared: 'lodash',
   },
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
 };

如果要在单个 HTML 页面上使用多个入口点,还需要 optimization.runtimeChunk: 'single',否则可能会遇到这里描述的问题。

webpack.config.js

diff 复制代码
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';

 const __filename = fileURLToPath(import.meta.url);
 const __dirname = path.dirname(__filename);

 export default {
   mode: 'development',
   entry: {
     index: {
       import: './src/index.js',
       dependOn: 'shared',
     },
     another: {
       import: './src/another-module.js',
       dependOn: 'shared',
     },
     shared: 'lodash',
   },
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
+  optimization: {
+    runtimeChunk: 'single',
+  },
 };

构建结果如下:

bash 复制代码
...
[webpack-cli] Compilation finished
asset shared.bundle.js 549 KiB [compared for emit] (name: shared)
asset runtime.bundle.js 7.79 KiB [compared for emit] (name: runtime)
asset index.bundle.js 1.77 KiB [compared for emit] (name: index)
asset another.bundle.js 1.65 KiB [compared for emit] (name: another)
Entrypoint index 1.77 KiB = index.bundle.js
Entrypoint another 1.65 KiB = another.bundle.js
Entrypoint shared 557 KiB = runtime.bundle.js 7.79 KiB shared.bundle.js 549 KiB
runtime modules 3.76 KiB 7 modules
cacheable modules 530 KiB
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
  ./src/another-module.js 84 bytes [built] [code generated]
  ./src/index.js 257 bytes [built] [code generated]
webpack 5.x.x compiled successfully in 249 ms

如你所见,除了 shared.bundle.jsindex.bundle.jsanother.bundle.js 之外,还生成了另一个 runtime.bundle.js 文件。

虽然 webpack 允许每个页面使用多个入口点,但在可能的情况下应尽量避免,而使用带有多个导入的单个入口点:entry: { page: ['./analytics', './app'] }。这样可以获得更好的优化效果,并在使用 async 脚本标签时保证一致的执行顺序。

SplitChunksPlugin

SplitChunksPlugin 允许我们将公共依赖提取到现有的入口 chunk 或一个全新的 chunk 中。让我们用它来去除上一个示例中重复的 lodash 依赖:

webpack.config.js

diff 复制代码
  import path from 'node:path';
  import { fileURLToPath } from 'node:url';

  const __filename = fileURLToPath(import.meta.url);
  const __dirname = path.dirname(__filename);

  export default {
    mode: 'development',
    entry: {
      index: './src/index.js',
      another: './src/another-module.js',
    },
    output: {
      filename: '[name].bundle.js',
      path: path.resolve(__dirname, 'dist'),
    },
+   optimization: {
+     splitChunks: {
+       chunks: 'all',
+     },
+   },
  };

使用 optimization.splitChunks 配置选项后,我们应该会看到 lodash 的重复依赖从 index.bundle.jsanother.bundle.js 中移除。该插件会注意到我们已经把 lodash 分离到单独的 chunk,并从主 bundle 中去除了这部分冗余。但需要注意的是,公共依赖只有在满足 webpack 指定的大小阈值时才会被提取到单独的 chunk。

让我们运行 npm run build 看看是否生效:

bash 复制代码
...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 8.92 KiB [compared for emit] (name: index)
asset another.bundle.js 8.8 KiB [compared for emit] (name: another)
Entrypoint index 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB index.bundle.js 8.92 KiB
Entrypoint another 558 KiB = vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB another.bundle.js 8.8 KiB
runtime modules 7.64 KiB 14 modules
cacheable modules 530 KiB
  ./src/index.js 257 bytes [built] [code generated]
  ./src/another-module.js 84 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 241 ms

以下是社区提供的一些其他有用的代码分割插件和加载器:

动态导入(Dynamic Imports)

Webpack 在动态代码分割方面支持两种类似的技术。第一种也是推荐的方式是使用符合 ECMAScript 提案import() 语法。另一种是 webpack 特有的旧式方法 require.ensure。让我们尝试使用第一种方式……

W> import() 调用在内部使用 promise。如果要在较旧的浏览器(例如 IE 11)中使用 import(),请记得使用 polyfill(如 es6-promisepromise-polyfill)来垫片 Promise

在开始之前,我们先从上述示例的配置中移除多余的 entryoptimization.splitChunks,因为接下来的演示不需要它们:

webpack.config.js

diff 复制代码
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';

 const __filename = fileURLToPath(import.meta.url);
 const __dirname = path.dirname(__filename);

 export default {
   mode: 'development',
   entry: {
     index: './src/index.js',
-    another: './src/another-module.js',
   },
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
-  optimization: {
-    splitChunks: {
-      chunks: 'all',
-    },
-  },
 };

我们还将更新项目以移除现在不再使用的文件:

项目结构

diff 复制代码
 webpack-demo
  ├── package.json
  ├── package-lock.json
  ├── webpack.config.js
  ├── /dist
  ├── /src
  │   ├── index.js
- │   └── another-module.js
  └── /node_modules

现在,不再静态导入 lodash,我们使用动态导入来分离出一个 chunk:

src/index.js

diff 复制代码
-import _ from 'lodash';
-
-function component() {
+function getComponent() {
-  const element = document.createElement('div');

-  // Lodash, now imported by this script
-  element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+  return import('lodash')
+    .then(({ default: _ }) => {
+      const element = document.createElement('div');
+
+      element.innerHTML = _.join(['Hello', 'webpack'], ' ');

-  return element;
+      return element;
+    })
+    .catch((error) => 'An error occurred while loading the component');
 }

-document.body.appendChild(component());
+getComponent().then((component) => {
+  document.body.appendChild(component);
+});

我们需要 default 的原因在于,自 webpack 4 起,当导入 CommonJS 模块时,导入将不再解析为 module.exports 的值,而是会为 CommonJS 模块创建一个人工命名空间对象。有关这一原因的更多信息,请阅读 webpack 4: import() 和 CommonJS

让我们运行 webpack,观察 lodash 被分离到单独的 bundle:

bash 复制代码
...
[webpack-cli] Compilation finished
asset vendors-node_modules_lodash_lodash_js.bundle.js 549 KiB [compared for emit] (id hint: vendors)
asset index.bundle.js 13.5 KiB [compared for emit] (name: index)
runtime modules 7.37 KiB 11 modules
cacheable modules 530 KiB
  ./src/index.js 434 bytes [built] [code generated]
  ./node_modules/lodash/lodash.js 530 KiB [built] [code generated]
webpack 5.x.x compiled successfully in 268 ms

T> 你还可以将 magic comment webpackExportsimport() 一起使用,以公开动态导入模块中的特定导出:

js 复制代码
import(
  /* webpackExports: ["default", "namedExport"] */
  "./module"
);

这可以帮助 webpack 摇树优化其他未使用的导出。有关详细信息,请参阅 Magic Comments

由于 import() 返回 promise,它可以与 async 函数一起使用。下面展示了如何简化代码:

src/index.js

diff 复制代码
-function getComponent() {
+async function getComponent() {
+  const element = document.createElement('div');
+  const { default: _ } = await import('lodash');

-  return import('lodash')
-    .then(({ default: _ }) => {
-      const element = document.createElement('div');
+  element.innerHTML = _.join(['Hello', 'webpack'], ' ');

-      element.innerHTML = _.join(['Hello', 'webpack'], ' ');
-
-      return element;
-    })
-    .catch((error) => 'An error occurred while loading the component');
+  return element;
 }

 getComponent().then((component) => {
   document.body.appendChild(component);
 });

T> 当你可能需要基于计算出的变量导入特定模块时,可以向 import() 提供动态表达式

理解 ChunkLoadError

使用动态 import() 或代码分割时,如果某个 chunk 在运行时加载失败,webpack 可能会抛出 ChunkLoadError

此错误通常表示请求的 chunk 无法被正确执行或解析。在某些情况下,浏览器底层的网络或脚本加载错误可能不会完全反映在 ChunkLoadError 消息本身中。

如果遇到此错误:

  • 验证 chunk 文件是否可通过网络访问。
  • 检查 publicPath 是否正确配置。
  • 检查浏览器控制台是否有额外的脚本或网络错误。

更多上下文请参阅 webpack issue tracker 中的相关讨论。

预取/预加载模块(Prefetching/Preloading modules)

Webpack 4.6.0+ 增加了对预取和预加载的支持。

在声明导入时使用这些内联指令可以让 webpack 输出“资源提示(Resource Hint)”,从而告诉浏览器:

  • prefetch:该资源很可能在将来的某些导航中需要。
  • preload:在当前导航期间也将需要该资源。

例如,有一个 HomePage 组件,它渲染一个 LoginButton 组件,该按钮在被点击后按需加载 LoginModal 组件。

LoginButton.js

js 复制代码
// ...
import(/* webpackPrefetch: true */ "./path/to/LoginModal.js");

这将导致 <link rel="prefetch" href="login-modal-chunk.js"> 被附加到页面的 head 中,指示浏览器在空闲时间预取 login-modal-chunk.js 文件。

T> webpack 会在父 chunk 加载完成后添加预取提示。

预加载指令与预取相比有许多不同之处:

  • 预加载的 chunk 与父 chunk 并行开始加载。预取的 chunk 在父 chunk 完成加载后开始加载。
  • 预加载的 chunk 具有中等优先级,并会立即下载。预取的 chunk 会在浏览器空闲时下载。
  • 预加载的 chunk 应立即被父 chunk 请求。预取的 chunk 可以在将来任意时间使用。
  • 浏览器支持不同。

例如,一个 Component 总是依赖一个大型库,该库应位于单独的 chunk 中。

假设有一个 ChartComponent 组件,它需要一个庞大的 ChartingLibrary。它在渲染时显示一个 LoadingIndicator,并立即按需导入 ChartingLibrary

ChartComponent.js

js 复制代码
// ...
import(/* webpackPreload: true */ "ChartingLibrary");

当请求使用 ChartComponent 的页面时,charting-library-chunk 也会通过 <link rel="preload"> 被请求。假设页面 chunk 较小且加载更快,页面将显示 LoadingIndicator,直到已请求的 charting-library-chunk 完成加载。这将带来一点加载时间上的提升,因为只需要一次往返而不是两次。尤其是在高延迟环境中。

T> 不正确地使用 webpackPreload 实际上会损害性能,因此请谨慎使用。

有时你需要自己控制预加载。例如,任何动态导入的预加载都可以通过异步脚本完成。这在流式服务器端渲染的情况下很有用。

js 复制代码
const lazyComp = () =>
  import("DynamicComponent").catch((error) => {
    // 处理错误。
    // 例如,如果发生任何网络错误,我们可以重试请求。
  });

如果在 webpack 自行开始加载该脚本之前脚本加载失败(webpack 会创建一个脚本标签来加载其代码,如果该脚本不在页面上),那么 catch 处理程序要等到超过 chunkLoadTimeout 才会启动。这种行为可能会出乎意料,但也可以解释——webpack 无法抛出任何错误,因为它不知道脚本失败了。webpack 会在错误发生后立即向脚本添加 onerror 处理程序。

为避免此类问题,你可以添加自己的 onerror 处理程序,在发生任何错误时移除脚本:

html 复制代码
<script
  src="https://example.com/dist/dynamicComponent.js"
  async
  onerror="this.remove()"
></script>

在这种情况下,出错的脚本将被移除。webpack 会创建自己的脚本,并且任何错误都会在没有超时的情况下被处理。

打包分析(Bundle Analysis)

一旦开始分割代码,分析输出以检查模块最终位于何处会很有帮助。官方分析工具是一个很好的起点。还有一些其他社区支持的选项:

  • webpack-chart:webpack 统计信息的交互式饼图。
  • webpack-visualizer:可视化和分析你的 bundle,查看哪些模块占用空间,哪些可能是重复的。
  • webpack-bundle-analyzer:一个插件和 CLI 工具,将 bundle 内容表示为方便交互、可缩放的树图。
  • webpack bundle optimize helper:此工具会分析你的 bundle,并给出可操作的建议,以帮助减少 bundle 体积。
  • bundle-stats:生成 bundle 报告(bundle 大小、资源、模块),并比较不同构建之间的结果。
  • webpack-stats-viewer:一个用于 webpack 统计信息的插件,显示更多关于 webpack bundle 细节的信息。

下一步

请参阅懒加载,了解如何在真实应用中使用 import() 的更具体示例,以及缓存,学习如何更有效地分割代码。

帮助我们改进文档

发现翻译问题或内容错误?请告诉我们。