Webpack Code Splitting

这几天在看webpack的教学视频时对代码分离产生了一点疑惑,于是去翻看了官方文档,现在将原文档的翻译以及我对其中一些内容的理解整理下来。

代码分离是 webpack 中最引人注目的特性之一。此特性能够把代码分离到不同的 bundle 中,然后可以按需加载或并行加载这些文件。代码分离可以用于获取更小的 bundle,以及控制资源加载优先级,如果使用合理,会极大影响加载时间。

常用的代码分离方法有三种:

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

首先这里要解释一下bundle与chunk的区别与联系:

具体的解释可以看这里,但是总结来讲就是:

  1. 模块就是模块可以是ESM模块也可以是commonJS或者AMD模块
  2. 打包过程中被操作的模块文件叫做chunk,例如异步加载一个模块就是一个chunk
  3. bundle是最后打包后的文件,最终文件可以和chunk长的一模一样,但是大部分情况下他是多个chunk的集合

入口起点(entry points)

这是迄今为止最简单、最直观的分离代码的方式。不过,这种方式手动配置较多,并有一些隐患,我们将会解决这些问题。先来看看如何从 main bundle 中分离 another module(另一个模块):

project

1
2
3
4
5
6
7
8
webpack-demo
|- package.json
|- webpack.config.js
|- /dist
|- /src
|- index.js
+ |- another-module.js
|- /node_modules

another-module.js

1
2
3
4
5
import _ from 'lodash';

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

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
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')
}
};

关于上述配置文件中的 output.filename 中的 [name],它的作用是可以让打包生成的文件名称是入口[文件名称.bundle.js],这里还可以配置许多其他的配置,详细的配置可以看官方文档

这将生成如下构建结果:

1
2
3
4
5
6
7
...
Asset Size Chunks Chunk Names
another.bundle.js 550 KiB another [emitted] another
index.bundle.js 550 KiB index [emitted] index
Entrypoint index = index.bundle.js
Entrypoint another = another.bundle.js
...

正如前面提到的,这种方式存在一些隐患:

  • 如果入口 chunk 之间包含一些重复的模块,那些重复模块都会被引入到各个 bundle 中。因为这里会把根据每个入口文件产生的依赖关系中的所有文件都打包到各自的同一个文件中。
  • 这种方法不够灵活,并且不能动态地将核心应用程序逻辑中的代码拆分出来。

这两点中的第一点,对我们的示例来说毫无疑问是个严重问题,因为我们在 ./src/index.js 中也引入过 lodash,这样就造成在两个 bundle 中重复引用。我们可以通过使用 SplitChunksPlugin 插件来移除重复模块。

防止重复(prevent duplication)

SplitChunksPlugin 插件可以将公共的依赖模块提取到已有的 entry chunk 中,或者提取到一个新生成的 chunk。让我们使用这个插件,将前面示例中重复的 lodash 模块去除:

CommonsChunkPlugin 已经从 webpack v4(代号 legato)中移除。想要了解最新版本是如何处理 chunk,请查看 SplitChunksPlugin

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  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 这个沉重负担从主 bundle 中移除,然后分离到一个单独的 chunk 中。执行 npm run build 查看效果:

1
2
3
4
5
6
7
8
...
Asset Size Chunks Chunk Names
another.bundle.js 5.95 KiB another [emitted] another
index.bundle.js 5.89 KiB index [emitted] index
vendors~another~index.bundle.js 547 KiB vendors~another~index [emitted] vendors~another~index
Entrypoint index = vendors~another~index.bundle.js index.bundle.js
Entrypoint another = vendors~another~index.bundle.js another.bundle.js
...

以下是由社区提供,一些对于代码分离很有帮助的 plugin 和 loader:

动态导入(dynamic imports)

当涉及到动态代码拆分时,webpack 提供了两个类似的技术。第一种,也是推荐选择的方式是,使用符合 ECMAScript 提案import() 语法 来实现动态导入。第二种,则是 webpack 的遗留功能,使用 webpack 特定的 require.ensure。让我们先尝试使用第一种……

import() 调用会在内部用到 promises。如果在旧版本浏览器中使用 import(),记得使用一个 polyfill 库(例如 es6-promisepromise-polyfill),来 shim Promise

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

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  const path = require('path');

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

注意,这里使用了 chunkFilename,它决定 non-entry chunk(非入口 chunk) 的名称。关于 chunkFilename 更多信息,请查看 输出 文档。更新我们的项目,移除现在不会用到的文件:

project

1
2
3
4
5
6
7
8
webpack-demo
|- package.json
|- webpack.config.js
|- /dist
|- /src
|- index.js
- |- another-module.js
|- /node_modules

现在,我们不再使用 statically import(静态导入) lodash,而是通过 dynamic import(动态导入) 来分离出一个 chunk:

src/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- import _ from 'lodash';
-
- function component() {
+ function getComponent() {
- var element = document.createElement('div');
-
- // Lodash, now imported by this script
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ return import(/* webpackChunkName: "lodash" */ 'lodash').then(({ default: _ }) => {
+ var element = document.createElement('div');
+
+ element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+
+ return element;
+
+ }).catch(error => 'An error occurred while loading the component');
}

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

这里我们需要使用 default 的原因是,从 webpack v4 开始,在 import CommonJS 模块时,不会再将导入模块解析为 module.exports 的值,而是为 CommonJS 模块创建一个 artificial namespace object(人工命名空间对象),关于其背后原因的更多信息,请阅读 webpack 4: import() 和 CommonJs

注意,在注释中我们提供了 webpackChunkName。这样会将拆分出来的 bundle 命名为 lodash.bundle.js,而不是 [id].bundle.js。想了解更多关于 webpackChunkName 和其他可用选项,请查看 import() 文档。让我们执行 webpack,看到 lodash 分离出一个单独的 bundle:

1
2
3
4
5
6
...
Asset Size Chunks Chunk Names
index.bundle.js 7.88 KiB index [emitted] index
vendors~lodash.bundle.js 547 KiB vendors~lodash [emitted] vendors~lodash
Entrypoint index = index.bundle.js
...

由于 import() 会返回一个 promise,因此它可以和 async 函数一起使用。但是,需要使用像 Babel 这样的预处理器和 Syntax Dynamic Import Babel Plugin。下面是如何通过 async 函数简化代码:

src/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- function getComponent() {
+ async function getComponent() {
- return import(/* webpackChunkName: "lodash" */ 'lodash').then({ default: _ } => {
- var element = document.createElement('div');
-
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
-
- return element;
-
- }).catch(error => 'An error occurred while loading the component');
+ var element = document.createElement('div');
+ const { default: _ } = await import(/* webpackChunkName: "lodash" */ 'lodash');
+
+ element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+
+ return element;
}

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

预取/预加载模块(prefetch/preload module)

webpack v4.6.0+ 添加了预取和预加载的支持。

在声明 import 时,使用下面这些内置指令,可以让 webpack 输出 “resource hint(资源提示)”,来告知浏览器:

  • prefetch(预取):将来某些导航下可能需要的资源
  • preload(预加载):当前导航下可能需要资源

下面这个 prefetch 的简单示例中,有一个 HomePage 组件,其内部渲染一个 LoginButton 组件,然后在点击后按需加载 LoginModal 组件。

LoginButton.js

1
2
//...
import(/* webpackPrefetch: true */ 'LoginModal');

这会生成 `` 并追加到页面头部,指示着浏览器在闲置时间预取 login-modal-chunk.js 文件。

只要父 chunk 完成加载,webpack 就会添加 prefetch hint(预取提示)。

与 prefetch 指令相比,preload 指令有许多不同之处:

  • preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。
  • preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。
  • preload chunk 会在父 chunk 中立即请求,用于当下时刻。prefetch chunk 会用于未来的某个时刻。
  • 浏览器支持程度不同。

下面这个简单的 preload 示例中,有一个 Component,依赖于一个较大的 library,所以应该将其分离到一个独立的 chunk 中。

我们假想这里的图表组件 ChartComponent 组件需要依赖体积巨大的 ChartingLibrary 库。它会在渲染时显示一个 LoadingIndicator(加载进度条) 组件,然后立即按需导入 ChartingLibrary

ChartComponent.js

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

在页面中使用 ChartComponent 时,在请求 ChartComponent.js 的同时,还会通过 `` 请求 charting-library-chunk。假定 page-chunk 体积很小,很快就被加载好,页面此时就会显示 LoadingIndicator(加载进度条) ,等到 charting-library-chunk 请求完成,LoadingIndicator 组件才消失。启动仅需要很少的加载时间,因为只进行单次往返,而不是两次往返。尤其是在高延迟环境下。

不正确地使用 webpackPreload 会有损性能,请谨慎使用。

对于其他webpack支持的打包方法可以去看这篇文档