性能优化可以分为两块。
一块是通过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.lock
webpack.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
:lodash
axios
main
包中的异步chunk
:a
b
c
a
与b
的公共chunk
entry
包(它里面的lodash
不会再单独分包,因为main
已经分了)
执行命令后,具体打包情况如下:
1-1-4.不同入口间的公共代码拆包
在多页面应用当中,多个入口文件之间可能存在大量重复的代码。譬如一般我们的项目当中都会有一个工具库utils.js
,它用来存放一些全局通用的方法。如果这个文件特别大的话,那么就很有拆包的必要了。
在本例中,main
与entry
两个入口文件之间都引入了lodash
这个第三方库,我们来用这个来类比。
cacheGroups: {
common: {
minChunks: 2,
chunks: 'initial',
priority: 20
}
}
同样,先分析结果:
main
包main
包的异步chunk
:a
b
c
a
和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.lock
scripts
及项目依赖如下:
{
"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
的链接当中对应修改。