webpack的代码分片
引言
实现高性能应用其中重要的一点就是尽可能地让用户每次只加载必要的资源,优先级不太高的资源则采用延迟加载等技术渐进式地获取,这样可以保证页面的首屏速度。代码分片(code splitting)是 Webpack 作为打包工具所特有的一项技术,通过这项技术我们可以把代码按照特定的形式进行拆分,使用户不必一次全部加载,而是按需加载。
代码分片可以有效降低首屏加载资源的大小,但同时也会带来新的问题,比如我们应该对哪些模块进行分片、分片后的资源如何管理等,这些也是需要关注的。
通过入口划分代码
在 Webpack 中每个入口(entry)都将生成一个对应的资源文件,通过入口的配置我们可以进行一些简单有效的代码拆分。
对于 Web 应用来说通常会有一些库和工具是不常变动的,可以把它们放在一个单独的入口中,由该入口产生的资源不会经常更新,因此可以有效地利用客户端缓存,让用户不必在每次请求页面时都重新加载。
这种拆分方法主要适合于那些将接口绑定在全局对象上的库,因为业务代码中的模块无法直接引用库中的模块,二者属于不同的依赖树。
对于多页面应用来说,我们也可以利用入口划分的方式拆分代码。比如,为每一个页面创建一个入口,并放入只涉及该页面的代码,同时再创建一个入口来包含所有公共模块,并使每个页面都进行加载。但是这样仍会带来公共模块与业务模块处于不同依赖树的问题。另外,很多时候不是所有的页面都需要这些公共模块。比如 A、B 页面需要 lib-a 模块,C、D 需要 lib-b 模块,通过手工的方式去配置和提取公共模块将会变得十分复杂。好在我们还可以使用 Webpack 专门提供的插件来解决这个问题。
CommonsChunkPlugin
CommonsChunkPlugin 是 Webpack 4 之前内部自带的插件(Webpack 4 之后替换为了 SplitChunks)。它可以将多个 Chunk 中公共的部分提取出来。公共模块的提取可以为项目带来几个收益:
- 开发过程中减少了重复模块打包,可以提升开发速度;
- 减小整体资源体积;
- 合理分片后的代码可以更有效地利用客户端缓存。
让我们先看一个简单的例子来直观地认识它。假设我们当前的项目中有 foo.js 和 bar.js 两个入口文件,并且都引入了 react,未使用 CommonsChunkPlugin 时 react 被分别打包到了 foo.js 和 bar.js 中。
更改 webpack.config.js,添加 CommonsChunkPlugin。
const webpack = require('webpack');
module.exports = {
entry: {
foo: './foo.js',
bar: './bar.js',
},
output: {
filename: '[name].js',
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'commons',
filename: 'commons.js',
})
],
};
在配置文件的头部首先引入了 Webpack,接着使用其内部的 CommonsChunkPlugin 函数创建了一个插件实例,并传入配置对象(过去的版本中也支持按顺序传入多个参数,该形式目前已经被废弃)。这里我们使用了两个配置项。
- name:用于指定公共 chunk 的名字。
- filename:提取后的资源文件名。
最后,记得在页面中添加一个 script 标签来引入 commons.js,并且注意,该 JS 一定要在其他 JS 之前引入。
CommonsChunkPlugin 的默认规则是只要一个模块被两个入口 chunk 所使用就会被提取出来,比如只要 a 和 b 用了 react,react 就会被提取出来。
然而现实情况是,有些时候我们不希望所有的公共模块都被提取出来,比如项目中一些组件或工具模块,虽然被多次引用,但是可能经常修改,如果将其和 react 这种库放在一起反而不利于客户端缓存。
此时我们可以通过 CommonsChunkPlugin 的 minChunks 配置项来设置提取的规则。该配置项非常灵活,支持多种输入形式。
- 数字: minChunks 可以接受一个数字,当设置 minChunks 为 n 时,只有该模块被 n 个入口同时引用才会进行提取。另外,这个阈值不会影响通过数组形式入口传入模块的提取。
- Infinity: 设置为无穷代表提取的阈值无限高,也就是说所有模块都不会被提取。这个配置项的意义有两个:
- 第一个是和上面的情况类似,即我们只想让 Webpack 提取特定的几个模块,并将这些模块通过数组型入口传入,这样做的好处是提取哪些模块是完全可控的;
- 另一个是我们指定 minChunks 为 Infinity,为了生成一个没有任何模块而仅仅包含 Webpack 初始化环境的文件,这个文件我们通常称为 manifest。
- 函数: minChunks 支持传入一个函数,它可以让我们更细粒度地控制公共模块。Webpack 打包过程中的每个模块都会经过这个函数的处理,当函数的返回值是 true 时进行提取。
CommonsChunkPlugin 的不足
在提取公共模块方面,CommonsChunkPlugin 可以满足很多场景的需求,但是它也有一些欠缺的地方。
- 一个 CommonsChunkPlugin 只能提取一个 vendor,假如我们想提取多个 vendor 则需要配置多个插件,这会增加很多重复的配置代码。
- 前面我们提到的 manifest 实际上会使浏览器多加载一个资源,这对于页面渲染速度是不友好的。
- 由于内部设计上的一些缺陷,CommonsChunkPlugin 在提取公共模块的时候会破坏掉原有 Chunk 中模块的依赖关系,导致难以进行更多的优化。比如在异步 Chunk 的场景下 CommonsChunkPlugin 并不会按照我们的预期正常工作。
// webpack.config.js
const webpack = require("webpack");
module.exports = {
entry: "./foo.js",
output: {
filename: "foo.js",
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: "commons",
filename: "commons.js",
}),
],
};
// foo.js
import React from "react";
import("./bar.js");
document.write("foo.js", React.version);
// bar.js
import React from "react";
document.write("bar.js", React.version);
optimization.SplitChunks
optimization.SplitChunks(简称 SplitChunks)是 Webpack 4 为了改进 CommonsChunk-Plugin 而重新设计和实现的代码分片特性。它不仅比 CommonsChunkPlugin 功能更加强大,还更简单易用。
如我们前面异步加载的例子,在换成 Webpack 4 的 SplitChunks 之后,就可以自动提取出 react 了。请看下面的例子:
// webpack.config.js
module.exports = {
entry: "./foo.js",
output: {
filename: "foo.js",
publicPath: "/dist/",
},
mode: "development",
optimization: {
splitChunks: {
chunks: "all",
},
},
};
// foo.js
import React from "react";
import("./bar.js");
document.write("foo.js", React.version);
// bar.js
import React from "react";
console.log("bar.js", React.version);
此处 Webpack 4 的配置与之前相比有两点不同:
- 使用 optimization.splitChunks 替代了 CommonsChunkPlugin,并指定了 chunks 的值为 all,这个配置项的含义是,SplitChunks 将会对所有的 chunks 生效(默认情况下,SplitChunks 只对异步 chunks 生效,并且不需要配置)。
- mode 是 Webpack 4 中新增的配置项,可以针对当前是开发环境还是生产环境自动添加对应的一些 Webpack 配置。
从声明式到命令式
使用 CommonsChunkPlugin 的时候,我们大多数时候是通过配置项将特定入口中的特定模块提取出来,也就是更贴近命令式的方式。而 SplitChunks 的不同之处在于我们只需要设置一些提取条件,如提取的模式、提取模块的体积等,当某些模块达到这些条件后就会自动被提取出来。SplitChunks 的使用更像是声明式的。
以下是 SplitChunks 默认情形下的提取条件:
- 提取后的 chunk 可被共享或者来自 node_modules 目录。这一条很容易理解,被多次引用或处于 node_modules 中的模块更倾向于是通用模块,比较适合被提取出来。
- 提取后的 Javascript chunk 体积大于 30kB(压缩和 gzip 之前),CSS chunk 体积大于 50kB。这个也比较容易理解,如果提取后的资源体积太小,那么带来的优化效果也比较一般。
- 在按需加载过程中,并行请求的资源最大值小于等于 5。按需加载指的是,通过动态插入 script 标签的方式加载脚本。我们一般不希望同时加载过多的资源,因为每一个请求都要花费建立链接和释放链接的成本,因此提取的规则只在并行请求不多的时候生效。
- 在首次加载时,并行请求的资源数最大值小于等于 3。和上一条类似,只不过在页面首次加载时往往对性能的要求更高,因此这里的默认阈值也更低。
配置
为了更好地了解 SplitChunks 是怎样工作的,我们来看一下它的默认配置。
splitChunks: {
chunks: "async",
minSize: {
javascript: 30000,
style: 50000,
},
maxSize: 0,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',
name: true,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
- 匹配模式
- 通过 chunks 我们可以配置 SplitChunks 的工作模式。它有 3 个可选值,分别为 async(默认)、initial 和 all。async 即只提取异步 chunk,initial 则只对入口 chunk 生效(如果配置了 initial 则上面异步的例子将失效),all 则是两种模式同时开启。
- 匹配条件
- minSize、minChunks、maxAsyncRequests、maxInitialRequests 都属于匹配条件,前文已经介绍过了,不赘述。
- 命名
- 配置项 name 默认为 true,它意味着 SplitChunks 可以根据 cacheGroups 和作用范围自动为新生成的 chunk 命名,并以 automaticNameDelimiter 分隔。如 vendors~a~b~c.js 意思是 cacheGroups 为 vendors,并且该 chunk 是由 a、b、c 三个入口 chunk 所产生的。
- cacheGroups
- 可以理解成分离 chunks 时的规则。默认情况下有两种规则——vendors 和 default。vendors 用于提取所有 node_modules 中符合条件的模块,default 则作用于被多次引用的模块。我们可以对这些规则进行增加或者修改,如果想要禁用某种规则,也可以直接将其置为 false。当一个模块同时符合多个 cacheGroups 时,则根据其中的 priority 配置项确定优先级。
资源异步加载
资源异步加载主要解决的问题是,当模块数量过多、资源体积过大时,可以把一些暂时使用不到的模块延迟加载。这样使页面初次渲染的时候用户下载的资源尽可能小,后续的模块等到恰当的时机再去触发加载。因此一般也把这种方法叫作按需加载。
import()
在 Webpack 中有两种异步加载的方式——import 函数及 require.ensure。
与正常 ES6 中的 import 语法不同,通过 import 函数加载的模块及其依赖会被异步地进行加载,并返回一个 Promise 对象。
首先让我们看一个正常模块加载的例子。
// foo.js
import { add } from "./bar.js";
console.log(add(2, 3));
// bar.js
export function add(a, b) {
return a + b;
}
假设 bar.js 的资源体积很大,并且我们在页面初次渲染的时候并不需要使用它,就可以对它进行异步加载。
// foo.js
import("./bar.js").then(({ add }) => {
console.log(add(2, 3));
});
// bar.js
export function add(a, b) {
return a + b;
}
这里还需要我们更改一下 Webpack 的配置。
module.exports = {
entry: {
foo: "./foo.js",
},
output: {
publicPath: "/dist/",
filename: "[name].js",
},
mode: "development",
devServer: {
publicPath: "/dist/",
port: 3000,
},
};
在之前的部分我们讲过,首屏加载的 JS 资源地址是通过页面中的 script 标签来指定的,而间接资源(通过首屏 JS 再进一步加载的 JS)的位置则要通过 output.publicPath 来指定。上面我们的 import 函数相当于使 bar.js 成为了一个间接资源,我们需要配置 publicPath 来告诉 Webpack 去哪里获取它。
此时我们使用 Chrome 的 network 面板应该可以看到一个 0.js 的请求,它就是 bar.js 及其依赖产生的资源。观察面板中的 Initiator 字段,可以发现它是由 foo.js 产生的请求。
该技术实现的原理很简单,就是通过 JavaScript 在页面的 head 标签里插入一个 script 标签/dist/0.js,打开 Chrome 的 Elements 面板就可以看到。由于该标签在原本的 HTML 页面中并没有,因此我们称它是动态插入的。
异步 chunk 的配置
现在我们已经生成了异步资源,但我们会发现产生的资源名称都是数字 id(如 0.js),没有可读性。还需要通过一些 Webpack 的配置来为其添加有意义的名字,以便于管理。
还是上面的例子,我们修改一下 foo.js 及 Webpack 的配置。
// webpack.config.js
module.exports = {
entry: {
foo: "./foo.js",
},
output: {
publicPath: "/dist/",
filename: "[name].js",
chunkFilename: "[name].js",
},
mode: "development",
};
// foo.js
import(/* webpackChunkName: "bar" */ "./bar.js").then(({ add }) => {
console.log(add(2, 3));
});
可以看到,我们在 Webpack 的配置中添加了 output.chunkFilename,用来指定异步 chunk 的文件名。其命名规则与 output.filename 基本一致,不过由于异步 chunk 默认没有名字,其默认值是[id].js
,这也是为什么我们在例子中看到的是 0.js。如果有更多的异步 chunk,则会依次产生 1.js、2.js 等。
在 foo.js 中,我们通过特有的注释来让 Webpack 获取到异步 chunk 的名字,并配置 output.chunkFilename 为[name].js
。