webpack性能优化
本文将探讨webpack性能优化的关键策略,包括构建性能、传输性能和运行性能三个维度。通过合理配置和优化技术,可以显著提升开发效率、减少加载时间并改善用户体验。
性能优化概述
本章所讲的性能优化主要体现在

构建性能
这里所说的构建性能,是指在开发阶段的构建性能,而不是生产环境的构建性能
优化的目标,是降低从打包开始,到代码效果呈现所经过的时间
构建性能会影响开发效率。构建性能越高,开发过程中时间的浪费越少
传输性能
传输性能是指,打包后的JS代码传输到浏览器经过的时间
在优化传输性能时要考虑到:
- 总传输量:所有需要传输的JS文件的内容加起来,就是总传输量,重复代码越少,总传输量越少
- 文件数量:当访问页面时,需要传输的JS文件数量,文件数量越多,http请求越多,响应速度越慢
- 浏览器缓存:JS文件会被浏览器缓存,被缓存的文件不会再进行传输
运行性能
运行性能是指,JS代码在浏览器端的运行速度
它主要取决于我们如何书写高性能的代码
永远不要过早的关注于性能,因为你在开发的时候,无法完全预知最终的运行性能,过早的关注性能会极大的降低开发效率
性能优化主要从上面三个维度入手
性能优化没有完美的解决方案,需要具体情况具体分析
减少模块解析
什么叫做模块解析?

模块解析包括:抽象语法树分析、依赖分析、模块语法替换
不做模块解析会怎样?

如果某个模块不做解析,该模块经过loader处理后的代码就是最终代码。
如果没有loader对该模块进行处理,该模块的源码就是最终打包结果的代码。
如果不对某个模块进行解析,可以缩短构建时间
哪些模块不需要解析?
模块中无其他依赖:一些已经打包好的第三方库,比如jquery
如何让某个模块不要解析?
配置module.noParse,它是一个正则,被正则匹配到的模块不会解析
优化loader性能
进一步限制loader的应用范围
思路是:对于某些库,不使用loader
例如:babel-loader可以转换ES6或更高版本的语法,可是有些库本身就是用ES5语法书写的,不需要转换,使用babel-loader反而会浪费构建时间
lodash就是这样的一个库
lodash是在ES5之前出现的库,使用的是ES3语法
通过module.rule.exclude或module.rule.include,排除或仅包含需要应用loader的场景
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /lodash/,
use: "babel-loader"
}
]
}
}
如果暴力一点,甚至可以排除掉node_modules目录中的模块,或仅转换src目录的模块
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
//或
// include: /src/,
use: "babel-loader"
}
]
}
}
这种做法是对loader的范围进行进一步的限制,和noParse不冲突,想想看,为什么不冲突
缓存loader的结果
我们可以基于一种假设:如果某个文件内容不变,经过相同的loader解析后,解析后的结果也不变
于是,可以将loader的解析结果保存下来,让后续的解析直接使用保存的结果
cache-loader可以实现这样的功能
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: ['cache-loader', ...loaders]
},
],
},
};
有趣的是,cache-loader放到最前面,却能够决定后续的loader是否运行
实际上,loader的运行过程中,还包含一个过程,即pitch

cache-loader还可以实现各自自定义的配置,具体方式见文档
为loader的运行开启多线程
thread-loader会开启一个线程池,线程池中包含适量的线程
它会把后续的loader放到线程池的线程中运行,以提高构建效率
由于后续的loader会放到新的线程中,所以,后续的loader不能:
- 使用 webpack api 生成文件
- 无法使用自定义的 plugin api
- 无法访问 webpack options
在实际的开发中,可以进行测试,来决定
thread-loader放到什么位置
特别注意,开启和管理线程需要消耗时间,在小型项目中使用thread-loader反而会增加构建时间
热替换 HMR
热替换并不能降低构建时间(可能还会稍微增加),但可以降低代码改动到效果呈现的时间
当使用webpack-dev-server时,考虑代码改动到效果呈现的过程

而使用了热替换后,流程发生了变化

使用和原理
- 更改配置
module.exports = {
devServer:{
hot:true // 开启HMR
},
plugins:[
// 可选
new webpack.HotModuleReplacementPlugin()
]
}
- 更改代码
// index.js
if(module.hot){ // 是否开启了热更新
module.hot.accept() // 接受热更新
}
首先,这段代码会参与最终运行!
当开启了热更新后,webpack-dev-server会向打包结果中注入module.hot属性
默认情况下,webpack-dev-server不管是否开启了热更新,当重新打包后,都会调用location.reload刷新页面
但如果运行了module.hot.accept(),将改变这一行为
module.hot.accept()的作用是让webpack-dev-server通过socket管道,把服务器更新的内容发送到浏览器
然后,将结果交给插件HotModuleReplacementPlugin注入的代码执行
插件HotModuleReplacementPlugin会根据覆盖原始代码,然后让代码重新执行
所以,热替换发生在代码运行期
样式热替换
对于样式也是可以使用热替换的,但需要使用style-loader
因为热替换发生时,HotModuleReplacementPlugin只会简单的重新运行模块代码
因此style-loader的代码一运行,就会重新设置style元素中的样式
而mini-css-extract-plugin,由于它生成文件是在构建期间,运行期间并会也无法改动文件,因此它对于热替换是无效的
手动分包
基本原理
手动分包的总体思路是:
- 先单独的打包公共模块

公共模块会被打包成为动态链接库(dll Dynamic Link Library),并生成资源清单
- 根据入口模块进行正常打包
打包时,如果发现模块中使用了资源清单中描述的模块,则不会形成下面的代码结构
//源码,入口文件index.js
import $ from "jquery"
import _ from "lodash"
_.isArray($(".red"));
由于资源清单中包含jquery和lodash两个模块,因此打包结果的大致格式是:
(function(modules){
//...
})({
// index.js文件的打包结果并没有变化
"./src/index.js":
function(module, exports, __webpack_require__){
var $ = __webpack_require__("./node_modules/jquery/index.js")
var _ = __webpack_require__("./node_modules/lodash/index.js")
_.isArray($(".red"));
},
// 由于资源清单中存在,jquery的代码并不会出现在这里
"./node_modules/jquery/index.js":
function(module, exports, __webpack_require__){
module.exports = jquery;
},
// 由于资源清单中存在,lodash的代码并不会出现在这里
"./node_modules/lodash/index.js":
function(module, exports, __webpack_require__){
module.exports = lodash;
}
})
打包公共模块
打包公共模块是一个独立的打包过程
- 单独打包公共模块,暴露变量名
// webpack.dll.config.js
module.exports = {
mode: "production",
entry: {
jquery: ["jquery"],
lodash: ["lodash"]
},
output: {
filename: "dll/[name].js",
library: "[name]"
}
};
- 利用
DllPlugin生成资源清单
// webpack.dll.config.js
module.exports = {
plugins: [
new webpack.DllPlugin({
path: path.resolve(__dirname, "dll", "[name].manifest.json"), //资源清单的保存位置
name: "[name]"//资源清单中,暴露的变量名
})
]
};
运行后,即可完成公共模块打包
使用公共模块
- 在页面中手动引入公共模块
<script src="./dll/jquery.js"></script>
<script src="./dll/lodash.js"></script>
- 重新设置
clean-webpack-plugin
如果使用了插件clean-webpack-plugin,为了避免它把公共模块清除,需要做出以下配置
new CleanWebpackPlugin({
// 要清除的文件或目录
// 排除掉dll目录本身和它里面的文件
cleanOnceBeforeBuildPatterns: ["**/*", '!dll', '!dll/*']
})
目录和文件的匹配规则使用的是globbing patterns
- 使用
DllReferencePlugin控制打包结果
module.exports = {
plugins:[
new webpack.DllReferencePlugin({
manifest: require("./dll/jquery.manifest.json")
}),
new webpack.DllReferencePlugin({
manifest: require("./dll/lodash.manifest.json")
})
]
}
总结
手动打包的过程:
- 开启
output.library暴露公共模块 - 用
DllPlugin创建资源清单 - 用
DllReferencePlugin使用资源清单
手动打包的注意事项:
- 资源清单不参与运行,可以不放到打包目录中
- 记得手动引入公共JS,以及避免被删除
- 不要对小型的公共JS库使用
优点:
- 极大提升自身模块的打包速度
- 极大的缩小了自身文件体积
- 有利于浏览器缓存第三方库的公共代码
缺点:
- 使用非常繁琐
- 如果第三方库中包含重复代码,则效果不太理想
自动分包
基本原理
不同与手动分包,自动分包是从实际的角度出发,从一个更加宏观的角度来控制分包,而一般不对具体哪个包要分出去进行控制
因此使用自动分包,不仅非常方便,而且更加贴合实际的开发需要
要控制自动分包,关键是要配置一个合理的分包策略
有了分包策略之后,不需要额外安装任何插件,webpack会自动的按照策略进行分包
实际上,webpack在内部是使用
SplitChunksPlugin进行分包的 过去有一个库CommonsChunkPlugin也可以实现分包,不过由于该库某些地方并不完善,到了webpack4之后,已被SplitChunksPlugin取代

从分包流程中至少可以看出以下几点:
- 分包策略至关重要,它决定了如何分包
- 分包时,webpack开启了一个新的chunk,对分离的模块进行打包
- 打包结果中,公共的部分被提取出来形成了一个单独的文件,它是新chunk的产物
分包策略的基本配置
webpack提供了optimization配置项,用于配置一些优化信息
其中splitChunks是分包策略的配置
module.exports = {
optimization: {
splitChunks: {
// 分包策略
}
}
}
事实上,分包策略有其默认的配置,我们只需要轻微的改动,即可应对大部分分包场景
- chunks
该配置项用于配置需要应用分包策略的chunk
我们知道,分包是从已有的chunk中分离出新的chunk,那么哪些chunk需要分离呢
chunks有三个取值,分别是:
- all: 对于所有的chunk都要应用分包策略
- async:【默认】仅针对异步chunk应用分包策略
- initial:仅针对普通chunk应用分包策略
所以,你只需要配置chunks为all即可
- maxSize
该配置可以控制包的最大字节数
如果某个包(包括分出来的包)超过了该值,则webpack会尽可能的将其分离成多个包
但是不要忽略的是,分包的基础单位是模块,如果一个完整的模块超过了该体积,它是无法做到再切割的,因此,尽管使用了这个配置,完全有可能某个包还是会超过这个体积
另外,该配置看上去很美妙,实际意义其实不大
因为分包的目的是提取大量的公共代码,从而减少总体积和充分利用浏览器缓存
虽然该配置可以把一些包进行再切分,但是实际的总体积和传输量并没有发生变化
如果要进一步减少公共模块的体积,只能是压缩和
tree shaking
分包策略的其他配置
如果不想使用其他配置的默认值,可以手动进行配置:
- automaticNameDelimiter:新chunk名称的分隔符,默认值~
- minChunks:一个模块被多少个chunk使用时,才会进行分包,默认值1
- minSize:当分包达到多少字节后才允许被真正的拆分,默认值30000
缓存组
之前配置的分包策略是全局的
而实际上,分包策略是基于缓存组的
每个缓存组提供一套独有的策略,webpack按照缓存组的优先级依次处理每个缓存组,被缓存组处理过的分包不需要再次分包
默认情况下,webpack提供了两个缓存组:
module.exports = {
optimization:{
splitChunks: {
//全局配置
cacheGroups: {
// 属性名是缓存组名称,会影响到分包的chunk名
// 属性值是缓存组的配置,缓存组继承所有的全局配置,也有自己特殊的配置
vendors: {
test: /[\\/]node_modules[\\/]/, // 当匹配到相应模块时,将这些模块进行单独打包
priority: -10 // 缓存组优先级,优先级越高,该策略越先进行处理,默认值为0
},
default: {
minChunks: 2, // 覆盖全局配置,将最小chunk引用数改为2
priority: -20, // 优先级
reuseExistingChunk: true // 重用已经被分离出去的chunk
}
}
}
}
}
很多时候,缓存组对于我们来说没什么意义,因为默认的缓存组就已经够用了
但是我们同样可以利用缓存组来完成一些事情,比如对公共样式的抽离
module.exports = {
optimization: {
splitChunks: {
chunks: "all",
cacheGroups: {
styles: {
test: /\.css$/, // 匹配样式模块
minSize: 0, // 覆盖默认的最小尺寸,这里仅仅是作为测试
minChunks: 2 // 覆盖默认的最小chunk引用数
}
}
}
},
module: {
rules: [{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, "css-loader"] }]
},
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
template: "./public/index.html",
chunks: ["index"]
}),
new MiniCssExtractPlugin({
filename: "[name].[hash:5].css",
// chunkFilename是配置来自于分割chunk的文件名
chunkFilename: "common.[hash:5].css"
})
]
}
配合多页应用
虽然现在单页应用是主流,但免不了还是会遇到多页应用
由于在多页应用中需要为每个html页面指定需要的chunk,这就造成了问题
new HtmlWebpackPlugin({
template: "./public/index.html",
chunks: ["index~other", "vendors~index~other", "index"]
})
我们必须手动的指定被分离出去的chunk名称,这不是一种好办法
幸好html-webpack-plugin的新版本中解决了这一问题
npm i -D html-webpack-plugin@next
做出以下配置即可:
new HtmlWebpackPlugin({
template: "./public/index.html",
chunks: ["index"]
})
它会自动的找到被index分离出去的chunk,并完成引用
目前这个版本仍处于测试解决,还未正式发布
原理
自动分包的原理其实并不复杂,主要经过以下步骤:
- 检查每个chunk编译的结果
- 根据分包策略,找到那些满足策略的模块
- 根据分包策略,生成新的chunk打包这些模块(代码有所变化)
- 把打包出去的模块从原始包中移除,并修正原始包代码
在代码层面,有以下变动
- 分包的代码中,加入一个全局变量,类型为数组,其中包含公共模块的代码
- 原始包的代码中,使用数组中的公共代码
代码压缩
前言
- 为什么要进行代码压缩
减少代码体积;破坏代码的可读性,提升破解成本;
- 什么时候要进行代码压缩
生产环境
- 使用什么压缩工具
目前最流行的代码压缩工具主要有两个:UglifyJs和Terser
UglifyJs是一个传统的代码压缩工具,已存在多年,曾经是前端应用的必备工具,但由于它不支持ES6语法,所以目前的流行度已有所下降。
Terser是一个新起的代码压缩工具,支持ES6+语法,因此被很多构建工具内置使用。webpack安装后会内置Terser,当启用生产环境后即可用其进行代码压缩。
因此,我们选择Terser
关于副作用 side effect
副作用:函数运行过程中,可能会对外部环境造成影响的功能
如果函数中包含以下代码,该函数叫做副作用函数:
- 异步代码
- localStorage
- 对外部数据的修改
如果一个函数没有副作用,同时,函数的返回结果仅依赖参数,则该函数叫做纯函数(pure function)
Terser
在Terser的官网可尝试它的压缩效果
Terser官网:https://terser.org/
webpack+Terser
webpack自动集成了Terser
如果你想更改、添加压缩工具,又或者是想对Terser进行配置,使用下面的webpack配置即可
const TerserPlugin = require('terser-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
module.exports = {
optimization: {
// 是否要启用压缩,默认情况下,生产环境会自动开启
minimize: true,
minimizer: [ // 压缩时使用的插件,可以有多个
new TerserPlugin(),
new OptimizeCSSAssetsPlugin()
],
},
};
``
tree shaking
压缩可以移除模块内部的无效代码 tree shaking 可以移除模块之间的无效代码
背景
某些模块导出的代码并不一定会被用到
// myMath.js
export function add(a, b){
console.log("add")
return a+b;
}
export function sub(a, b){
console.log("sub")
return a-b;
}
// index.js
import {add} from "./myMath"
console.log(add(1,2));
tree shaking 用于移除掉不会用到的导出
使用
webpack2开始就支持了tree shaking
只要是生产环境,tree shaking自动开启
原理
webpack会从入口模块出发寻找依赖关系
当解析一个模块时,webpack会根据ES6的模块导入语句来判断,该模块依赖了另一个模块的哪个导出
webpack之所以选择ES6的模块导入语句,是因为ES6模块有以下特点:
- 导入导出语句只能是顶层语句
- import的模块名只能是字符串常量
- import绑定的变量是不可变的
这些特征都非常有利于分析出稳定的依赖
在具体分析依赖时,webpack坚持的原则是:保证代码正常运行,然后再尽量tree shaking
所以,如果你依赖的是一个导出的对象,由于JS语言的动态特性,以及webpack还不够智能,为了保证代码正常运行,它不会移除对象中的任何信息
因此,我们在编写代码的时候,尽量:
- 使用
export xxx导出,而不使用export default {xxx}导出 - 使用
import {xxx} from "xxx"导入,而不使用import xxx from "xxx"导入
依赖分析完毕后,webpack会根据每个模块每个导出是否被使用,标记其他导出为dead code,然后交给代码压缩工具处理
代码压缩工具最终移除掉那些dead code代码
使用第三方库
某些第三方库可能使用的是commonjs的方式导出,比如lodash
又或者没有提供普通的ES6方式导出
对于这些库,tree shaking是无法发挥作用的
因此要寻找这些库的es6版本,好在很多流行但没有使用的ES6的第三方库,都发布了它的ES6版本,比如lodash-es
作用域分析
tree shaking本身并没有完善的作用域分析,可能导致在一些dead code函数中的依赖仍然会被视为依赖
插件webpack-deep-scope-plugin提供了作用域分析,可解决这些问题
副作用问题
webpack在tree shaking的使用,有一个原则:一定要保证代码正确运行
在满足该原则的基础上,再来决定如何tree shaking
因此,当webpack无法确定某个模块是否有副作用时,它往往将其视为有副作用
因此,某些情况可能并不是我们所想要的
//common.js
var n = Math.random();
//index.js
import "./common.js"
虽然我们根本没用有common.js的导出,但webpack担心common.js有副作用,如果去掉会影响某些功能
如果要解决该问题,就需要标记该文件是没有副作用的
在package.json中加入sideEffects
{
"sideEffects": false
}
有两种配置方式:
- false:当前工程中,所有模块都没有副作用。注意,这种写法会影响到某些css文件的导入
- 数组:设置哪些文件拥有副作用,例如:
["!src/common.js"],表示只要不是src/common.js的文件,都有副作用
这种方式我们一般不处理,通常是一些第三方库在它们自己的
package.json中标注
css tree shaking
webpack无法对css完成tree shaking,因为css跟es6没有半毛钱关系
因此对css的tree shaking需要其他插件完成
例如:purgecss-webpack-plugin
注意:
purgecss-webpack-plugin对css module无能为力
ESLint
ESLint是一个针对JS的代码风格检查工具,当不满足其要求的风格时,会给予警告或错误
民间中文网:https://eslint.bootcss.com/
使用
ESLint通常配合编辑器使用
- 在vscode中安装
ESLint
该工具会自动检查工程中的JS文件
检查的工作交给eslint库,如果当前工程没有,则会去全局库中查找,如果都没有,则无法完成检查
另外,检查的依据是eslint的配置文件.eslintrc,如果找不到工程中的配置文件,也无法完成检查
- 安装
eslint
npm i [-g] eslint
- 创建配置文件
可以通过eslint交互式命令创建配置文件
由于windows环境中git窗口对交互式命名支持不是很好,建议使用powershell
npx eslint --init
eslint会识别工程中的
.eslintrc.*文件,也能够识别package.json中的eslintConfig字段
配置
env
配置代码的运行环境
- browser:代码是否在浏览器环境中运行
- es6:是否启用ES6的全局API,例如
Promise等
parserOptions
该配置指定eslint对哪些语法的支持
- ecmaVersion: 支持的ES语法版本
- sourceType
- script:传统脚本
- module:模块化脚本
parser
eslint的工作原理是先将代码进行解析,然后按照规则进行分析
eslint 默认使用Espree作为其解析器,你可以在配置文件中指定一个不同的解析器。
globals
配置可以使用的额外的全局变量
{
"globals": {
"var1": "readonly",
"var2": "writable"
}
}
eslint支持注释形式的配置,在代码中使用下面的注释也可以完成配置
/* global var1, var2 */
/* global var3:writable, var4:writable */
extends
该配置继承自哪里
它的值可以是字符串或者数组
比如:
{
"extends": "eslint:recommended"
}
表示,该配置缺失的位置,使用eslint推荐的规则
ignoreFiles
排除掉某些不需要验证的文件
.eslintignore
dist/**/*.js
node_modules
rules
eslint规则集
每条规则影响某个方面的代码风格
每条规则都有下面几个取值:
- off 或 0 或 false: 关闭该规则的检查
- warn 或 1 或 true:警告,不会导致程序退出
- error 或 2:错误,当被触发的时候,程序会退出
除了在配置文件中使用规则外,还可以在注释中使用:
/* eslint eqeqeq: "off", curly: "error" */
gzip
gzip是一种压缩文件的算法
B/S结构中的压缩传输

优点:传输效率可能得到大幅提升
缺点:服务器的压缩需要时间,客户端的解压需要时间
使用webpack进行预压缩
使用compression-webpack-plugin插件对打包结果进行预压缩,可以移除服务器的压缩时间
