0%

webpack配置——优化篇

webpack配置知识梳理

压缩

图片压缩、使用 DataURL,以及基本的代码压缩

图片压缩:

之前提及使用 file-loader 来处理图片文件,在此基础上,我们再添加一个 image-webpack-loader 来压缩图片文件。
image-webpack-loader 的压缩是使用 imagemin 提供的一系列图片压缩类库来处理的,详细查看文档了解。
简单的配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
module.exports = {
// ...
module: {
rules: [
{
test: /.*\.(gif|png|jpe?g|svg|webp)$/i,
use: [
{
loader: 'file-loader',
options: {}
},
{
loader: 'image-webpack-loader',
options: {
mozjpeg: { // 压缩 jpeg 的配置
progressive: true,
quality: 65
},
optipng: { // 使用 imagemin-optipng 压缩 png,enable: false 为关闭
enabled: false,
},
pngquant: { // 使用 imagemin-pngquant 压缩 png
quality: '65-90',
speed: 4
},
gifsicle: { // 压缩 gif 的配置
interlaced: false,
},
webp: { // 开启 webp,会把 jpg 和 png 图片压缩为 webp 格式
quality: 75
},
},
],
},
],
},
}

使用DataURL:

项目中个别不能使用svg图,搞成 CSS Sprites又觉得麻烦,就可以使用这种方式
使用url-loader,一般情况仅使用 limit 即可详细查看官方文档:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports = {
// ...
module: {
rules: [
{
test: /\.(png|jpg|gif)$/,
use: [
{
loader: 'url-loader',
options: {
limit: 8192, // 单位是 Byte,当文件小于 8KB 时将其转换为一个 base64 编码的 DataURL
},
},
],
},
],
},
}

代码压缩:

webpack 4.x mode 为 production 即会启动压缩 JS 代码的插件。

webpack 3.x
Js压缩:JS 的压缩使用uglify插件比较彻底(替换掉长变量等)
HTML、CSS压缩:虽然只能移除空格换行等无用字符,但也能在一定程度上减小文件大小。在 webpack 中的配置使用也不是特别麻烦,所以我们通常也会使用。

【对HTML的压缩】在html-webpack-plugin插件配置中。
html-webpack-plugin这个插件是使用 html-minifier 来实现 HTML 代码压缩的,minify 下的配置项直接透传给 html-minifier,配置项参考 html-minifier 文档即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
module.exports = {
// ...
plugins: [
new HtmlWebpackPlugin({
filename: 'index.html', // 配置输出文件名和路径
template: 'assets/index.html', // 配置文件模板
minify: { // 压缩 HTML 的配置
minifyCSS: true, // 压缩 HTML 中出现的 CSS 代码
minifyJS: true // 压缩 HTML 中出现的 JS 代码
}
}),
],
}

【对CSS的压缩】,通过css-loader:
css-loader 是使用 cssnano 来压缩代码的,minimize 字段也可以配置为一个对象,来将相关配置传递给 cssnano。更多详细内容请参考 cssnano 官方文档。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module.exports = {
module: {
rules: {
// ...
{
test: /\.css/,
include: [
path.resolve(__dirname, 'src'),
],
use: [
'style-loader',
{
loader: 'css-loader',
options: {
minimize: true, // 使用 css 的压缩功能
},
},
],
},
},
}
}

代码分离

代码分离的原因:

在没有异步加载的时候,除去vender.js、manifest.js 其余的css、js会打包到一个js中,此时无论js的小改动还是css的小改动都会在用户刷新页面时重新加载。
解决方案:提取公共的css到单独的文件,分离不同模块的js,这样公共css可以缓存,分离的模块虽然里边也有css但是只有这部分修改时只重新加载这部分代码还是能接受的。之所以不把模块的css也分离出来,是因为分离出来后多加载分离的css文件也是一种时间损耗。

webpack 4.x代码分离:

1
2
3
4
5
6
7
8
9
module.exports = {
// ... webpack 配置

optimization: {
splitChunks: {
chunks: "all", // 所有的 chunks 代码公共的部分分离出来成为一个单独的文件 commons.js
},
},
}

上边配置后页面引入commons.js和entry.bundle.js即可,或者使用html-webpack-plugin自动引入,没使用这个插件需要从 stats 的 entrypoints 属性来获取入口应该引用哪些 JS 文件。

进一步
相对于我们写的代码的公共代码,第三方类库的更新频率更低,所以我们可以把这部分代码再单独抽出来一个公共文件,更好的利用缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
module.exports = {
entry: {
vendor: ["react", "lodash", "angular", ...], // 指定公共使用的第三方类库
},
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
chunks: "initial”, // 产出的chunks的名字
test: "vendor",
name: "vendor", // 使用 vendor 入口作为公共部分
enforce: true,
},
},
},
},
// ... 其他配置
}

// 或者
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
test: /react|angluar|lodash/, // 直接使用 test 来做路径匹配
chunks: "initial",
name: "vendor",
enforce: true,
},
},
},
},
}

// 或者
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
chunks: "initial",
test: path.resolve(__dirname, "node_modules") // 路径在 node_modules 目录下的都作为公共部分
name: "vendor", // 使用 vendor 入口作为公共部分
enforce: true,
},
},
},
},
}

上边
第一种做法是显示指定哪些类库作为公共部分,
第二种做法实现的功能差不多,只是利用了 test 来做模块路径的匹配,
第三种做法是把所有在 node_modules 下的模块,即作为依赖安装的,都作为公共部分。
你可以针对项目情况,选择最合适的做法。

webpack 3.x代码分离:

webpack 3.x 以下的版本需要用到 webpack 自身提供的 CommonsChunkPlugin 插件。

1
2
3
4
5
6
7
8
9
10
module.exports = {
// ...
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: "commons", // 公共使用的 chunk 的名称
filename: "commons.js", // 公共 chunk 的生成文件名
minChunks: 3, // 公共的部分必须被 3 个 chunk 共享
}),
],
}

chunk 在这里是构建的主干,可以简单理解为【一个入口对应一个 chunk】。

以上插件配置在构建后会生成一个 commons.js 文件,该文件就是代码中的公共部分。上面的配置中 minChunks 字段为 3,【该字段的意思是当一个模块被 3 个以上的 chunk 依赖时,这个模块就会被划分到 commons chunk 中去】。单从这个配置的角度上讲,这种方式并没有 4.x 的 chunks: “all” 那么方便。

进一步
提取共享类库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module.exports = {
entry: {
vendor: ['react', 'react-redux'], // 指定公共使用的第三方类库
app: './src/entry',
// ...
},
// ...
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor' // 使用 vendor 入口作为公共部分
filename: "vendor.js",
minChunks: Infinity, // 这个配置会让 webpack 不再自动抽离公共模块,如果这里和之前一样依旧设置为 3,那么被 3 个以上的 chunk 依赖的模块会和 React、React-Redux 一同打包进 vendor,这样就失去显式指定的意义了。
}),
],
}

minChunks其实还可以是一个函数,如:
该函数在分析每一个依赖的时候会被调用,可以在函数中针对每一个模块做更加精细化的控制。

1
2
3
4
5
6
// module: 传入当前依赖模块的信息 
// count: 被作为公共模块的数量
minChunks: (module, count) => {
console.log(module, count);
return true;
},

Demo:

1
2
3
4
minChunks: (module, count) => {
return module.context && module.context.includes("node_modules");
// node_modules 目录下的模块都作为公共部分,效果就如同 webpack 4.x 中的 test: path.resolve(__dirname, "node_modules")
}

其余详见文档

进一步控制JS大小

模块按需加载:

要按需加载代码模块很简单,遵循 ES 标准的动态加载语法 dynamic-import 来编写代码即可,webpack 会自动处理使用该语法编写的模块:

1
2
3
4
5
6
7
8
9
10
11
// import 作为一个方法使用,传入模块名即可,返回一个 promise 来获取模块暴露的对象
// 注释 webpackChunkName: "lodash" 可以用于指定 chunk 的名称,在输出文件时有用
// 输出文件是在 webpack 配置中添加一个 output.chunkFilename 的配置
import(/* webpackChunkName: "lodash" */ 'lodash').then((_) => {
console.log(_.lash([1, 2, 3])) // 打印 3
})
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[hash:8].js',
chunkFilename: '[name].[hash:8].js' // 指定分离出来的代码文件的名称, 这样就会输出结果中就有一个对应上边注释中loadash的文件名字。
}

如果没有添加注释 webpackChunkName: “lodash” 以及 output.chunkFilename 配置,那么分离出来的文件名称会以简单数字的方式标识,不便于识别。
注意一下,如果你使用了 Babel 的话,还需要 Syntax Dynamic Import 这个 Babel 插件来处理 import() 这种语法。
由于动态加载代码模块的语法依赖于 promise,对于低版本的浏览器,需要添加 promise 的 polyfill 后才能使用。
动态加载代码时依赖于网络,其模块内容会异步返回,所以 import 方法是返回一个 promise 来获取动态加载的模块内容。

Tree shaking

可以移除 JavaScript 上下文中的未引用代码,删掉用不着的代码,能够有效减少 JS 代码文件的大小。
在 webpack 中,只有启动了 JS 代码压缩功能(即使用 uglify)时,会做 Tree shaking 的优化。webpack 4.x 需要指定 mode 为 production。

如果你在项目中使用了 Babel 的话,要把 Babel 解析模块语法的功能关掉,在 .babelrc 配置中增加 “modules”: false 这个配置,这样可以把 import/export 的这一部分模块语法交由 webpack 处理,否则没法使用 Tree shaking 的优化。

1
2
3
{
"presets": [["env", { "modules": false }]]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 下边启动了Tree shaking之后,构建出来的结果就会移除 square 的那一部分代码了。
// src/math.js
export function square(x) {
return x * x;
}

export function cube(x) {
return x * x * x;
}

// src/index.js
import { cube } from './math.js' // 在这里只是引用了 cube 这个方法

console.log(cube(3))

有的时候你启用了 Tree shaking 功能,但是发现好像并没有什么用,例如:

// src/component.js
export class Person {
constructor ({ name }) {
this.name = name
}

getName () {
return this.name
}
}

export class Apple {
constructor ({ model }) {
this.model = model
}
getModel () {
return this.model
}
}

// src/index.js
import { Apple } from './components'

const appleModel = new Apple({
model: 'X'
}).getModel()

console.log(appleModel)

打包压缩后还是可以发现,Person 这一块看起来没用到的代码出现在文件中。现在如果你在 Babel 配置中增加 “loose”: true 配置的话,Person 这一块代码就可以在构建时移除掉了。相关问题自行搜索。

sideEffects

webpack 4.x 才具备的特性
现在 lodash 的 ES 版本 的 package.json 文件中已经有 sideEffects: false 这个声明了,当某个模块的 package.json 文件中有了这个声明之后,webpack 会认为这个模块没有任何副作用,只是单纯用来对外暴露模块使用,那么在打包的时候就会做一些额外的处理。

1
2
3
4
5
6
7
8
9
10
11
12
import { forEach, includes } from 'lodash-es'

forEach([1, 2], (item) => {
console.log(item)
})

console.log(includes([1, 2, 3], 1))
由于 lodash-es 这个模块的 package.json 文件有 sideEffects: false 的声明,所以 webpack 会将上述的代码转换为以下的代码去处理:
import { default as forEach } from 'lodash-es/forEach'
import { default as includes } from 'lodash-es/includes'

// ... 其他代码

最终 webpack 不会把 lodash-es 所有的代码内容打包进来,只是打包了你用到的那两个方法,这便是 sideEffects 的作用。

提升 webpack 的构建速度

  • 减少 resolve 的解析
  • 减少 plugin 的消耗
  • 换种方式处理图片
  • 使用 DLLPlugin
  • 积极更新 webpack 版本

减少 resolve 的解析

1
2
3
4
5
6
7
8
9
10
11
modules: [
path.resolve(__dirname, 'node_modules'), // 使用绝对路径指定 node_modules,不做过多查询
],

// 删除不必要的后缀自动补全,少了文件后缀的自动匹配,即减少了文件路径查询的工作
// 其他文件可以在编码时指定后缀,如 import('./index.scss')
extensions: [".js"],

// 避免新增默认文件,编码时使用详细的文件路径,代码会更容易解读,也有益于提高构建速度
mainFiles: ['index'],
}

在编码时,如果是使用我们自己本地的代码模块,尽可能编写完整的路径,避免使用目录名,如:import ‘./lib/slider/index.js’

把 loader 应用的文件范围缩小

尽可能把 loader 应用的文件范围缩小,只在最少数必须的代码模块中去使用必要的 loader
例如 node_modules 目录下的其他依赖类库文件,基本就是直接编译好可用的代码,无须再经过 loader 处理了
如果没有配置 include,所有的外部依赖模块都经过 Babel 处理的话,构建速度也是会收很大影响的。

1
2
3
4
5
6
7
8
9
10
11
12
rules: [ 
{
test: /\.jsx?/,
include: [
path.resolve(__dirname, 'src'),
// 限定只在 src 目录下的 js/jsx 文件需要经 babel-loader 处理
// 通常我们需要 loader 处理的文件都是存放在 src 目录
],
use: 'babel-loader',
},
// ...
]

减少 plugin 的消耗

webpack 的 plugin 会在构建的过程中加入其它的工作步骤,如果可以的话,适当地移除掉一些没有必要的 plugin。

这里再提一下 webpack 4.x 的 mode,区分 mode 会让 webpack 的构建更加有针对性,更加高效。例如当 mode 为 development 时,webpack 会避免使用一些提高应用代码加载性能的配置项,如 UglifyJsPlugin,ExtractTextPlugin 等,这样可以更快地启动开发环境的服务,而当 mode 为 production 时,webpack 会避免使用一些便于 debug 的配置,来提升构建时的速度,例如极其消耗性能的 Source Maps 支持。

换种方式处理图片

之前我们用webpack 的 image-webpack-loader 来压缩图片
换一种思路,我们可以直接使用 imagemin 来做图片压缩,编写简单的命令即可。然后使用 pre-commit 这个类库来配置对应的命令,使其在 git commit 的时候触发,并且将要提交的文件替换为压缩后的文件。
这样提交到代码仓库的图片就已经是压缩好的了,以后在项目中再次使用到的这些图片就无需再进行压缩处理了,image-webpack-loader 也就没有必要了。

使用 DLLPlugin

DLLPlugin 是 webpack 官方提供的一个插件,也是用来分离代码的,和 optimization.splitChunks(3.x 版本的是 CommonsChunkPlugin)有异曲同工之妙,如果项目不涉及性能优化这一块,基本上使用 optimization.splitChunks 即可。
使用DLLPlugin需要额外的一个构建配置,用来打包公共的那一部分代码,举个例子,假设这个额外配置是 webpack.dll.config.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = {
name: 'vendor',
entry: ['lodash'], // 这个例子我们打包 lodash 作为公共类库

output: {
path: path.resolve(__dirname, "dist"),
filename: "vendor.js",
library: "vendor_[hash]" // 打包后对外暴露的类库名称
},

plugins: [
new webpack.DllPlugin({
name: 'vendor_[hash]',
path: path.resolve(__dirname, "dist/manifest.json"), // 使用 DLLPlugin 在打包的时候生成一个 manifest 文件
})
],
}

然后在我们原来的webpack配置中添加一个webpack.DllReferencePlugin 配置

1
2
3
4
5
6
7
8
9
module.exports = {
plugins: [
new webpack.DllReferencePlugin({
manifest: path.resolve(__dirname, 'dist/manifest.json'),
// 指定需要用到的 manifest 文件,
// webpack 会根据这个 manifest 文件的信息,分析出哪些模块无需打包,直接从另外的文件暴露出来的内容中获取
}),
],
}

在构建的时候,我们需要优先使用 webpack.dll.config.js 来打包,如 webpack -c webpack.dll.config.js –mode production,构建后生成公共代码模块的文件 vendor.js 和 manifest.json,然后再进行应用代码的构建。

你会发现构建结果的应用代码中不包含 lodash 的代码内容,这一部分代码内容会放在 vendor.js 这个文件中,DLLPlugin 构建出来的内容无需每次都重新构建,后续应用代码部分变更时,你不用再执行配置为 webpack.dll.config.js 这一部分的构建,沿用原本的构建结果即可,所以相比 optimization.splitChunks,使用 DLLPlugin 时,构建速度是会有显著提高的。
但是当你升级 lodash(即你的公共部分代码的内容变更)时,要重新去执行 webpack.dll.config.js 这一部分的构建,不然沿用的依旧是旧的构建结果,使用上并不如 optimization.splitChunks 来得方便。

还有一点需要注意的是,html-webpack-plugin 并不会自动处理 DLLPlugin 分离出来的那个公共代码文件,我们需要自己处理这一部分的内容,可以考虑使用 add-asset-html-webpack-plugin,详见文档

webpack 4.x 的构建性能

webpack 4.0 版本做了很多关于提升构建性能的工作

  • AST 可以直接从 loader 直接传递给 webpack,避免额外的解析,对这一个优化细节有兴趣的可以查看这个 PR。
  • 使用速度更快的 md4 作为默认的 hash 方法,对于大型项目来说,文件一多,需要 hash 处理的内容就多,webpack 的 hash 处理优化对整体的构建速度提升应该还是有一定的效果的。
  • Node 语言层面的优化,如用 for of 替换 forEach,用 Map 和 Set 替换普通的对象字面量等等,这一部分就不展开讲了,有兴趣的同学可以去 webpack 的 PRs 寻找更多的内容。
  • 默认开启 uglifyjs-webpack-plugin 的 cache 和 parallel,即缓存和并行处理,这样能大大提高 production mode 下压缩代码的速度。
    除此之外,还有比较琐碎的一些内容,可以查阅:webpack release 4.0,留意 performance 关键词。

可以看出4.x 的构建性能对比 3.x 是有很显著的提高,而 webpack 官方后续计划加入多核运算,持久化缓存等特性来进一步提升性能(可能要等到 5.x 版本了),所以,及时更新 webpack 版本,也是提升构建性能的一个有效方式。

另外当我们面对因项目过大而导致的构建性能问题时,我们也可以换个角度,思考在 webpack 之上的另外一些解决方案,不要过分依赖于 webpack。

ps.
webpack4.x新增了 mode 参数(4.x 是必要的参数)
webpack5.x后增加对CSS、HTML模块类型的支持:

  • CSS 模块类型的支持,可以用 CSS 文件作为入口
  • HTML 模块类型的支持,可以用 HTML 文件作为入口