代码拆分

代码拆分是 webpack 最引人注目的功能之一。此功能允许您将代码拆分为多个捆绑包,然后可以按需或并行加载。它可以用来实现更小的捆绑包并控制资源加载优先级,如果使用得当,可以对加载时间产生重大影响。

有三种通用的代码拆分方法可用

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

入口点

这是迄今为止最简单、最直观的代码拆分方式。但是,它更手动,并且有一些我们将要讨论的陷阱。让我们看看如何从主捆绑包中拆分另一个模块

项目

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
  |- index.js
+ |- another-module.js
|- /node_modules

another-module.js

import _ from 'lodash';

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

webpack.config.js

 const path = require('path');

 module.exports = {
-  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'),
   },
 };

这将产生以下构建结果

...
[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.4.0 compiled successfully in 245 ms

如上所述,这种方法有一些陷阱

  • 如果入口块之间有任何重复的模块,它们将被包含在两个捆绑包中。
  • 它不够灵活,不能用于使用核心应用程序逻辑动态拆分代码。

这两个要点中的第一个绝对是我们示例中的一个问题,因为 lodash 也在 ./src/index.js 中导入,因此将在两个捆绑包中重复。让我们在下一节中删除此重复。

防止重复

入口依赖项

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

webpack.config.js

 const path = require('path');

 module.exports = {
   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

 const path = require('path');

 module.exports = {
   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',
+  },
 };

这是构建结果

...
[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.4.0 compiled successfully in 249 ms

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

尽管 webpack 允许在每个页面上使用多个入口点,但应尽可能避免使用多个入口点,而应使用具有多个导入的入口点:entry: { page: ['./analytics', './app'] }。这在使用 async 脚本标签时会产生更好的优化和一致的执行顺序。

SplitChunksPlugin

SplitChunksPlugin 插件 允许我们将公共依赖项提取到现有的入口块或全新的块中。让我们使用它来从前面的示例中删除lodash 依赖项的重复项。

webpack.config.js

  const path = require('path');

  module.exports = {
    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 配置选项 后,我们现在应该看到重复的依赖项从我们的index.bundle.jsanother.bundle.js 中删除。该插件应该注意到我们已将lodash 分离到一个单独的块中,并从我们的主捆绑包中删除了无用部分。让我们执行npm run build 来查看它是否有效。

...
[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.4.0 compiled successfully in 241 ms

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

  • mini-css-extract-plugin 插件:用于将 CSS 从主应用程序中分离出来。

动态导入

在动态代码拆分方面,webpack 支持两种类似的技术。第一种也是推荐的方法是使用符合ECMAScript 提案 的动态导入的import() 语法。传统的、特定于 webpack 的方法是使用require.ensure 方法。让我们尝试使用这两种方法中的第一种...

在我们开始之前,让我们从上面的示例中删除额外的entry 入口optimization.splitChunks 配置,因为它们在接下来的演示中将不再需要。

webpack.config.js

 const path = require('path');

 module.exports = {
   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',
-    },
-  },
 };

我们还将更新我们的项目以删除现在未使用的文件。

项目

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
  |- index.js
- |- another-module.js
|- /node_modules

现在,我们将使用动态导入来分离一个块,而不是静态导入lodash

src/index.js

-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() and CommonJs

让我们运行 webpack 来查看lodash 是否已分离到一个单独的捆绑包中。

...
[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.4.0 compiled successfully in 268 ms

由于import() 返回一个 Promise,因此它可以与async 函数 一起使用。以下是如何简化代码。

src/index.js

-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);
 });

预取/预加载模块

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

在声明导入时使用这些内联指令,允许 webpack 输出“资源提示”,告诉浏览器:

  • 预取:资源可能在未来的一些导航中需要。
  • 预加载:资源在当前导航中也会需要。

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

LoginButton.js

//...
import(/* webpackPrefetch: true */ './path/to/LoginModal.js');

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

预加载指令与预取相比有很多不同。

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

例如,有一个 Component 始终依赖于一个应该在单独块中的大型库。

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

ChartComponent.js

//...
import(/* webpackPreload: true */ 'ChartingLibrary');

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

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

const lazyComp = () =>
  import('DynamicComponent').catch((error) => {
    // Do something with the error.
    // For example, we can retry the request in case of any net error
  });

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

为了防止此类问题,您可以添加自己的 onerror 处理程序,该处理程序在发生任何错误时会删除脚本。

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

在这种情况下,出错的脚本将被删除。Webpack 将创建自己的脚本,任何错误都将在没有任何超时的情况下进行处理。

捆绑分析

一旦您开始拆分代码,分析输出以检查模块的最终位置将非常有用。 官方分析工具 是一个不错的起点。还有一些其他社区支持的选项。

  • webpack-chart: 用于 webpack 统计信息的交互式饼图。
  • webpack-visualizer: 可视化和分析您的捆绑包,以查看哪些模块占用了空间,哪些可能是重复的。
  • webpack-bundle-analyzer: 一个插件和 CLI 工具,它将捆绑包内容表示为方便的交互式可缩放树状图。
  • webpack 捆绑包优化助手: 此工具将分析您的捆绑包并为您提供有关如何改进以减小捆绑包大小的可操作建议。
  • bundle-stats: 生成捆绑包报告(捆绑包大小、资产、模块)并比较不同构建之间的结果。
  • webpack-stats-viewer: 一个带有 webpack 统计信息构建的插件。显示有关 webpack 捆绑包详细信息的更多信息。

下一步

请参阅延迟加载,了解如何在实际应用程序中使用import()的更具体示例,以及缓存,了解如何更有效地拆分代码。

33 位贡献者

pksjcepastelskysimon04jonwheelerjohnstewshinxitomtaschelevy9527rahulcschrisVillanuevarafdeshaunwallaceskipjackjakearchibaldTheDutchCoderrouzbeh84shaodahongsudarsangpkcoltonefreitasnEugeneHlushkoTiendo1011byzykAnayaDesignwizardofhogwartsmaximilianschmelzersmelukovchenxsanAdarahatesgoralsnitin315artem-malko