前端工程化之 Webpack 5 项目构建优化
前情提要:
1. 优化改造导向
一个项目的构建的性能优化,主要是从 构建时间层面
和 构建体积层面
这两个方面入手。
- 构建时间优化:
-
- 缩减与指定编译范围、提前与缓存构建、并行构建
- 构建体积优化:
-
- 分割代码、摇树优化、动态垫片、按需加载、作用提升、压缩资源
2. 构建分析处理
- webpack-bundle-analyzer:这是一个可以审查打包后的体积分布的插件,构建运行后可以可视化的看到每个模块打包的依赖内容,进而进行相应的构建包体积优化;
- speed-measure-webpack-plugin:这是一个打包构建耗时分析插件;
webpack-bundle-analyzer
npm i webpack-bundle-analyzer -D
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
plugins: [
new BundleAnalyzerPlugin(),
]
speed-measure-webpack-plugin
npm i speed-measure-webpack-plugin -D
const SpeedMeasureWebpackPlugin = require('speed-measure-webpack-plugin')
plugins: [
new SpeedMeasureWebpackPlugin(),
]
具体构建优化操作
一、编译性能、构建时间优化
1.1 使用高版本的 Webpack 和 Node.js
这个不多说了,新版本语言底层运行逻辑优化更佳。
1.2 高性能构建处理
多进程处理
thread-loader
/ HappyPack
这是一个多进程构建打包的 loader,能够极大提高构建的速度,只要将 thread-loader 放在构建耗时较大的 loader 之前,比如 babel-loader
- happypack 因为不维护了,因此这里就不考虑了。
thread-loader
npm i thread-loader -D
{
test: /.js$/,
use: [
'thread-loader',
'babel-loader'
],
}
高性能 loader 和 plugin 处理
esbuild-loader
esbuild 是基于 Go 语言开发的,而 esbuild-loader 则是在 esbuild 基础上增加的对 webpack 的支持适配,使得 webpack 能够利用 esbuild (Go 语言)的多线程能力,从而对构建流程进行优化提速。
使用 esbuild-loader 对 babel-loader 与 ts-loader 进行替换处理,甚至利用 esbuild 的多线程力量能将 thread-loader 也省略了引入了。
npm i esbuild-loader -D
const { ESBuildPlugin } = require('esbuild-loader')
{
test: /.js$/,
loader: 'esbuild-loader',
options: {
loader: 'jsx', // Remove this if you're not using JSX
target: 'es2015' // Syntax to compile to (see options below for possible values)
}
},
{
test: /.tsx?$/,
loader: 'esbuild-loader',
options: {
loader: 'tsx', // Or 'ts' if you don't need tsx
target: 'es2015',
tsconfigRaw: require('./tsconfig.json')
}
},
plugins: [
new ESBuildPlugin()
]
1.3 缓存构建资源
webpack 持久化缓存
将构建结果保存到文件系统中,在下次编译时对比每一个文件的内容哈希或时间戳,未发生变化的文件跳过编译操作,直接使用缓存副本,减少重复计算;发生变更的模块则重新执行编译流程。
webpack 5 已经整合相关 plugin,webpack 4 及更低的版本可使用 hard-source-webpack-plugin 插件
- cache.type:缓存类型,支持 'memory' | 'filesystem',需要设置 filesystem 才能开启持久缓存
- cache.cacheDirectory:缓存文件存放的路径,默认为 node_modules/.cache/webpack
- cache.buildDependencies:额外的依赖文件,当这些文件内容发生变化时,缓存会完全失效而执行完整的编译构建,通常可设置为项目配置文件
- cache.managedPaths:受控目录,Webpack 构建时会跳过新旧代码哈希值与时间戳的对比,直接使用缓存副本,默认值为 ['./node_modules']
- cache.profile:是否输出缓存处理过程的详细日志,默认为 false
- cache.maxAge:缓存失效时间,默认值为 5184000000
module.exports = {
cache: {
type: 'filesystem'
},
};
cache-loader
这个 loader 能够缓存构建的资源,进而提高二次构建时候的速度,将 cache-loader 放在构建耗时较大的 loader 之前,比如 babel-loader、vue-loader。
npm i cache-loader -D
{
test: /.js$/,
use: [
'cache-loader',
'babel-loader'
],
},
{
test: /.vue$/,
use: ['cache-loader', 'vue-loader'],
},
1.4 开启热更新
这个仅仅需要在开发构建开启,真正上线生产构建时候需要关闭的功能。
在修改了项目中某一个文件,一般会导致整个项目刷新,这非常消耗构建的时间和 CPU 资源。如果是只刷新相关的修改代码范围的模块,其他保持原状,那能够极大的提高修改代码的重新构建时间。
HotModuleReplacement
const webpack = require('webpack');
{
devServer: {
hot: true
},
plugins: [
new webpack.HotModuleReplacementPlugin()
],
}
1.5 规定编译范围
合理的设置规定 webpack 处理编译范围,能够节约运行构建时候所处理的文件量进而对构建速度和时间进行优化。
缩小打包作用域:
- exclude/include (确定 loader 规则范围)
- resolve.modules 指明第三方模块的绝对路径 (减少不必要的查找)
- resolve.mainFields 只采用 main 字段作为入口文件描述字段 (减少搜索步骤,需要考虑到所有运行时依赖的第三方模块的入口文件描述字段)
- resolve.extensions 尽可能减少后缀尝试的可能性
- noParse 对完全不需要解析的库进行忽略 (不去解析但仍会打包到 bundle 中,注意被忽略掉的文件里不应该包含 import、require、define 等模块化语句)
- IgnorePlugin (完全排除模块)
- 合理使用 alias
exclude / include
{
test: /.js$/,
include: path.resolve(__dirname, '../src'),
exclude: /node_modules/,
use: [
'babel-loader'
]
},
1.6 区分环境构建配置
区分环境去构建是非常重要的,我们要明确知道,开发环境时我们需要哪些配置,不需要哪些配置;而最终打包生产环境时又需要哪些配置,不需要哪些配置:
- 开发环境:去除代码压缩、gzip等优化的配置,大大提高构建速度;
- 生产环境:需要代码压缩、gzip等优化的配置,大大降低最终项目打包体积;
- 调试环境:额外开启体积分析等配置;
mode
在webpack里,通过选择 development, production 或 none 之中的一个,来设置mode参数,从而可以启用webpack内置在相应环境下的优化,其默认值为 production。
- development: 会将 DefinePlugin 中 process.env.NODE_ENV 的值设置为 development,为模块和 chunk 启用确定的名称
- production: 会将 DefinePlugin 中 process.env.NODE_ENV 的值设置为 production,为模块和 chunk 启用确定性的混淆名称,开启 FlagDependencyUsagePlugin,FlagIncludedChunksPlugin,ModuleConcatenationPlugin,NoEmitOnErrorsPlugin 和 TerserPlugin
- none: 不使用任何默认优化选项
Scope Hoisting 处理
「作用域提升」,它可以让 webpack 打包出来的「代码文件更小」,「运行更快」;webpack 将引入到 JS 文件“提升到”它的引入者的顶部。
- 「代码体积更小」,因为函数申明语句会产生大量代码,导致包体积增大(模块越多越明显);
- 代码在运行时因为创建的函数作用域更少,「内存开销也随之变小」。
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');
module.exports = {
// 方法1: 将 `mode` 设置为 production,即可开启
mode: "production",
// 方法2: 将 `optimization.concatenateModules` 设置为 true
optimization: {
concatenateModules: true,
usedExports: true,
providedExports: true,
},
// 方法3: 直接使用 `ModuleConcatenationPlugin` 插件
plugins: [new ModuleConcatenationPlugin()]
};
source-map 类型
source-map 的作用是:方便你报错的时候能定位到错误代码的位置。但是它的体积不容小觑,所以对于不同环境设置不同的类型是很有必要的。
module.exports = {
devtool: 'eval',
};
开发环境 适合使用:
- eval:速度极快,但只能看到原始文件结构,看不到打包前的代码内容
- eval-cheap-source-map:速度比较快,可以看到打包前的代码内容,但看不到 loader 处理之前的源码
- ****eval-cheap-module-source-map:速度比较快,可以看到 loader 处理之前的源码,不过定位不到列级别
- eval-source-map:初次编译较慢,但定位精度最高
生产环境 适合使用:
-
source-map:信息最完整,但安全性最低,外部用户可轻易获取到压缩、混淆之前的源码,慎重使用
-
hidden-source-map:信息较完整,安全性较低,外部用户获取到 .map 文件地址时依然可以拿到源码
-
nosources-source-map:源码信息确实,但安全性较高,需要配合 Sentry 等工具实现完整的 Sourcemap 映射
二、构建打包体积优化
2.1 样式文件单独打包文件
mini-css-extract-plugin
样式代码从js文件中提取到单独的css文件中。
npm i mini-css-extract-plugin -D
const devMode = process.env.NODE_ENV !== 'production'
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = {
module: {
rules: [
{
test: /.(sa|sc|c)ss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
// 这里可以指定一个 publicPath
// 默认使用 webpackOptions.output中的publicPath
// publicPath的配置,和plugins中设置的filename和chunkFilename的名字有关
// 如果打包后,background属性中的图片显示不出来,请检查publicPath的配置是否有误
publicPath: './',
hmr: devMode, // 仅dev环境启用HMR功能
},
},
'css-loader',
'sass-loader'
],
},
]
},
plugins: [
new MiniCssExtractPlugin({
// 这里的配置和webpackOptions.output中的配置相似
// 即可以通过在名字前加路径,来决定打包后的文件存在的路径
filename: devMode ? 'css/[name].css' : 'css/[name].[hash].css',
chunkFilename: devMode ? 'css/[id].css' : 'css/[id].[hash].css',
})
]
}
2.2 样式代码压缩
css-minimizer-webpack-plugin
CSS 代码压缩使用 css-minimizer-webpack-plugin 插件,这个插件能够对 css 样式代码进行压缩、去重。
npm i css-minimizer-webpack-plugin -D
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
optimization: {
minimizer: [
new CssMinimizerPlugin(), // 去重压缩 css
],
}
2.3 删除冗余的 css 样式
purgecss-webpack-plugin
npm i purgecss-webpack-plugin -D
const PurgeCSSPlugin = require('purgecss-webpack-plugin')
plugins: [
new PurgeCSSPlugin({
paths: glob.sync(`${PATHS.src}/**/*`, { nodir: true }),
}),
]
2.4 分包优化
SplitChunksPlugin
webpack4中支持了零配置的特性,同时对块打包也做了优化,CommonsChunkPlugin 已经被移除了,现在是使用 optimization.splitChunks 代替。
设置 webpack 分包分割配置。
chunk
SplitChunksPlugin 默认只对 Async Chunk 生效,开发者也可以通过 optimization.splitChunks.chunks 调整作用范围,该配置项支持如下值:
- 字符串 'all' :对 Initial Chunk 与 Async Chunk 都生效,建议优先使用该值
- 字符串 'initial' :只对 Initial Chunk 生效
- 字符串 'async' :只对 Async Chunk 生效
- 函数 (chunk) => boolean :该函数返回 true 时生效
cacheGroups
缓存组的作用在于能为不同类型的资源设置更具适用性的分包规则
- test:接受正则表达式、函数及字符串,所有符合 test 判断的 Module 或 Chunk 都会被分到该组
- type:接受正则表达式、函数及字符串,与 test 类似均用于筛选分组命中的模块,区别是它判断的依据是文件类型而不是文件名,例如 type = 'json' 会命中所有 JSON 文件
- idHint:字符串型,用于设置 Chunk ID,它还会被追加到最终产物文件名中,例如 idHint = 'vendors' 时,输出产物文件名形如 vendors-xxx-xxx.js
- priority:数字型,用于设置该分组的优先级,若模块命中多个缓存组,则优先被分到 priority 更大的组
runtimeChunk
可以将 optimization.runtimeChunk 设置为 true,以此将运行时代码拆分到一个独立的 Chunk,实现分包。
总结
- minChunks:用于设置引用阈值,被引用次数超过该阈值的 Module 才会进行分包处理
- maxInitialRequest/maxAsyncRequests:用于限制 Initial Chunk(或 Async Chunk) 最大并行请求数,本质上是在限制最终产生的分包数量
- minSize:超过这个尺寸的 Chunk 才会正式被分包
- maxSize:超过这个尺寸的 Chunk 会尝试继续做分包
- maxAsyncSize:与 maxSize 功能类似,但只对异步引入的模块生效
- maxInitialSize:与 maxSize 类似,但只对 entry 配置的入口模块生效
- enforceSizeThreshold:超过这个尺寸的 Chunk 会被强制分包,忽略上述其它 size 限制
- cacheGroups:用于设置缓存组规则,为不同类型的资源设置更有针对性的分包策略
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
libs: {
name: 'chunk-vendor',
test: /[\/]node_modules[\/]/,
priority: 10,
},
corejs: {
name: 'chunk-corejs',
test: /[\/]node_modules[\/]_?(.*)core-js(.*)/,
priority: 15,
},
vue: {
name: 'chunk-vue',
test: /[\/]node_modules[\/]_?(.*)vue(.*)/,
priority: 15,
},
vant: {
name: 'chunk-vant',
test: /[\/]node_modules[\/]_?(.*)vant(.*)/,
priority: 20,
},
commons: {
name: 'chunk-commons',
test: resolve('src/components'),
minChunks: 2,
priority: 5,
reuseExistingChunk: true,
},
},
},
},
模块动态导入
如果不进行模块懒加载的话,最后整个项目代码都会被打包到一个js文件里,单个 js 文件体积非常大,那么当用户网页请求的时候,首屏加载时间会比较长,使用模块懒加载之后,大js文件会分成多个小js文件,网页加载时会按需加载,大大提升首屏加载速度
const asyncChunk = () => import('...')
import(/* webpackChunkName: "async-lib" */ '...').then(component => {
console.log(component)
})
2.5 JS 脚本代码压缩
terser-webpack-plugin
JS代码压缩使用 terser-webpack-plugin,实现打包后JS代码的压缩
npm i terser-webpack-plugin -D
const TerserPlugin = require('terser-webpack-plugin')
optimization: {
minimizer: [
...
new TerserPlugin({ // 压缩JS代码
terserOptions: {
compress: {
drop_console: true, // 去除console
},
},
}), // 压缩JavaScript
],
}
多进程并行压缩
- webpack-paralle-uglify-plugin
- uglifyjs-webpack-plugin 开启 parallel 参数 (不支持ES6)
- terser-webpack-plugin 开启 parallel 参数
esbuild-loader
上面我们提及到使用 esbuild 能够替换 ts-loader、babel-loader 对 ts/js 文件进行处理,这里 esbuild-loader 也是能够进行 js 的代码压缩处理,并且因因为 esbuild 的多线程构建能力,性能速度方面也是不差的。
const {
ESBuildPlugin, ESBuildMinifyPlugin
} = require('esbuild-loader')
optimization: {
minimize: true,
minimizer: [
new ESBuildMinifyPlugin()
],
},
plugins: [
new ESBuildPlugin()
]
2.6 tree-shaking
只打包用到的代码,没用到的代码不打包,而 webpack5 默认开启 tree-shaking,当打包的 mode 为 production 时,自动开启 tree-shaking 进行优化。
- 打包过程中检测工程中没有引用过的模块并进行标记,在资源压缩时将它们从最终的 bundle 中去掉(只能对 ES6 Modlue 生效) 开发中尽可能使用 ES6 Module 的模块,提高 tree shaking 效率;
- 禁用 babel-loader 的模块依赖解析,否则 Webpack 接收到的就都是转换过的 CommonJS 形式的模块,无法进行 tree-shaking;
module.exports = {
mode: 'production'
}
2.7 图片质量压缩
image-webpack-loader
npm i image-webpack-loader -D
{
test: /.(jpg|png|gif|bmp)$/,
use: [
{
loader: 'image-webpack-loader',
options: {
mozjpeg: {
progressive: true,
},
optipng: {
enabled: false,
},
pngquant: {
quality: [0.65, 0.90],
speed: 4
},
gifsicle: {
interlaced: false,
},
webp: {
quality: 75
}
}
}
]
}
2.8 Gzip 压缩
compression-webpack-plugin
开启Gzip后,大大提高用户的页面加载速度,因为gzip的体积比原文件小很多,当然需要运维端 nginx / tomcat 的配置设置支持返回。
npm i compression-webpack-plugin -D
const CompressionPlugin = require('compression-webpack-plugin')
plugins: [
new CompressionPlugin({
algorithm: 'gzip',
threshold: 10240,
minRatio: 0.8
})
]
2.9 外部资源优化
html-webpack-externals-plugin
使用 html-webpack-externals-plugin,将基础包通过 CDN 引入,不打入 bundle 中
- Vue.js、Vue-Router、Vuex
- lodash、axios、echarts、ui 库
npm i html-webpack-externals-plugin -D
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin')
plugins: [
new HtmlWebpackPlugin(),
new HtmlWebpackExternalsPlugin(
externals: [
{
module: 'jquery',
entry: 'dist/jquery.min.js',
global: 'jQuery',
},
{
module: 'jquery',
entry: 'https://unpkg.com/jquery@3.2.1/dist/jquery.min.js',
global: 'jQuery',
},
],
),
]
Dll 处理
当 webpack 打包引入第三方模块的时候,每一次引入,它都会去从 node_modules 中去分析,这样肯定会影响 webpack 打包的一些性能,如果我们能在第一次打包的时候就生成一个第三方打包文件,在接下去的过程中应用第三方文件的时候,就直接使用这个文件,这样就会提高 webpack 的打包速度。
使用 DllPlugin 进行分包,使用 DllReferencePlugin(索引链接) 对 manifest.json 引用,让一些基本不会改动的代码先打包成静态资源,避免反复编译浪费时间。
打包 DLL 库
const path = require('path');
const webpack = require('webpack');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
entry: {
react: ["echarts"]
},
output: {
path: path.resolve(__dirname, "./dll"),
filename: "dll_[name].js",
library: 'dll_[name]'
},
optimization: {
minimizer: [
new TerserPlugin({
extractComments: false
})
]
},
plugins: [
new webpack.DllPlugin({
name: "dll_[name]",
path: path.resolve(__dirname, "./dll/[name].manifest.json")
})
]
}
使用 DLL 库
- 第一步:通过 DllReferencePlugin 插件告知要使用的 DLL 库;
- 第二步:通过 HtmlWebpackExternalsPlugin 插件,将我们打包的 DLL 库引入到 Html 模块中
plugins: [
new webpack.DllReferencePlugin({
context: resolveApp("./"),
manifest: resolveApp("./dll/echarts.manifest.json")
}),
new HtmlWebpackExternalsPlugin ({
externals: [
{
module: 'echart',
entry: resolveApp('./dll/dll_echarts.js'),
global: 'echarts',
},
],
})
],
2.10 合理配置静态资源 hash
改过的文件需要更新hash值,而没改过的文件依然保持原本的hash值,这样才能保证在上线后,浏览器访问时没有改变的文件会命中缓存,从而达到性能优化的目的。
Hash、ContentHash、ChunkHash
- Hash:hash值的生成和整个项目有关系
-
- 一旦项目中有文件改变了,Hash 值就会改变
- chunkhash:有效的解决上面的问题,它会根据不同的入口进行借来解析来生成 hash 值
-
- 修改一个入口的文件,和这个入口相关的文件 hash 值都会改变
- contenthash:表示生成的文件 hash 名称,只和内容有关系
-
- 仅改变当前文件内容会改变 hash 值
output: {
path: path.resolve(__dirname, '../dist'),
// 给js文件加上 contenthash
filename: 'js/chunk-[contenthash].js',
clean: true,
},
2.11 Polyfill 优化
在 babel 中实现 polyfill
主要有两种方式:
- 一种是通过 @babel/polyfill 配合 preset-env 去使用,这种方式可能会存在污染全局作用域。
- 一种是通过 @babel/runtime 配合 @babel/plugin-transform-runtime 去使用,这种方式并不会污染作用域。
- 全局引入会污染全局作用域,但是相对于局部引入来说。它会增加很多额外的引入语句,增加包体积。
动态 polyfill
- 采用 polyfill-service 只给用户返回需要的polyfill,社区维护。 (部分国内奇葩浏览器UA可能无法识别,但可以降级返回所需全部polyfill)
// 访问url,根据 User Agent 直接返回浏览器所需的 polyfills
https://polyfill.io/v3/polyfill.js
2.12 小图片转换 base64
对于一些小图片转成 base64 格式并且整合进 js 文件当中能够有效的减少用户的http网络请求次数,提高用户的体验。
- webpack5 中 url-loader 已被废弃,改用 webpack 内置的 asset-module
url-loader
/ asset-module
asset-module
{
test: /.(png|jpe?g|gif|svg|webp)$/,
type: 'asset',
parser: {
// 转base64的条件
dataUrlCondition: {
maxSize: 25 * 1024, // 25kb
}
},
generator: {
// 打包到 image 文件下
filename: 'images/[contenthash][ext][query]',
},
},
构建优化结果与成效
webpack-bundle-analyzer 分析结果:
- 构建资源大小变化:
-
- 重复依赖的整合打包 + 删除语言包:【11.37 MB】->【10.96 MB】
- CDN + 优化 chunk 分包 + Tree-sharking:【10.96 MB】->【8.41 MB】
- GZip 压缩处理:【8.41 MB】->【2.15 MB】
speed-measure-webpack-plugin 分析:
- 首次冷启动构建时间变化(无任何 cache 缓存):
-
- 【113947ms / 114s / 1min54s】->【169946ms / 170s / 2min50s】
- cache-loader + webpack5 cache 在初次构建时构建缓存损耗时间
- Gzip 压缩处理需要耗时
- 存在缓存后二次构建时间变化
-
- 【113947ms / 114s / 1min54s】->【42458ms / 42s】
- 各类 cache 发挥效用
- 高性能 loader 与 插件:
-
-
- thread-loader 多线程 loader 处理
- esbuil-loader 替换 js 代码压缩处理
-
-
- 缩短到一分钟内,比最初无任何构建缓存优化缩短为差不多是原时间的 1/3 左右,效率极大地提高
转载自:https://juejin.cn/post/7178663743285362748