webpack5之性能优化
webpack系列目录
- webpack5之核心配置梳理
- webpack5之模块化原理
- webpack5之Babel/ESlint/浏览器兼容
- webpack5之性能优化
- webpack5之Loader和Plugin的实现
- webpack5之核心源码解析
性能分析工具
性能优化一直是我们一直关注的问题,对于性能优化分析的角度,我们可以从构建过程
和构建产物
两方面谈起。
构建过程分析
对于构建过程
,如果我们希望看到每一个loader、每一个Plugin消耗的打包时间,可以借助于一个插件: speed-measure-webpack-plugin
,安装npm install speed-measure-webpack-plugin -D
。
使用配置如下
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')
const smp = new SpeedMeasurePlugin()
module.exports = (env) => {
const config = {
// 配置属性
}
return smp.wrap(config)
}
该插件在最新的webpack版本中存在一些兼容性的问题(和部分Plugin不兼容),因此我们只能先加有兼容的插件删除或者注释掉
构建产物分析
对于构建产物
,我们可以使用webpack命令生成文件分析产物stats.json
和分析工具webpack-bundle-analyzer
两种方案
stats.json
我们在package.json的script中添加"build:stats": "webpack --config ./config/webpack.common.js --env production --profile --json=stats.json"
,我们执行npm run build:stats
,现在我们能够在当前目录下生成一个stats.json
文件,并对其格式化一下
该产物记录了构建产物的模块大小,模块之前的引入关系等,我们也可以将生成的stats.json文件放到github.com/webpack/ana… ,进行分析
webpack-bundle-analyzer
我们还可以通过一个查看包大小的分析工具webpack-bundle-analyzer
,安装npm install webpack-bundle-analyzer -D
,使用配置插件,并且打包
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
{
plugins: [
//...
new BundleAnalyzerPlugin()
]
}
该插件在打包结束后,启一个8888端口上的服务,我们可以直接的浏览器上访问该端口页面看到每个包的大小。
性能优化方案
目前在实际项目中,具体的场景有对应的优化方案,现在我们来看一下的一些常用的性能优化方案
打包代码分离
代码分离的方式有三种
- 入口起点 : 使用entry配置手动分离代码
- 防止重复 : 使用Entry Dependencies或者SplitChunksPlugin去重和分离代码
- 动态导入 : 通过模块的内联函数调用来分离代码
entry配置手动分离代码
entry
属性除了可以穿字符串,也可以传对象,相关配置如下
{
entry: {
main: resolve('src/main.js'),
index: resolve('src/index.js')
},
output: {
path: resolve('build'),
filename: 'js/[name].bundle.js', // 通过[name]占位符来导出不同的bundle文件
clean: true
}
}
可以看到打包后输出index.bundle.js和main.bundle.js这两个文件,并且在index.html中引入这两个bundle文件,该方式一般可以用来打包多页面项目。
Entry Dependencies
现在我们使用一个lodash包分别在index.js里和main.js文件中使用
// index.js
import _ from 'lodash'
console.log(_.join('hello', 'index.js'))
// main.js
import _ from 'lodash'
console.log(_.join('hello', 'main.js'))
可以看到lodash都打包进了两个bundle文件中,那么体积就大了一倍,我们能否将重复引入的依赖包给抽离成一个公共的呢,可以看如下配置
entry: {
main: {import: resolve('src/main.js'), dependOn: 'lodash'},
index: {import: resolve('src/index.js'), dependOn: 'lodash'},
lodash: 'lodash'
}
现在可以看到,通过配置dependOn去指出依赖项lodash,打包后可以看到抽离出lodash.bundle.js文件。如果两个文件又依赖另一个包,比如都是用的dayjs,那么配置和打包如下
entry: {
main: {import: resolve('src/main.js'), dependOn: 'shared'},
index: {import: resolve('src/index.js'), dependOn: 'shared'},
shared: ['lodash', 'dayjs']
}
现在loadsh和dayjs都打包进了shared.bundle.js里面了。
SplitChunksPlugin
我们可以使用 SplitChunks 的分块策略,我们介绍几个常用的属性
chunks
: 默认值是async,另一个值是initial,表示对通过的代码进行处理,all表示对同步和异步代码都进行处理minSize
: 拆分包的大小,至少为minSize,如果一个包拆分出来达不到minSize,那么这个包就不会拆分maxSize
: 将大于maxSize的包,拆分为不小于minSize的包minChunks
: 至少被引入的次数,默认是1,如果我们写一个2,但是引入了一次,那么不会被单独拆分name
: 设置拆包的名称,可以设置一个名称,也可以设置为false,设置为false后,需要在cacheGroups中设置名称cacheGroups
: 用于对拆分的包就行分组,比如一个lodash在拆分之后,并不会立即打包,而是会等到有没有其他符合规则的包一起来打包test
: 匹配符合规则的包name
: 拆分包的name属性filename
: 拆分包的名称,可以自己使用placeholder属性priority
: 打包优先级
optimization: {
//...
splitChunks: {
// async异步导入
// initoal同步导入
// all 异步/同步
chunks: 'all',
minSize: 20000, // 最小尺寸,拆分出来的一个包的大小最小为minSize 默认 20kb
maxSize: 20000, // 将大于maxSize的包,拆成不小于minSize的包 默认 0, 一般会设置和minSize一样
minChunks: 2, // 引入的包,至少被导入几次 默认 1次
cacheGroups: { // 缓存分组
vendor: { // 第三方打包到vendor
test: /[\/]node_modules[\/]/, // 匹配node_modules
filename: 'js/[id]_vendors.js', // 与name属性区别是 filename可用占位符, name固定名称
// name: 'js/check_vendors.js',
priority: -10 // 当所有打包条件都满足时,按priority优先级来打包,大的先打包
},
default: { // 默认打包,当其他条件不满足
minChunks: 2,
filename: 'js/[id]_common.js', // 一般是多入口会打包common.js
priority: -20
}
}
}
}
动态导入模块
还有一种代码拆分的方式是动态导入时,webpack提供了两种实现动态导入的方式
- 使用ECMAScript中的
import()
语法来完成,也是目前推荐的方式 - 使用webpack遗留的
require.ensure
,目前已经不推荐使用
比如一个模块在初始页面打开是并不会用到,而是在点击某一处使用该模块,那么我们希望一开始不需要将这个模块一起加载下来,而是等到需要用到的时候import动态导入后再加载。
我们可以通过配置output.chunkFilename
来改变异步打包文件名
output: {
//...
chunkFilename: 'js/chunk.[id].[name].js'
}
我们通过import异步加载foo.js,打包后可以看到chunk.604.604.js,但是id和name却是相同,我们可通过魔法注释(magic comments) 来改变name
现在重新打包后可以看到异步模块名已经替换成我们配置的名称。
我们在浏览器中打开后经过2秒后这个依赖包被异步加载出来。我们实际上在很多框架如vue中加载路由会通过import懒加载的方式,就是我们上面所说的这种。
在异步加载模块中,我们还可以通过魔法注释(magic comments)配置一个prefetch
和preload
prefetch
(预获取) : 将来某些导航下可能需要的资源,prefetch会在父chunk加载结束后会开始加载。preload
(预加载) : 当前导航下可能需要资源,和父chunk一起请求
目前我们推荐使用prefetch
,配置 /*webpackPrefetch: true*/
如下
打包后可以看到浏览器中引入的chunk通过<link rel="prefetch" as="script">
引入,并且在network中也可以看到当前模块在所以模块加载之后在加载并从 prefetch cache
中取出并执行,所以该资源预先下载完了资源,当我们使用到的时候再重缓存中读取加载。
Tree Shaking
Tree Shaking就是摇晃树的意思,将我们没有用到的代码给摇晃(删除)下来,一般Tree Shaking分为javascript和css的tree shaking。而javascript的Tree Shaking方法中又借助了Teser压缩工具,所以我们先介绍一下javascript和css的压缩工具。
Javascript压缩
我们先讲讲如何使用压缩工具,早期我们会使用 uglify-js 来压缩、丑化我们的JavaScript代码,但是目前已经不再维护,并且不支持ES6+的语法,现在我们主要使用了TerserPlugin
这个插件配置,这个插件在安装webpack5的时候自动安装上了
- 在webpack中有一个
minimizer
属性,在production模式下,默认就是使用TerserPlugin
来处理我们的代码的,如果我们对默认的配置不满意,也可以自己来创建TerserPlugin
的实例,并且覆盖相关的配置 - 首先,我们需要打开
minimize
,让其对我们的代码进行压缩(默认production模式下已经打开了) - 其次,我们可以在minimizer创建一个
TerserPlugin
-
extractComments
: 默认值为true,表示会将注释抽取到一个单独的文件中,在开发中,我们不希望保留这个注释时,可以设置为falseparallel
: 使用多进程并发运行提高构建的速度,默认值是true,并发运行的默认数量: os.cpus().length - 1,我们也可以设置自己的个数,但是使用默认值即可terserOptions
: 设置我们的terser相关的配置compress
: 设置压缩相关的选项mangle
: 设置丑化相关的选项,可以直接设置为truetoplevel
: 底层变量是否进行转换keep_classnames
: 保留类的名称keep_fnames
: 保留函数的名称
TeserPlugin配置如下
const TerserPlugin = require('terser-webpack-plugin')
//...
optimization: {
minimize: true, // minimizer配置开关
minimizer: [
new TerserPlugin({ // 默认不需要去配置, 压缩js
parallel: true, // 使用cpu多核来构建
extractComments: false, // 打包后的 LICENSE.txt 注释文件去吃
terserOptions: {
compress: {
arguments: true,
dead_code: true,
}, // 设置压缩相关的选项;
mangle: true, // 设置丑化相关的选项,可以直接设置为true;
toplevel: true, // 底层变量是否进行转换;
keep_classnames: false, // 保留类的名称;
keep_fnames: false, // 保留函数的名称;
}
}),
],
}
CSS压缩
除了JS压缩之外,我们使用也有对于css的压缩,css压缩通常是去除无用的空格等,因为很难去修改选择器、属性的名称、值等,我们可以使用一个插件: css-minimizer-webpack-plugin
,css-minimizer-webpack-plugin是使用cssnano工具来优化、压缩CSS(也可以单独使用),安装 npm install css-minimizer-webpack-plugin -d
。配置如下
const TerserPlugin = require('terser-webpack-plugin')
const CssMinimizerWebpackPlugin = require('css-minimizer-webpack-plugin')
//...
optimization: {
minimize: true, // minimizer配置开关
minimizer: [
new TerserPlugin({ // 默认不需要去配置, 压缩js
//...
}),
new CssMinimizerWebpackPlugin({
parallel: true
})
],
}
打包后可以看到css文件已被压缩
Javascript Tree Shaking
现在我们再来介绍下什么是Javascript Tree Shaking,Tree Shaking是一个术语,在计算机中表示消除死代码(dead_code),Tree Shaking依赖于ES Module的静态语法分析(不执行任何的代码,可以明确知道模块的依赖关系),tree shaking在webpack实现的过程如下
- webpack2正式内置支持了ES2015模块,和检测未使用模块的能力
- 在webpack4正式扩展了这个能力,并且通过 package.json的
sideEffects
属性作为标记,告知webpack在编译时, 哪里文件可以安全的删除掉 - webpack5中,也提供了对部分CommonJS的tree shaking的支持
目前webpack实现Tree Shaking采用了两种不同的方案
- usedExports: 通过标记某些函数是否被使用,之后通过Terser来进行优化的
- sideEffects: 跳过整个模块/文件,直接查看该文件是否有副作用
usedExports
为了可以看到 usedExports带来的效果,我们需要设置为development
模式,因为在production
模式下,webpack默认的一些优化会带来很大额影响。
现在准备我们的priceFormat.js文件和入口文件main.js,main.js只引入priceFormat.js文件中的add函数
// priceFormat.js
const add = (a, b) => {
return a + b
}
const minus = (a, b) => {
return a - b
}
// console.log(add(1, 2))
export {
add,
minus
}
import { add } from './js/priceFormat'
var dom = document.createElement('div')
dom.innerHTML = 'div' + add(1, 2)
document.body.appendChild(dom)
配置webpack并重新打包
{
mode: 'development',
devtool: 'source-map',
optimization: {
usedExports: true
}
}
在usedExports
设置为true时,会有一段注释: unused harmony export minus,这段注释的意义是告知Terser
在优化时,可以删除掉这段代码。
现在这个时候,我们将minimize
设置true,并重新打包
{
mode: 'development',
devtool: 'source-map',
optimization: {
usedExports: true,
minimize: true
}
}
现在在来看打包文件,minus函数已经被消除,所以usedExports实现Tree Shaking是结合Terser来完成的。
sideEffects
- sideEffects用于告知webpack compiler哪些模块时有副作用的,副作用的意思是这里面的代码有执行一些特殊的任务,不能仅仅通过export来判断这段代码的意义
- 在package.json中设置sideEffects的值,默认为true,表示所有引入的文件都是存在副作用的,不会删除掉
- 如果我们将sideEffects设置为false,就是告知webpack可以安全的删除未用到的exports
- 如果有一些我们希望保留,可以设置为数组
现在我们在package.json中将sideEffects设置成false,表示都没有副作用,之后在main.js中引入 foo.js文件,并重新打包
// foo.js
console.log('foo')
// main.js
import './js/foo'
我们可以看到打包后的文件里面并没有出现foo.js的代码,因为它认为foo.js不是一个副作用文件,因此在打包过程中将其删除了。
我们也可以将sideEffects设置为数组,并将副作用的文件存放进去,打包就不会删除。
比如我们将sideEffects设置为["./src/js/foo.js"]
,重新打包后
现在我们可以看到打包文件内出现了foo的代码,因此配置已生效。在引入css模块时,我们也可以在rules配置sideEffects,这样css模块就会被认为是副作用文件不会被删除,配置如下
{
test: /.less$/,
use: [
isProduction ? MiniCssExtractPlugin.loader : 'style-loader',
'css-loader',
'postcss-loader',
'less-loader'
],
sideEffects: true
},
CSS Tree Shaking
除了JS的Tree Shaking外,CSS也有Tree Shaking。CSS的Tree Shaking需要借助于一些其他的插件,在早期的时候,我们会使用 PurifyCss 插件来完成CSS的tree shaking,但是目前该库已经不再维护了,目前我们可以使用另外一个库来完成CSS的Tree Shaking,PurgeCSS
,也是一个帮助我们删除未使用的CSS 的工具,安装 npm install purgecss-webpack-plugin -D
。相关配置如下
const PurgecssWebpackPlugin = require('purgecss-webpack-plugin')
const glob = require('glob') // webpack自带安装的插件 用来匹配文件夹或者文件
{
plugins: {
//...
new PurgecssWebpackPlugin({
paths: glob.sync(`${resolve('./src')}/**/*`, {nodir: true}), // 匹配src目录下所有文件,不包括文件夹
safelist: function() { // 安全的白名单,不会被tree shaking
return {
standard: ['html', 'body']
}
}
}),
}
}
该插件目前并不能对vue模版写法中style里面的css tree shaking,有小伙伴知道如何配置么!
动态链接库
DLL是一种软件链接库,我们可以共享,不经常改变的代码,将其抽取成一个共享的库,这个库在之后编译的过程中,会被引入到其他项目的代码中。DLL库的使用分为两步:
1.打包一个DLL库
我们新建一个项目,添加webpack配置文件,我们使用一个webpack内置的 DllPlugin
插件,我们将vue打包成一个DLL库
const { DllPlugin } = require('webpack')
const path = require('path')
const resolve = (src) => {
return path.resolve(__dirname, src)
}
module.exports = {
mode: 'production',
entry: {
vue: ['vue']
},
output: {
path: resolve('./dll'),
filename: 'dll_[name].js',
library: 'dll_[name]'
},
plugins: [
new DllPlugin({
name: 'dll_[name]',
path: resolve('./dll/[name].manifest.json')
})
]
}
重新打包后我们得到一个dll构建目录,里面有 dll_vue.js
和 vue.manifest.json
文件
2.项目中引入DLL库
我们将构建目录复制到需要引用的项目下,并使用如下配置,该配置中我们使用webpack内置的 DllReferencePlugin
插件,除此外我们还需要安装一个 add-asset-html-webpack-plugin
依赖包,该依赖用于将静态资源引入到html中,这里其实做了两个操作,一个是将静态资源赋值到构建目录中,第二是在html注入script并引用该静态资源,安装 npm install add-asset-html-webpack-plugin -d
。
const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin')
const { DllReferencePlugin } = require('webpack')
module.exports = {
//...
plugins: [
//...
new DllReferencePlugin({
context: resolve('./'),
manifest: resolve('./dll/vue.manifest.json')
}),
new AddAssetHtmlPlugin({
outputPath: './auto',
filepath: resolve('./dll/dll_vue.js')
})
]
}
CDN引入
CDN的全称是Content Delivery Network,即内容分发网络。CDN是构建在现有网络基础之上的智能虚拟网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。CDN的关键技术主要有内容存储和分发技术。在实际项目中,我们使用CDN的主要方式主要有两种
- 打包的所有静态资源,放到CDN服务器,用户所有资源都是通过CDN服务器加载
- 一些第三方资源放到CDN服务器上
一般大厂会购买自己的CDN服务,但是正常我们还是会使用第二种方法,比如我们使用了lodash和dayjs这两个库,在生产环境,我们可以配置externals
属性,传入一个对象,key为引入的安装包,value是安装包使用到的全局变量
externals: {
lodash: '_',
dayjs: 'dayjs'
}
我们在模版index.html通过ejs语法配置上依赖包的cdn链接,重新打包后vender.js中就没有了我们依赖的包的内容了。
GIZP压缩
HTTP压缩是一种内置在服务器和客户端之间的,以改进传输速度和带宽利用率的方式,HTTP压缩的流程什么呢
- HTTP数据在服务器发送前就已经被压缩了(可以在webpack中完成)
- 兼容的浏览器在向服务器发送请求时,会告知服务器自己支持哪些压缩格式
- 服务器在浏览器支持的压缩格式下,直接返回对应的压缩后的文件,并且在响应头中告知浏览器
目前的压缩格式非常的多,如下可见
- compress – UNIX的“compress”程序的方法(历史性原因,不推荐大多数应用使用,应该使用gzip或 deflate)
- deflate – 基于deflate算法(定义于RFC 1951)的压缩,使用zlib数据格式封装
- gzip – GNU zip格式(定义于RFC 1952),是目前使用比较广泛的压缩算法
- br – 一种新的开源压缩算法,专为HTTP内容的编码而设计
webpack中相当于是实现了HTTP压缩的第一步操作,我们可以使用 CompressionPlugin
,安装 npm install compression-webpack-plugin -D
,配置如下
const CompressionWebpackPlugin = require('compression-webpack-plugin') // 压缩打包文件插件
{
plugins: [
//...
new CompressionWebpackPlugin({
threshold: 0,
test: /.(css|js)$/i,
minRatio: 0.8,
algorithm: 'gzip'
})
]
}
Chunk内联Html
另外有一个插件,可以辅助将一些chunk出来的模块,内联到html中,减少http请求数量
- 比如runtime的代码,代码量不大,但是是必须加载的
- 那么我们可以直接内联到html中
这个插件是在react-dev-utils中实现的,但是不会删除runtime文件,我们可以安装另一个插件, script-ext-html-webpack-plugin
,安装 npm install script-ext-html-webpack-plugin -D
。配置如下
const ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin') // 辅助将一些chunk出来的模块,内联到html中(会删除runtime-chunk)
{
plugins: [
new ScriptExtHtmlWebpackPlugin({
inline: /runtime.*.js(.gz)?$/ //正则匹配runtime文件名
})
]
}
上述正则如果我们配置了gzip压缩,那么生成的gz文件也会被匹配被删除掉。
Hash值合理配置
在我们给打包的文件进行命名的时候,会使用placeholder,placeholder中有几个属性比较相似: hash
、chunkhash
、contenthash
,hash本身是通过MD4的散列函数处理后,生成一个128位的hash值(32个十六进制)
- hash值的生成和整个项目有关系,比如我们有两个入口文件index.js和main.js,比如我们使用了hash,然后修改了main.js,那么我们的index.js文件名也是会改变。
- chunkhash能解决上面的问题,如果我们修改了其中其中一个入口文件,那么另一个入口文件名就不会改变。
- 但是如果比如index.js中引入了css文件,css是通过单独抽离出来的,当我们修改了index.js文件时,css文件名也是会改变,这个时候我们可以使用contenthash,contenthash跟内容有关,内容不变文件名不变
所以我们一般会对runtime chunk或者css chunk用contenthash,入口文件用chunkhash。
Scope Hoisting
Scope Hoisting从webpack3开始增加的一个新功能,功能是对作用域进行提升,并且让webpack打包后的代码更小、运行更快
默认情况下webpack打包会有很多的函数作用域,包括一些(比如最外层的)IIFE: 无论是从最开始的代码运行,还是加载一个模块,都需要执行一系列的函数,Scope Hoisting可以将函数合并到一个模块中来运行。
使用Scope Hoisting非常的简单,webpack已经内置了对应的模块:
- 在
production
模式下,默认这个模块就会启用 - 在
development
模式下,我们需要自己来打开该模块,配置如下。
new webpack.optimize.ModuleConcatenationPlugin()
转载自:https://juejin.cn/post/7079989314242740232