性能优化可以分为两块。
一块是通过webpack优化项目打包,进而提升项目性能。这一部分大致有splitChunks、DLL以及gzip等,本文标题均以1-开头。
另一块则是通过配置项或插件对webpack的打包过程进行检测优化,本文标题均以2-开头。
1.项目优化
1-1.splitChunksPlugin
本章的代码仓库,已上传至github。
我们都知道webpack从入口文件开始,依次遍历依赖,打包成一个个的chunk。如果我们不对这些大chunk做处理,直接部署到服务器,那么是不能有效的利用浏览器缓存的,会对宽带造成极大浪费。
譬如现在有一个比较大的chunk,名为A。它内部会包含一些业务代码a、第三方库代码b或者一些不经常变动的代码c。如果我们不拆分A,那么在改动业务代码的时候,A的chunkhash必然是会变的,就会导致浏览器缓存失去作用,重新发起请求。如果将A拆分成a、b、c,a的改动并不会影响到b、c,这俩部分依旧会读取浏览器缓存,这样就极大的节省了宽带。
webpack从4.0版本开始使用splitChunksPlugin,以替代旧版的commonsChunkPlugin。而且webpack4.0已经默认内置了splitChunksPlugin插件,只需要我们在optimization.splitChunks中配置即可。
1-1-1.默认配置
在实际项目中,可能你明明没有配置过splitChunks相关配置,可打包过程中还是会有0.js、1.js等文件,这是因为webpack4.0默认打包符合一些条件的chunk。它内置了一些默认规则:
module.exports = {
optimization: {
splitChunks: {
// 哪些chunk会被分离 可选值有all initial async。
chunks: 'async',
// 当chunk块的体积大于该值时,被分离
minSize: 30000,
// 当目标代码被包含的chunk数量大于该值时,被分离。譬如 A>>>a B>>>a,则a的chunks为2
minChunks: 1,
// 异步chunk最大拆包数
maxAsyncRequests: 5,
// 入口同步chunk最大拆包数
maxInitialRequests: 3,
// 默认情况下,webpack将使用块的来源和名称生成名称,例如vendors~main.js
automaticNameDelimiter: '~',
// 主要用来防止同名的split-chunks被打包进同一个chunk包里
name: true,
// cacheGroups中的每个配置都可以使用上面的属性。webpack会自动覆盖。以cacheGroups中的为准。
cacheGroups: {
// 相对上面的默认缓存组来说,这里的高速缓存组有两个额外的属性test和priority,这俩值可以帮助用户更加精确的拆分包。
vendors: {
// 通常是一个正则 该正则用来匹配路径
test: /[\\/]node_modules[\\/]/,
// 该配置的权重 值越高越优先
priority: -10
},
default: {
minChunks: 2,
priority: -20,
// 允许重用现有的块,而不是在模块完全匹配时创建新的块
reuseExistingChunk: true
}
}
}
}
}在默认规则下,会根据如下条件进行代码优化(原文看这里):
优化只针对动态引入的模块,即
async类型脚本,因为对原始(initial)sync类型的bundle进行拆分会产生新的bundle,这个新产生的bundle需要被正确地在页面引入才能工作,这超出了Webpack作为脚本编译的范畴(将脚本插入页面是html-webpack-plugin干的事情)。这条规则确实是符合实际操作的,但是从默认配置的代码中推断,有些不符合。新产生的
chunk来自node_modules或可被多个地方复用。新
chunk需要大于30kb。对
chunks的最大同时请求数小于等于5。换句话说,如果拆分后导致bundle需要同时异步加载的chunk数量大于5个或更多时,则不会进行拆分,因为增加了请求数,得不偿失。拆分后需要尽量做到对于入口文件中最大同时请求数控制在
3个以内。
在满足最后两个条件时,决定了 chunks 应越大越好,而不是越多。
1-1-2.默认配置下的打包
首先说明下个人在学习过程中总结的两个概念:
- 同步chunk: 利用
import from或者import语法直接引入的方式,所分离的chunk。 - 异步chunk: 利用
import()语法引入的方式,所分离的chunk。
初始化项目,webpack的版本为4.29.0,webpack-cli的版本为3.2.1。所有例子均以本部分的代码为基础。目录结构如下:
.
├── package.json
├── src
│ ├── assets
│ │ └── js
│ │ ├── a.js
│ │ ├── b.js
| | └── c.js
│ ├── main.js
| └── entry.js
├── webpack
│ └── webpack.config.js
└── yarn.lockwebpack.config.js的代码如下:
const path = require('path')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
// webpack拆包的分析插件 能更清楚的观察到拆包细节
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
module.exports = {
entry: {
main: path.resolve(__dirname, '../src/main.js'),
entry: path.resolve(__dirname, '../src/entry.js')
},
resolve: {
alias: {
'@': path.resolve(__dirname, '../src')
},
extensions: ['.js', '.json']
},
output: {
path: path.resolve(__dirname, '../dist'),
filename: 'js/[name].[chunkhash:6].js',
chunkFilename: 'js/[name].[chunkhash:6].js'
},
plugins: [
new CleanWebpackPlugin(),
new BundleAnalyzerPlugin()
]
}main.js的代码如下:
// 同步chunk
import _ from 'lodash'
// 同步chunk
import axios from 'axios'
const btn = document.createElement('button')
btn.innerText = 'import a.js'
btn.onclick = function () {
// 异步chunk-a a>>>jquery
import('@/assets/js/a.js')
// 异步chunk-b b>>>jquery
import('@/assets/js/b.js')
// 异步chunk-c 加载
import('@/assets/js/c.js')
// import('axios')
}
document.body.appendChild(btn)entry.js的代码如下:
import _ from 'lodash'a.js和b.js的代码都如下:
import $ from 'jquery'c.js的代码如下:
import axios from 'axios'在执行打包命令前,先分析下上述代码,在打包后,会有多少个拆包。
- 一个
main包,内部包含lodash、axios以及main本身的代码。 - 一个
entry包,内部包含lodash。 main中三个动态引入,所以有三个异步包。而且异步chunk c当中不会有axios,这是因为main的同步chunk已经引入了axios。额外一句,如果上面异步chunk-c使用的是import('axios'),那么只会有两个异步包,chunk-c不会打出来。main有两个动态引入都引入了jquery,所以会再拆一个包。
根据上面的分析,最终结果会有6个包。直接运行yarn start,打包结果如下图: 

可以看出在默认配置下我的上述分析是正确的。
TIP
1.异步chunk与同步chunk并不共享chunks数量。minChunks指的是initial和async的各自chunk数量,即使chunks设置为all。
2.在默认splitChunks的设置下,如果主chunk中已经引入了某第三方node_modules代码,那么异步chunk中的该对应第三方库代码不会被拆包,该第三方库代码会在主chunk中。
3.异步chunk总是默认被拆包的。
1-1-3.第三方库拆包
在1-1-2的默认配置下,可以看到主chunk中有很多的第三方库代码。为了更好的缓存,我们将其提取成单独的chunk。
cacheGroups: {
// 默认分离异步chunk中的node_module代码 这里改成同步
libs: {
test: /[\\/]node_modules[\\/]/,
chunks: 'initial',
priority: 10
},
// 分离下lodash
lodash: {
test: /[\\/]node_modules[\\/]lodash[\\/]/,
chunks: 'initial',
priority: 20
}
}再分析下打包结果,应该是8个chunk:
main包main包中的同步chunk:lodashaxiosmain包中的异步chunk:abca与b的公共chunkentry包(它里面的lodash不会再单独分包,因为main已经分了)
执行命令后,具体打包情况如下: 
1-1-4.不同入口间的公共代码拆包
在多页面应用当中,多个入口文件之间可能存在大量重复的代码。譬如一般我们的项目当中都会有一个工具库utils.js,它用来存放一些全局通用的方法。如果这个文件特别大的话,那么就很有拆包的必要了。
在本例中,main与entry两个入口文件之间都引入了lodash这个第三方库,我们来用这个来类比。
cacheGroups: {
common: {
minChunks: 2,
chunks: 'initial',
priority: 20
}
}同样,先分析结果:
main包main包的异步chunk:abca和b的公共代码包entry包main以及entry之间的公共代码包
执行命令,分析图如下: 
TIP
项目当中的两条分包准则:
1.第三方库的分包,推荐使用test检测。由于webpack已经内置了对异步chunk中的第三方库的分包,所以一般我们可以根据实际需要配置同步chunk即可。即chunks为initial,也可以粗暴点,设置成all,但要注意priority应大于webpack内置的默认值。
2.公共代码的分包,使用minChunks来检测。同样的,webpack也内置了对异步chunk中的公共代码的分包。我们可以根据需要配置同步chunk中的公共代码分包。
这两条不仅适用单页面应用,更适用于多页面应用。
1-1-4.同入口间的公共代码拆包
在上例中,我们看到了如何在不同入口间的公共代码拆包。看起来很简单。现在尝试下同入口间的公共代码拆包。
先改造下我们的文件,在assets的js文件夹下增加一个d.js,它内部的代码如下:
import axios from 'axios'
console.log('d.js')然后在main.js引入:
import _ from 'lodash'
import axios from 'axios'
// 引入d.js
import '@/assets/js/d.js'
const btn = document.createElement('button')
btn.innerText = 'import a.js'
btn.onclick = function () {
import('@/assets/js/a.js')
import('@/assets/js/b.js')
import('@/assets/js/c.js')
}
document.body.appendChild(btn)
console.log('main.js')这样,在main.js当中,主chunk引入了axios,d.js中也引入了axios。如果直接使用1-1-3中的缓存组配置,进行打包,发现是不会有额外的包分出来的。
解决办法是,将minChunks设置为1。
TIP
刚开始到这里,我是有一些疑问的。在a.js和b.js中都有jquery这个公共库,这个包的自动拆分是使用了webpack的默认配置。
default: { minChunks: 2, priority: -20, reuseExistingChunk: true }
为什么minChunks: 2不能拆分出同入口文件件的公共代码。因为在我看来main.js是一个chunk,d.js是一个chunk。已经满足了minChunks:2。
于是我得出了一个结论,minChunks在计算同步chunk与异步chunk时的计数方式不同。
当是同步chunk时,其主chunk(即入口chunk)计为1个。当是异步chunk时,一个异步chunk记为1个。
1-1-5.maxAsyncRequests与maxInitialRequests
官网或者其他一些资料,在介绍这俩概念时有点太让人不解了。我个人理解如下:
maxAsyncRequests:异步chunk的最大拆包数。maxInitialRequests:入口chunk的最大拆包数。
TIP
注意:异步chunk本身也算是一次拆包。
譬如:异步文件demo.js中有vue及element-ui包,拆包结果为0.hash.js、vue.hash.js以及element.hash.js。其中0.hash.js中是demo.js本身的代码。 那么此时这个异步chunk的拆包数是3,而不是2。0.hash.js也要计算在内。
在a.js和b.js中添加引入vue。
cacheGroups: {
libs: {
test: /[\\/]node_modules[\\/]/,
priority: 10
},
// 当jquery和vue都加上maxAsyncRequests为1时 会发现jquery和vue会被打进同一个包内。
jquery: {
test: /[\\/]node_modules[\\/]jquery[\\/]/,
maxAsyncRequests: 1,
priority: 20
},
vue: {
test: /[\\/]node_modules[\\/]vue[\\/]/,
maxAsyncRequests: 1,
priority: 20
}
}这部分写的不太好。例子刚开始设计的时候没有考虑到,导致现在来叙述这部分时,措辞过长。所以这部分推荐一个链接。
1-1-6.runtimeChunk
optimization.runtimeChunk用来抽离每个chunk中的webpack加载代码。webpack有runtime的概念,会在每次编译完成后,在chunk中生成一堆加载逻辑代码,为了更有效的利用浏览器缓存,可以将这部分也抽离出来。
- 单页面应用可设置为
single或object.name - 多页面应用设置为
multiple
设置完毕后,webpack会针对每一个入口文件生成一个对应的runtime文件。
1-2.gzip
将资源进行gzip压缩后,体积更小,传输效率更高。通常情况下是由服务端譬如nginx将资源进行压缩并返回给客户端,然后浏览器解析gzip压缩后的文件。目前主流浏览器均已支持gzip压缩。
在webpack中设置gzip,主要是为了将资源提前压缩,这样在部署后就可以降低nginx的消耗,提升网站响应效率。
在webpack需要使用compression-webpack-plugin插件,将资源进行压缩。核心配置如下:
const CompressionWebpackPlugin = require('compression-webpack-plugin')
module.exports = {
plugins: [
new CompressionWebpackPlugin({
// 压缩后的文件命名 默认为'[path].[base].gz'
filename: '[path].[base].gz',
// 采用的压缩算法
algorithm: 'gzip',
// 目标资源
test: /\.(js|css)$/,
// 阈值 大于该值的文件才会经历压缩 这里设置成1024b
thresold: 10240,
// 压缩比例 1-10 数值越大 压缩效果越好 但也会更加耗时
minRatio: 8,
// 压缩文件后 是否删除源文件 默认不删除
deleteOriginalAssets: true
})
]
}当我们使用compression-webpack-plugin进行压缩后,在部署时还需要nginx的配合。常见配置如下:
#开启gzip压缩 on开启 off关闭
gzip on;
#开启nginx_static后,对于任何文件都会先查找是否有对应的gz文件。如果有,直接使用,nginx不再对该文件gzip。
gzip_static on;
gzip_min_length 1k;
gzip_buffers 4 32k;
gzip_http_version 1.1;
gzip_comp_level 2;
gzip_types text/plain application/x-javascript text/css application/xml;
gzip_vary on;
gzip_disable "MSIE [1-6]."1-3.Long Term Caching
webpack4.0下的最佳实践,一言以蔽之:
output.filename以及output.chunkFilename使用chunkhash。而extractCss使用contenthash。gif|png|jpe?g|eot|woff|ttf|svg|pdf等使用hash。其配置的hash表示的是静态文件的内容hash值,不是webpack每次打包编译生成的hash值。code spliting当中利用optimization.runtimeChunk抽取runtime.js。这样可以保证每个chunk相互独立、降低影响,chunk当中不包含其他冗余代码。- 在生产环境中,使用
webpack.HashdModuleIdsPlugin以及webpack.NamedChunksPlugin以保证chunkhash的稳定。 - 项目部署到生产后,
html的cache-control最好设置成no-cache。而其他资源(js、css、img等)的cache-conrol可设置成一个较大值。
TIP
- 保证
module id的稳定。开发环境下可使用webpack.NamedModulesPlugin(利用路径替代原本的数值型id),生产环境下可使用webpack.HashdModuleIdsPlugin(对比前者,将路径hash化,减小代码size)。 - 保证
chunk id的稳定。在开发环境和生产环境中,使用webpack.NamedChunksPlugin。
2.webpack打包优化
2-1.DLL
本章的代码仓库,已上传至github
DLL意为动态链接库,即Dynamic Link Library。在split-chunks一章中,我们知道为了浏览器更好的缓存,可以将第三方模块分包。 但是如果第三方模块很多,webpack进行构建的时间就会很长。为了优化时长、提高打包效率,可以考虑一次性的将不经常变动的第三方模块单独打包,这样以后只需要打包业务代码。
TIP
DLL与split-chunks并不冲突。它们针对的优化点并不相同,二者可以相辅相成。
2-1-1.流程梳理
webpack已经自带了DllPlugin以及DllReferencePlugin插件。- 创建一个配置文件
webpack.dll.js,这个文件主要是用来生成dll文件以及manifest文件。dll文件会声明一个全局变量,可以在html当中引用。而DllPlugin生成的manifest文件可以看做一份信息清单。 - 在
webpack.config.js中,使用webpack.DllReferencePlugin并配置manifest,映射到上步骤中建立的manifest文件。
2-1-2.初始项目搭建
项目基础结构如下:
.
├── index.html
├── package.json
├── src
│ ├── a.js
│ ├── b.js
│ └── main.js
|
├── webpack
│ ├── webpack.config.js
│ └── webpack.dll.js
└── yarn.lockscripts及项目依赖如下:
{
"scripts": {
"start": "webpack --config webpack/webpack.config.js",
"report": "config_report=true webpack --config webpack/webpack.config.js",
"dll": "webpack --config webpack/webpack.dll.js"
},
"devDependencies": {
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "5.0.0",
"html-webpack-plugin": "^4.5.0",
"webpack": "4.29.0",
"webpack-bundle-analyzer": "^4.1.0",
"webpack-cli": "3.2.1"
},
"dependencies": {
"element-ui": "^2.14.1",
"vue": "^2.6.12"
}
}main.js、a.js、b.js文件的内容如下:
// main.js
import vue from 'vue'
import element from 'element-ui'
import('./a')
import('./b')
console.log('main.js')
// a.js
import vue from 'vue'
console.log('a.js')
// b.js
import vue from 'vue'
console.log('b.js')webpack.config.js文件内容如下:
const path = require('path')
const webpack = require('webpack')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
const config = {
// 实际生产,这里设置成production
mode: 'development',
entry: {
main: path.resolve(__dirname, '../src/main.js')
},
resolve: {
alias: {
'@': path.resolve(__dirname, '../src')
},
extensions: ['.js']
},
output: {
path: path.resolve(__dirname, '../dist'),
filename: 'js/[name].[chunkhash:6].js'
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../index.html')
}),
new CleanWebpackPlugin()
]
}
// 是否开启webpack-bundle-analyzer
// 这里对应的package.json中的script为 { "report": "config_report=true webpack --config webpack/webpack.config.js"
if (process.env.config_report) {
config.plugins.push(new BundleAnalyzerPlugin())
}
module.exports = config执行yarn start后,结果如下: 
可以看出,构建时间为2.48s。
2-1-3.DllPlugin
执行yarn report,查看下打包情况: 
现在我们开始使用DllPlugin,将vue及element-ui打包成vendor。
webpack.dll.js内容如下:
const path = require('path')
const webpack = require('webpack')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = {
// 实际生产 这里设置成production
mode: 'development',
entry: {
vendor: ['vue', 'element-ui']
},
output: {
path: path.resolve(__dirname, '../static'),
filename: 'dll/[name].[hash:4].dll.js',
// ①dll会将资源打成新的js包 并需要手动在html添加链接引用 所以这里是把资源导出为一个全局变量
library: '[name]_[hash:4]_dll'
},
plugins: [
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: [path.resolve(__dirname, '../static/dll')]
}),
// !!! DllPlugin的主要作用和价值就是生成一个manifest文件
new webpack.DllPlugin({
context: __dirname,
// ①manifest文件的name值 必须与output.library保持一致
name: '[name]_[hash:4]_dll',
// 存放manifest文件的位置 DllReferencePlugin会根据这个文件进行寻找模块与全局变量之间的关系
path: path.resolve(__dirname, './dll', '[name].manifest.json')
})
]
}执行yarn dll,dll文件将被打包到根目录下static/js文件夹下。而由DllPlugin生成的manifest将会存在webpack/dll文件夹中。
2-1-4.DllReferencePlugin
在上步中,已经得到了dll文件以及manifest文件。现在我们就要在业务代码的构建过程中引入这些相关的第三方模块。需要对webpack.config.js文件做一些额外改造。
dll文件
在index.html模板中,添加script标签,譬如:
<script src="/static/js/vendor.hash.dll.js"><script>这样,在经过html-webpack-plugin插件处理后,生成的dist/index.html就会有相应的script标签。但这样还是不够,因为static文件夹也需要拷贝到dist目录中,我们需要在webpack.config.js中添加使用copy-webpack-plugin插件。代码如下:
// 不同版本的copy-webpack-plugin插件 用法可能略有区别
new CopyWebpackPlugin([
{
from: path.resolve(__dirname, '../static'),
to: path.resolve(__dirname, '../dist/static')
}
])manifest文件
在webpack.config.js文件中使用DllReferencePlugin插件,代码如下:
new webpack.DllReferencePlugin({
context: __dirname,
manifest: require('./dll/vendor.manifest.json')
}),经过上面的操作,我们就完成了webpack.config.js的改造,现在来执行下yarn start。构建时间从之前2.48s下降到了1.03s。注意红框里的内容,说明目标第三方模块是从dll文件中读取的: 
查看yarn report的结果,同样可以看到第三方是从我们的dll文件中获取的: 
2-1-5.注意点
在实际操作中,有以下几个地方需要额外注意:
output.library与DllPlugin配置项中的name必须一致。DllPlugin与DllReferencePlugin的配置项中都需要有context,而且二者需要一致。(webpack.dll.js与webpack.config.js最好在同一目录下)。- 每次利用
DllPlugin构建新的dll文件后,需要在模板index.html的链接当中对应修改。