性能优化
一、认识
Webpack
性能优化手段主要有 并行编译、缓存、缩小资源搜索范围 等等, 从而实现极致的性能优化。
二、持久化缓存
2.1 Webpack5 cache
持久化缓存 算得上是 Webpack 5
最令人振奋的特性之一。它能够将首次构建的过程与结果数据持久化保存到本地文件系,在下次编译时对比每一个文件的内容哈希或时间戳,未发生变化的文件跳过编译操作,直接使用缓存副本,减少重复计算, 发生变更的模块则重新执行编译流程。需要在 Webpack5
中设置 cache.type = 'filesystem'
即可开启。
module.exports = {
//...
cache: {
type: 'filesystem'
},
//...
};
此外,cache
还提供了若干用于配置缓存效果、缓存周期的配置项,包括:
cache.type
: 缓存类型,支持 'memory' | 'filesystem'
,需要设置为 filesystem
才能开启持久缓存
cache.cacheDirectory
: 缓存文件路径,默认为 node_modules/.cache/webpack
cache.buildDependencies
: 额外的依赖文件,当这些文件内容发生变化时,缓存会完全失效而执行完整的编译构建,通常可设置为各种配置文件,如:
module.exports = {
cache: {
type: 'filesystem',
buildDependencies: {
config: [
path.join(__dirname, 'webpack.dll_config.js'),
path.join(__dirname, '.babelrc')
],
},
},
};
cache.managedPaths
: 受控目录,Webpack
构建时会跳过新旧代码哈希值与时间戳的对比,直接使用缓存副本,默认值为 ['./node_modules']
;
cache.profile
: 是否输出缓存处理过程的详细日志,默认为 false
cache.maxAge
: 缓存失效时间,默认值为 5184000000
。
2.2 Webpack4 cache-loader
Webpack5
的持久化缓存用法简单,效果出众,但可惜在 Webpack4
及之前版本原生还没有相关实现,只能借助一些第三方组件实现类似效果,包括:
-
使用
cache-loader
-
使用
hard-source-webpack-plugin
-
使用
Loader
(如babel-loader
、eslint-loader
)自带的缓存能力
cache-loader
: 能够将 Loader
处理结果保存到硬盘,下次运行时若文件内容没有发生变化则直接返回缓存结果。由于 cache-loader
只缓存了 Loader
执行结果,缓存范围与精度不如 Webpack5
内置的缓存功能,所以性能效果相对较低。用法如下:
1. 安装依赖
yarn add -D cache
2. 修改配置,注意必须将 cache-loader
放在 loader
数组首位,例如:
module.exports = {
// ...
module: {
rules: [{
test: /\.js$/,
use: ['cache-loader', 'babel-loader', 'eslint-loader']
}]
},
// ...
};
2.3 Webpack4 hard-source-webpack-plugin
hard-source-webpack-plugin
也是一种实现缓存功能的第三方组件,与 cache-loader
不同的是,它并不仅仅缓存了 Loader
运行结果,还保存了 Webpack
构建过程中许多中间数据,包括: 模块、模块关系、模块 Resolve
结果、Chunks
、Assets
等,效果几乎与 Webpack5
自带的 Cache
对齐。
1. 安装依赖
yarn add -D hard-source-webpack-plugin
2. 添加配置
const HardSourceWebpackPlugin = require("hard-source-webpack-plugin");
module.exports = {
// ...
plugins: [
new HardSourceWebpackPlugin(),
],
};
2.4 Webpack4 babel-loader 自带缓存
使用 babel-loader
时,只需设置 cacheDirectory = true
即可开启缓存功能,例如:
module.exports = {
// ...
module: {
rules: [{
test: /\.m?js$/,
loader: 'babel-loader',
options: {
cacheDirectory: true,
},
}]
},
// ...
};
2.5 Webpack4 eslint-webpack-plugin 自带缓存
ESLint
这一类耗时较长的 Lint
工具也贴心地提供了相应的缓存能力,只需设置 cache = tru
e 即可开启,如
// webpack.config.js
module.exports = {
plugins: [
new ESLintPlugin({ cache: true }),
],
};
2.6 Webpack4 stylelint-webpack-plugin 自带缓存
Stylelint
这一类耗时较长的 Lint
工具也贴心地提供了相应的缓存能力,只需设置 cache = true
即可开启
// webpack.config.js
module.exports = {
plugins: [
new StylelintPlugin({ files: '**/*.css', cache: true }),
],
};
三、并行编译构建
3.1 HappyPack
HappyPack
能够将耗时的文件加载(Loader
)操作拆散到多个子进程中并发执行,子进程执行完毕后再将结果合并回传到 Webpack
进程,从而提升构建性能。
1. 安装依赖
yarn add -D happypack
2. 将原有 loader
配置替换为 happypack/loader
,如
module.exports = {
// ...
module: {
rules: [
{
test: /\.js$/,
use: "happypack/loader",
// 原始配置如:
// use: [
// {
// loader: 'babel-loader',
// options: {
// presets: ['@babel/preset-env']
// }
// },
// 'eslint-loader'
// ]
},
],
},
};
3. 创建 happypack
插件实例,并将原有 loader
配置迁移到插件中,完整配置:
const HappyPack = require("happypack");
module.exports = {
// ...
module: {
rules: [
{
test: /\.js$/,
use: "happypack/loader",
// 原始配置如:
// use: [
// {
// loader: 'babel-loader',
// options: {
// presets: ['@babel/preset-env']
// }
// },
// 'eslint-loader'
// ]
},
],
},
plugins: [
new HappyPack({
// 将原本定义在 `module.rules.use` 中的 Loader 配置迁移到 HappyPack 实例中
loaders: [
{
loader: "babel-loader",
option: {
presets: ["@babel/preset-env"],
},
},
"eslint-loader",
],
}),
],
};
4. 配置完毕后,再次启动 npx webpack
命令,即可使用 HappyPack
的多进程能力提升构建性能
上述示例仅演示了使用 HappyPack
加载单一资源类型的场景,实践中我们还可以创建多个 HappyPack
插件实例,来加载多种资源类型 —— 只需要用 id
参数做好 Loader
与 Plugin
实例的关联即可,例如:
const HappyPack = require('happypack');
module.exports = {
// ...
module: {
rules: [{
test: /\.js?$/,
// 使用 `id` 参数标识该 Loader 对应的 HappyPack 插件示例
use: 'happypack/loader?id=js'
},
{
test: /\.less$/,
use: 'happypack/loader?id=styles'
},
]
},
plugins: [
new HappyPack({
// 注意这里要明确提供 id 属性
id: 'js',
loaders: ['babel-loader', 'eslint-loader']
}),
new HappyPack({
id: 'styles',
loaders: ['style-loader', 'css-loader', 'less-loader']
})
]
};
上面这种多实例模式虽然能应对多种类型资源的加载需求,但默认情况下,HappyPack
插件实例 自行管理 自身所消费的进程,需要导致频繁创建、销毁进程实例 —— 这是非常昂贵的操作,反而会带来新的性能损耗。
为此,HappyPack
提供了一套简单易用的共享进程池接口,只需要创建 HappyPack.ThreadPool
对象,并通过 size
参数限定进程总量,之后将该例配置到各个 HappyPack
插件的 threadPool
属性上即可,例如:
const os = require('os')
const HappyPack = require('happypack');
const happyThreadPool = HappyPack.ThreadPool({
// 设置进程池大小
size: os.cpus().length - 1
});
module.exports = {
// ...
plugins: [
new HappyPack({
id: 'js',
// 设置共享进程池
threadPool: happyThreadPool,
loaders: ['babel-loader', 'eslint-loader']
}),
new HappyPack({
id: 'styles',
threadPool: happyThreadPool,
loaders: ['style-loader', 'css-loader', 'less-loader']
})
]
};
使用 HappyPack.ThreadPool
接口后,HappyPack
会预先创建好一组工作进程,所有插件实例的资源转译任务会通过内置的 HappyThread
对象转发到空闲进程做处理,避免频繁创建、销毁进程。
HappyPack
核心点如下:
-
使用
happypack/loader
代替原本的Loader
序列, 使用HappyPack
插件注入代理执行Loader
序列的逻辑 -
可以创建多个
HappyPack
插件实例,来加载多种资源类型, 只需要用id
参数做好Loader
与Plugin
实例的关联即可 -
默认情况下,
HappyPack
插件实例 自行管理 自身所消费的进程,需要导致频繁创建、销毁进程实例,这是非常昂贵的操作,反而会带来新的性能损耗。为此,HappyPack
提供了一套简单易用的共享进程池接口,只需要创建HappyPack.ThreadPool
对象,并通过size
参数限定进程总量,之后将该例配置到各个HappyPack
插件的threadPool
属性上即可。使用HappyPack.ThreadPool
接口后,HappyPack
会预先创建好一组工作进程,所有插件实例的资源转译任务会通过内置的HappyThread
对象转发到空闲进程做处理,避免频繁创建、销毁进程。
HappyPack
特点如下:
-
作者已经明确表示不会继续维护,扩展性与稳定性缺乏保障,随着
Webpack
本身的发展迭代,可以预见总有一天HappyPack
无法完全兼容Webpack
; -
HappyPack
底层以自己的方式重新实现了加载器逻辑,源码与使用方法都不如Thread-loader
清爽简单,而且会导致一些意想不到的兼容性问题,如awesome-typescript-loader
; -
HappyPack
主要作用于文件加载阶段,并不会影响后续的产物生成、合并、优化等功能,性能收益有限。
HappyPack
底层运行机制如下:
-
happlypack/loader
接受到转译请求后,从Webpack
配置中读取出相应HappyPack
插件实例; -
调用插件实例的
compile
方法,创建HappyThread
实例(或从HappyThreadPool
取出空闲实例) -
HappyThread
内部调用child_process.fork
创建子进程,并执行HappyWorkerChannel
文件 -
HappyWorkerChannel
创建HappyWorker
,开始执行Loader
转译逻辑;
总结: 对于 Webpack4
之前的项目,可以使用 HappyPack
实现并行文件加载
3.2 Thread-loader
Thread-loader
与 HappyPack
功能类似,都是以多进程方式加载文件的 Webpack
组件,两者主要区别:
-
Thread-loader
由Webpack
官方提供,目前还处于持续迭代维护状态,理论上更可靠; -
Thread-loader
只提供了一个Loader
组件,用法简单很多 -
HappyPack
启动后会创建一套Mock
上下文环境 —— 包含emitFile
等接口,并传递给Loader
,因此对大多数Loader
来说,运行在HappyPack
与运行在Webpack
原生环境相比没有太大差异;但Thread-loader
并不具备这一特性,所以要求Loader
内不能调用特定上下文接口,兼容性较差。
1. 安装依赖
yarn add -D thread-loader
2. 将 Thread-loader
放在 use
数组首位,确保最先运行,如:
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: ["thread-loader", "babel-loader", "eslint-loader"],
},
],
},
};
启动后,Thread-loader
会在加载文件时创建新的进程,在子进程中使用 loader-runner
库运行 thread-loader
之后的 Loader
组件,执行完毕后再将结果回传到 Webpack
主进程,从而实现性能更佳的文件加载转译效果。
此外,Thread-loader
还提供了一系列用于控制并发逻辑的配置项,包括:
-
workers
: 子进程总数,默认值为require('os').cpus() - 1
; -
workerParallelJobs
: 单个进程中并发执行的任务数; -
poolTimeout
: 子进程如果一直保持空闲状态,超过这个时间后会被关闭; -
poolRespawn
: 是否允许在子进程关闭后重新创建新的子进程,一般设置为false
即可; -
workerNodeArgs
: 用于设置启动子进程时,额外附加的参数。
module.exports = {
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: "thread-loader",
options: {
workers: 2,
workerParallelJobs: 50,
// ...
},
},
"babel-loader",
"eslint-loader",
],
},
],
},
};
不过,Thread-loader
也同样面临着频繁的子进程创建、销毁所带来的性能问题,为此,Thread-loader
提供了 warmup
接口用于前置创建若干工作子进程,降低构建时延,用法:
const threadLoader = require("thread-loader");
threadLoader.warmup(
{
// 可传入上述 thread-loader 参数
workers: 2,
workerParallelJobs: 50,
},
[
// 子进程中需要预加载的 node 模块
"babel-loader",
"babel-preset-es2015",
"sass-loader",
]
);
与 HappyPack
相比,Thread-loader
有两个突出的优点,一是产自 Webpack
官方团队,后续有长期维护计划,稳定性有保障;二是用法更简单。但它不可避免的也存在一些问题:
-
在
Thread-loader
中运行的Loader
不能调用emitAsset
等接口,这会导致style-loader
这一类加载器无法正常工作,解决方案是将这类组件放置在thread-loader
之前,如['style-loader', 'thread-loader', 'css-loader']
; -
Loader
中不能获取compilation
、compiler
等实例对象,也无法获取Webpack
配置。
总结: Webpack4
之后则建议使用 Thread-loader
3.3 Parallel-Webpack
Thread-loader
、HappyPack
这类组件所提供的并行能力都仅作用于文件加载过程,对后续 AST
解析、依赖收集、打包、优化代码等过程均没有影响,理论收益还是比较有限的。对此,社区还提供了另一种并行度更高,以多个独立进程运行 Webpack
实例的方案 —— Parallel-Webpack
,基本用法:
1. 安装依赖
yarn add -D parallel-webpack
2. 在 webpack.config.js
配置文件中导出多个 Webpack
配置对象,如:
module.exports = [{
entry: 'pageA.js',
output: {
path: './dist',
filename: 'pageA.js'
}
}, {
entry: 'pageB.js',
output: {
path: './dist',
filename: 'pageB.js'
}
}];
3. 执行 npx parallel-webpack
命令
Parallel-Webpack
会为配置文件中导出的每个 Webpack
配置对象启动一个独立的构建进程,从而实现并行编译的效果。底层原理很简单,基本上就是在 Webpack
上套了个壳:
-
根据传入的配置项数量,调用
worker-farm
创建复数个工作进程; -
工作进程内调用
Webpack
执行构建; -
工作进程执行完毕后,调用
node-ipc
向主进程发送结束信号。
为了更好地支持多种配置的编译,Parallel-Webpack
还提供了 createVariants
函数,用于根据给定变量组合,生成多份 Webpack
配置对象,如:
const createVariants = require('parallel-webpack').createVariants
const webpack = require('webpack')
const baseOptions = {
entry: './index.js'
}
// 配置变量组合
// 属性名为 webpack 配置属性;属性值为可选的变量
// 下述变量组合将最终产生 2*2*4 = 16 种形态的配置对象
const variants = {
minified: [true, false],
debug: [true, false],
target: ['commonjs2', 'var', 'umd', 'amd']
}
function createConfig (options) {
const plugins = [
new webpack.DefinePlugin({
DEBUG: JSON.stringify(JSON.parse(options.debug))
})
]
return {
output: {
path: './dist/',
filename: 'MyLib.' +
options.target +
(options.minified ? '.min' : '') +
(options.debug ? '.debug' : '') +
'.js'
},
plugins: plugins
}
}
module.exports = createVariants(baseOptions, variants, createConfig)
虽然,parallel-webpack
相对于 Thread-loader
、HappyPack
有更高的并行度,但进程实例之间并没有做任何形式的通讯,这可能导致相同的工作在不同进程 —— 或者说不同 CPU
核上被重复执行。
例如需要对同一份代码同时打包出压缩和非压缩版本时,在 parallel-webpack
方案下,前置的资源加载、依赖解析、AST
分析等操作会被重复执行,仅仅最终阶段生成代码时有所差异。
这种技术实现,对单 entry
的项目没有任何收益,只会徒增进程创建成本;但特别适合 MPA
等多 entry
场景,或者需要同时编译出 esm
、umd
、amd
等多种产物形态的类库场景。
总结: 多实例并行构建场景建议使用 Parallel-Webpack
实现并行
四、并行代码压缩
代码压缩 是指在不改变代码功能的前提下,从声明式(HTML
、CSS
)或命令式(JavaScript
)语言中删除所有不必要的字符(备注、变量名压缩、逻辑语句合并等),减少代码体积的过程,这在 Web
场景中能够有效减少浏览器从服务器获取代码资源所需要消耗的传输量,降低网络通讯耗时,提升页面启动速度,是一种非常基础且性价比特别高的应用性能优化方案。
4.1 使用 TerserWebpackPlugin 压缩 JS
Terser
是当下最为流行 的 ES6
代码压缩工具之一,支持 Dead-Code Eliminate
、删除注释、删除空格、代码合并、变量名简化等等一系列代码压缩功能。Terser
的前身是大名鼎鼎的 UglifyJS
,它在 UglifyJS
基础上增加了 ES6
语法支持,并重构代码解析、压缩算法,使得执行效率与压缩率都有较大提升。
Webpack5.0
后默认使用 Terser
作为 JavaScript
代码压缩器,简单用法只需通过 optimization.minimize
配置项开启压缩功能即可:
module.exports = {
//...
optimization: {
minimize: true
}
};
提示: 使用 mode = 'production'
启动生产模式构建时,默认也会开启 Terser
压缩。
多数情况下使用默认 Terser
配置即可,必要时也可以手动创建 terser-webpack-plugin
实例并传入压缩配置实现更精细的压缩功能,例如:
const TerserPlugin = require("terser-webpack-plugin");
module.exports = {
// ...
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
terserOptions: {
compress: {
reduce_vars: true,
pure_funcs: ["console.log"],
},
// ...
},
}),
],
},
};
示例中的 minimize
用于控制是否开启压缩,只有 minimize = true'
时才会调用 minimizer
声明的压缩器数组(没错,这是数组形式)执行压缩操作。
terser-webpack-plugin
是一个颇为复杂的 Webpack
插件,提供下述配置项:
-
test
: 只有命中该配置的产物路径才会执行压缩,功能与module.rules.test
相似; -
include
: 在该范围内的产物才会执行压缩,功能与module.rules.include
相似; -
exclude
: 与include
相反,不在该范围内的产物才会执行压缩,功能与module.rules.exclude
相似; -
parallel
: 是否启动并行压缩,默认值为true
,此时会按os.cpus().length - 1
启动若干进程并发执行; -
minify
: 用于配置压缩器,支持传入自定义压缩函数,也支持swc/esbuild/uglifyjs
等值 -
terserOptions
: 传入minify
—— “压缩器”函数的配置参数 -
extractComments
: 是否将代码中的备注抽取为单独文件,可配合特殊备注如@license
使用。
这些配置项总结下来有两个值得关注的逻辑:
-
可以通过
test/include/exclude
过滤插件的执行范围,这个功能配合minimizer
的数组特性,可以实现针对不同产物执行不同的压缩策略,例如:const TerserPlugin = require("terser-webpack-plugin");
module.exports = {
entry: { foo: "./src/foo.js", bar: "./src/bar.js" },
output: {
filename: "[name].js",
// ...
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
test: /foo\.js$/i,
extractComments: "all",
}),
new TerserPlugin({
test: /bar\.js/,
extractComments: false,
}),
],
},
};
示例中,针对 foo.js
产物文件会执行 exctractComments
逻辑,将备注信息抽取为单独文件;而针对 bar.js
,由于 extractComments = false
,不单独抽取备注内容。
-
terser-webpack-plugin
插件并不只是Terser
的简单包装,它更像是一个代码压缩功能骨架,底层还支持使用SWC
、UglifyJS
、ESBuild
作为压缩器,使用时只需要通过minify
参数切换即可,例如:module.exports = {
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
minify: TerserPlugin.swcMinify,
// `terserOptions` 将被传递到 `swc` (`@swc/core`) 工具
// 具体配置参数可参考:https://swc.rs/docs/config-js-minify
terserOptions: {},
}),
],
},
};TerserPlugin
内置如下压缩器:-
TerserPlugin.terserMinify
: 依赖于terser
库 -
TerserPlugin.uglifyJsMinify
: 依赖于uglify-js
,需要手动安装yarn add -D uglify-js
-
TerserPlugin.swcMinify
: 依赖于@swc/core
,需要手动安装yarn add -D @swc/core
-
TerserPlugin.esbuildMinify
: 依赖于esbuild
,需要手动安装yarn add -D esbuild
不同压缩器功能、性能差异较大,据我了解,
ESBuild
与SWC
这两个基于Go
与Rust
编写的压缩器性能更佳,且效果已经基本趋于稳定,虽然功能还比不上Terser
,但某些构建性能敏感场景下不失为一种不错的选择。 -
4.2 使用 CssMinimizerWebpackPlugin 压缩 CSS
CSS
是一种灵活多变得略显复杂的声明式语言,同样的样式效果可以被表达成非常多样的代码语句,例如一个非常典型的案例:margin: 10px
,可以被写成:
-
margin: 10px 10px;
-
margin-left: 10px; margin-right: 10px;...
这些不同的表述方式最终实现的样式效果相同,那理所当然的可以用最精简的方式压缩代码。扩展开来:
h1::before,
h1:before {
/* 下面各种备注都可以删除 */
/* margin 值可简写 */
margin: 10px 20px 10px 20px;
/* 颜色值也可以简写 */
color: #ff0000;
/* 删除重复属性 */
font-weight: 400;
font-weight: 400;
/* position 字面量值可简化为百分比 */
background-position: bottom right;
/* 渐变参数可精简 */
background: linear-gradient(
to bottom,
#ffe500 0%,
#ffe500 50%,
#121 50%,
#121 100%
);
/* 初始值也可精简 */
min-width: initial;
}
上述代码就有不少地方可以精简优化,使用 cssnano
压缩后大致上可简化为:
h1:before {
margin: 10px 20px;
color: red;
font-weight: 400;
background-position: 100% 100%;
quotes: "«" "»";
background: linear-gradient(180deg, #ffe500, #ffe500 50%, #121 0, #121);
min-width: 0;
}
从原来的 422
个字符精简为 212
个字符,接近 50%
,我们日常编写的 CSS
语句也跟上述示例类似,通常都会有不少可以优化压缩的地方。
Webpack
社区中有不少实现 CSS
代码压缩的插件,例如:css-minimizer-webpack-plugin
,用法:
1. 安装依赖
yarn add -D css-minimizer-webpack-plugin
2. 修改 Webpack
配置
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
//...
module: {
rules: [
{
test: /.css$/,
// 注意,这里用的是 `MiniCssExtractPlugin.loader` 而不是 `style-loader`
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
optimization: {
minimize: true,
minimizer: [
// Webpack5 之后,约定使用 `'...'` 字面量保留默认 `minimizer` 配置
"...",
new CssMinimizerPlugin(),
],
},
// 需要使用 `mini-css-extract-plugin` 将 CSS 代码抽取为单独文件
// 才能命中 `css-minimizer-webpack-plugin` 默认的 `test` 规则
plugins: [new MiniCssExtractPlugin()],
};
这里的配置逻辑,一是使用 mini-css-extract-plugin
将 CSS
代码抽取为单独的 CSS
产物文件,这样才能命中 css-minimizer-webpack-plugin
默认的 test
逻辑;二是使用 css-minimizer-webpack-plugin
压缩 CSS
代码。效果:
与 terser-webpack-plugin
类似,css-minimizer-webpack-plugin
也支持 test
、include
、exclude
、minify
、minimizerOptions
配置,其中 minify
支持:
-
CssMinimizerPlugin.cssnanoMinify
: 默认值,使用cssnano
压缩代码,不需要额外安装依赖; -
CssMinimizerPlugin.cssoMinify
: 使用csso
压缩代码,需要手动安装依赖yarn add -D csso
; -
CssMinimizerPlugin.cleanCssMinify
: 使用clean-css
压缩代码,需要手动安装依赖yarn add -D clean-css
; -
CssMinimizerPlugin.esbuildMinify
: 使用ESBuild
压缩代码,需要手动安装依赖yarn add -D esbuild
; -
CssMinimizerPlugin.parcelCssMinify
: 使用parcel-css
压缩代码,需要手动安装依赖yarn add -D @parcel/css
。
其中 parcel-css
与 ESBuild
压缩性能相对较佳, 但两者功能与兼容性稍弱,多数情况下推荐使用 cssnano
。
4.3 使用 HtmlMinifierTerser 压缩 HTML
现代 Web
应用大多会选择使用 React
、Vue
等 MVVM
框架,这衍生出来的一个副作用是原生 HTML
的开发需求越来越少,HTML
代码占比越来越低,所以大多数现代 Web
项目中其实并不需要考虑为 HTML
配置代码压缩工作流。不过凡事都有例外,某些场景如 SSG
或官网一类偏静态的应用中就存在大量可被优化的 HTML
代码,为此社区也提供了一些相关的工程化工具,例如 html-minifier-terser
。
html-minifier-terser
是一个基于 JavaScript
实现的、高度可配置的 HTML
压缩器,支持一系列压缩特性如:
-
collapseWhitespace
: 删除节点间的空字符串,如:<!-- 原始代码: -->
<div> <p> foo </p> </div>
<!-- 经过压缩的代码: -->
<div><p>foo</p></div> -
removeComments
:删除备注,如:<!-- 原始代码: -->
<!-- some comment --><p>blah</p>
<!-- 经过压缩的代码: -->
<p>blah</p> -
collapseBooleanAttributes
: 删除HTML
的Boolean
属性值,如:<!-- 原始代码: -->
<input value="foo" readonly="readonly">
<!-- 经过压缩的代码: -->
<input value="foo" readonly>
我们可以借助 html-minimizer-webpack-plugin
插件接入 html-minifier-terser
压缩器,步骤:
1. 安装依赖
yarn add -D html-minimizer-webpack-plugin
2. 修改 Webpack
配置,如
const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlMinimizerPlugin = require("html-minimizer-webpack-plugin");
module.exports = {
// ...
optimization: {
minimize: true,
minimizer: [
// Webpack5 之后,约定使用 `'...'` 字面量保留默认 `minimizer` 配置
"...",
new HtmlMinimizerPlugin({
minimizerOptions: {
// 折叠 Boolean 型属性
collapseBooleanAttributes: true,
// 使用精简 `doctype` 定义
useShortDoctype: true,
// ...
},
}),
],
},
plugins: [
// 简单起见,这里我们使用 `html-webpack-plugin` 自动生成 HTML 演示文件
new HtmlWebpackPlugin({
templateContent: `<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta charset="UTF-8" />
<title>webpack App</title>
</head>
<body>
<input readonly="readonly"/>
<!-- comments -->
<script src="index_bundle.js"></script>
</body>
</html>`,
}),
],
};
这段配置的关键逻辑,一是通过 html-webpack-plugin
生成 HTML
文件,这里为了演示方便特意在 HTML
模板 templateContent
中插入一些可以被压缩的代码;二是通过 html-minimizer-plugin
压缩 HTML
代码,效果:
上图中左边是正常构建结果,右图是经过 html-minimizer-plugin
压缩后的构建结果,可以看到如 doctype
标签被删掉若干不重要的声明,文档中的备注也被删除,等等。
与 terser-webpack-plugin
类似,html-minimizer-webpack-plugin
也支持 include
、test
、minimizerOptions
等等一系列配置,此处不再赘述。
五、约束 Loader 执行范围
Loader
组件用于将各式文件资源转换为可被 Webpack
理解、构建的标准 JavaScript
代码,正是这一特性支撑起 Webpack
强大的资源处理能力。不过,Loader
在执行内容转换的过程中可能需要比较密集的 CPU
运算,如 babel-loader
、eslint-loader
、vue-loader
等,需要反复执行代码到 AST
,AST
到代码的转换。因此, Loader
对文件的转换操作很耗时,需要让尽可能少的文件被 Loader
处理。
因此开发者可以根据实际场景,使用 module.rules.include
、module.rules.exclude
等配置项,限定 Loader
的执行范围 —— 通常可以排除 node_module
文件夹
// webpack.config.js
module.exports = {
// ...
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: ["babel-loader", "eslint-loader"],
},
],
},
};
配置 exclude: /node_modules/
属性后,Webpack
在处理 node_modules
中的 js
文件时会直接跳过这个 rule
项,不会为这些文件执行 Loader
逻辑。
六、使用 noParse 跳过文件编译
有不少 NPM
库已经提前做好打包处理(文件合并、Polyfill
、ESM
转 CJS
等),不需要二次编译就可以直接放在浏览器上运行,例如:
-
Vue2
的node_modules/vue/dist/vue.runtime.esm.js
文件; -
React
的node_modules/react/umd/react.production.min.js
文件; -
Lodash
的node_modules/lodash/lodash.js
文件
对我们来说,这些资源文件都是独立、内聚的代码片段,没必要重复做代码解析、依赖分析、转译等操作,此时可以使用 module.noParse
配置项跳过这些资源,例如:
// webpack.config.js
module.exports = {
//...
module: {
noParse: /lodash|react/,
},
};
配置后,所有匹配该正则的文件都会跳过前置的构建、分析动作,直接将内容合并进 Chunk
,从而提升构建速度。不过,使用 noParse
时需要注意:
-
由于跳过了前置的
AST
分析动作,构建过程无法发现文件中可能存在的语法错误,需要到运行(或Terser
做压缩)时才能发现问题,所以必须确保noParse
的文件内容正确性; -
由于跳过了依赖分析的过程,所以文件中,建议不要包含
import/export/require/define
等模块导入导出语句 —— 换句话说,noParse
文件不能存在对其它文件的依赖,除非运行环境支持这种模块化方案; -
由于跳过了内容分析过程,
Webpack
无法标记该文件的导出值,也就无法实现Tree-shaking
。
综上,建议在使用 noParse
配置 NPM
库前,先检查 NPM
库默认导出的资源满足要求,例如 React@18
默认定义的导出文件是 index.js
:
// react package.json
{
"name": "react",
// ...
"main": "index.js"
}
但 node_module/react/index.js
文件包含了模块导入语句 require
:
// node_module/react/index.js
'use strict';
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/react.production.min.js');
} else {
module.exports = require('./cjs/react.development.js');
}
此时,真正有效的代码被包含在 react.development.js
(或 react.production.min.js
)中,但 Webpack
只会打包这段 index.js
内容,也就造成了产物中实际上并没有真正包含 React
。针对这个问题,我们可以先找到适用的代码文件,然后用 resolve.alias
配置项重定向到该文件:
// webpack.config.js
module.exports = {
// ...
module: {
noParse: /react|lodash/,
},
resolve: {
alias: {
react: path.join(
__dirname,
process.env.NODE_ENV === "production"
? "./node_modules/react/cjs/react.production.min.js"
: "./node_modules/react/cjs/react.development.js"
),
},
},
};
注意: 使用 externals
也能将部分依赖放到构建体系之外,实现与 noParse
类似的效果
七、开发模式禁用产物优化
Webpack
提供了许多产物优化功能,例如:Tree-Shaking
、SplitChunks
、Minimizer
等,这些能力能够有效减少最终产物的尺寸,提升生产环境下的运行性能,但这些优化在开发环境中意义不大,反而会增加构建器的负担(都是性能大户)。因此,开发模式下建议关闭这一类优化功能,具体措施:
-
确保
mode='development'
或mode = 'none'
,关闭默认优化策略; -
optimization.minimize
保持默认值或false
,关闭代码压缩; -
optimization.concatenateModules
保持默认值或false
,关闭模块合并; -
optimization.splitChunks
保持默认值或false
,关闭代码分包; -
optimization.usedExports
保持默认值或false
,关闭Tree-shaking
功能;
最终,建议开发环境配置如:
module.exports = {
// ...
mode: "development",
optimization: {
removeAvailableModules: false,
removeEmptyChunks: false,
splitChunks: false,
minimize: false,
concatenateModules: false,
usedExports: false,
},
};
八、最小化 watch 监控范围
在 watch
模式下(通过 npx webpack --watch
命令启动),Webpack
会持续监听项目目录中所有代码文件,发生变化时执行 rebuild
命令。不过,通常情况下前端项目中部分资源并不会频繁更新,例如 node_modules
,此时可以设置 watchOptions.ignored
属性忽略这些文件,例如:
// webpack.config.js
module.exports = {
//...
watchOptions: {
ignored: /node_modules/
},
};
九、跳过 TS 类型检查
JavaScript
本身是一门弱类型语言,这在多人协作项目中经常会引起一些不必要的类型错误,影响开发效率。随前端能力与职能范围的不断扩展,前端项目的复杂性与协作难度也在不断上升,TypeScript
所提供的静态类型检查能力也就被越来越多人所采纳。
不过,类型检查涉及 AST
解析、遍历以及其它非常消耗 CPU
的操作,会给工程化流程带来比较大的性能负担,因此我们可以选择关闭 ts-loader
的类型检查功能:
module.exports = {
// ...
module: {
rules: [{
test: /\.ts$/,
use: [
{
loader: 'ts-loader',
options: {
// 设置为“仅编译”,关闭类型检查
transpileOnly: true
}
}
],
}],
}
};
有同学可能会问:“没有类型检查,那还用 TypeScript
干嘛?”,很简单,我们可以:
-
可以借助编辑器的
TypeScript
插件实现代码检查; -
使用
fork-ts-checker-webpack-plugin
插件将类型检查能力剥离到子进程 执行,例如:
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
module.exports = {
// ...
module: {
rules: [{
test: /\.ts$/,
use: [
{
loader: 'ts-loader',
options: {
transpileOnly: true
}
}
],
}, ],
},
plugins:[
// fork 出子进程,专门用于执行类型检查
new ForkTsCheckerWebpackPlugin()
]
};
这样,既可以获得 Typescript
静态类型检查能力,又能提升整体编译速度。
十、优化 ESLint
性能
ESLint
能帮助我们极低成本发现代码风格问题,维护代码质量,但若使用不当 —— 例如在开发模式下使用 eslint-loader
实现实时代码检查,会带来比较高昂且不必要的性能成本,我们可以选择其它更聪明的方式接入 ESLint
。
例如,使用新版本组件 eslint-webpack-plugin
替代旧版 eslint-loader
,两者差异在于,eslint-webpack-plugin
在模块构建完毕(compilation.hooks.succeedModule
钩子)后执行检查,不会阻断文件加载流程,性能更优,用法:
1. 安装依赖
yarn add -D eslint-webpack-plugin
2. 添加插件
const ESLintPlugin = require('eslint-webpack-plugin');
module.exports = {
// ...
plugins: [new ESLintPlugin(options)],
// ...
};
或者,可以选择在特定条件、场景下执行 ESLint
,减少对构建流程的影响,如:
-
使用编辑器插件完成
ESLint
检查、错误提示、自动Fix
,如VS Code
的dbaeumer.vscode-eslint
插件; -
使用
husky
,仅在代码提交前执行ESLint
代码检查; -
仅在
production
构建中使用ESLint
,能够有效提高开发阶段的构建效率。
十一、慎用 source-map
source-map
是一种将经过编译、压缩、混淆的代码映射回源码的技术,它能够帮助开发者迅速定位到更有意义、更结构化的源码中,方便调试。不过,source-map
操作本身也有很大构建性能开销,建议读者根据实际场景慎重选择最合适的 source-map
方案。
针对 source-map
功能,Webpack
提供了 devtool
选项,可以配置 eval
、source-map
、cheap-source-map
等值,不考虑其它因素的情况下,最佳实践:
-
开发环境使用
eval
,确保最佳编译速度; -
生产环境使用
source-map
,获取最高质量。
十二、设置 resolve 缩小搜索范围
Webpack
默认提供了一套同时兼容 CMD
、AMD
、ESM
等模块化方案的资源搜索规则 —— enhanced-resolve
,它能将各种模块导入语句准确定位到模块对应的物理资源路径。例如:
-
import 'lodash'
这一类引入NPM
包的语句会被enhanced-resolve
定位到对应包体文件路径node_modules/lodash/index.js
; -
import './a'
这类不带文件后缀名的语句,则可能被定位到./a.js
文件; -
import '@/a'
这类化名路径的引用,则可能被定位到$PROJECT_ROOT/src/a.js
文件。
需要注意,这类增强资源搜索体验的特性背后涉及许多 IO
操作,本身可能引起较大的性能消耗,开发者可根据实际情况调整 resolve
配置,缩小资源搜索范围,包括:
12.1 resolve.extensions 配置
例如,当模块导入语句未携带文件后缀时,如 import './a'
,Webpack 会遍历 resolve.extensions
项定义的后缀名列表,尝试在 './a'
路径追加后缀名,搜索对应物理文件。
在 Webpack5
中,resolve.extensions
默认值为 ['.js', '.json', '.wasm']
,这意味着 Webpack
在针对不带后缀名的引入语句时,可能需要执行三次判断逻辑才能完成文件搜索,针对这种情况,可行的优化措施包括:
-
修改
resolve.extensions
配置项,减少匹配次数; -
代码中尽量补齐文件后缀名;
-
设置
resolve.enforceExtension = true
,强制要求开发者提供明确的模块后缀名,不过这种做法侵入性太强,不太推荐。
12.2 resolve.modules 配置
类似于 Node
模块搜索逻辑,当 Webpack
遇到 import 'lodash'
这样的 npm
包导入语句时,会先尝试在当前项目 node_modules
目录搜索资源,如果找不到,则按目录层级尝试逐级向上查找 node_modules
目录,如果依然找不到,则最终尝试在全局 node_modules
中搜索。
在一个依赖管理良好的系统中,我们通常会尽量将 NPM
包安装在有限层级内,因此 Webpack
这一逐层查找的逻辑大多数情况下实用性并不高,开发者可以通过修改 resolve.modules
配置项,主动关闭逐层搜索功能,例如:
// webpack.config.js
const path = require('path');
module.exports = {
//...
resolve: {
modules: [path.resolve(__dirname, 'node_modules')],
},
};
12.3 resolve.mainFiles 配置
与 resolve.extensions
类似,resolve.mainFiles
配置项用于定义文件夹默认文件名,例如对于 import './dir'
请求,假设 resolve.mainFiles = ['index', 'home']
,Webpack
会按依次测试 ./dir/index
与 ./dir/home
文件是否存在。
因此,实际项目中应控制 resolve.mainFiles
数组数量,减少匹配次数。
十三、动态加载, 减少首屏资源加载量
Webpack
默认会将同一个 Entry
下的所有模块全部打包成一个产物文件 —— 包括那些与页面关键渲染路径无关的代码,这会导致页面初始化时需要花费多余时间去下载这部分暂时用不上的代码,影响首屏渲染性能。例如:
import someBigMethod from "./someBigMethod";
document.getElementById("someButton").addEventListener("click", () => {
someBigMethod();
});
逻辑上,直到点击页面的 someButton
按钮时才会调用 someBigMethod
方法,因此这部分代码没必要出现在首屏资源列表中,此时我们可以使用 Webpack
的动态加载功能将该模块更改为异步导入,修改上述代码:
document.getElementById("someButton").addEventListener("click", async () => {
// 使用 `import("module")` 动态加载模块
const someBigMethod = await import("./someBigMethod");
someBigMethod();
});
此时,重新构建将产生额外的产物文件 src_someBigMethod_js.js
,这个文件直到执行 import
语句时 —— 也就是上例 someButton
被点击时才被加载到浏览器,也就不会影响到关键渲染路径了。
动态加载是 Webpack
内置能力之一,我们不需要做任何额外配置就可以通过动态导入语句(import
、require.ensure
)轻易实现。但请 注意,这一特性有时候反而会带来一些新的性能问题:一是过度使用会使产物变得过度细碎,产物文件过多,运行时 HTTP
通讯次数也会变多,在 HTTP 1.x
环境下这可能反而会降低网络性能,得不偿失;二是使用时 Webpack
需要在客户端注入一大段用于支持动态加载特性的 Runtime
这段代码即使经过压缩也高达 2.5KB
左右,如果动态导入的代码量少于这段 Runtime
代码的体积,那就完全是一笔赔本买卖了。
因此,请务必慎重,多数情况下我们没必要为小模块使用动态加载能力!目前社区比较常见的用法是配合 SPA
的前端路由能力实现页面级别的动态加载,例如在 Vue
中:
import { createRouter, createWebHashHistory } from "vue-router";
const Home = () => import("./Home.vue");
const Foo = () => import(/* webpackChunkName: "sub-pages" */ "./Foo.vue");
const Bar = () => import(/* webpackChunkName: "sub-pages" */ "./Bar.vue");
// 基础页面
const routes = [
{ path: "/bar", name: "Bar", component: Bar },
{ path: "/foo", name: "Foo", component: Foo },
{ path: "/", name: "Home", component: Home },
];
const router = createRouter({
history: createWebHashHistory(),
routes,
});
export default router;
示例中,Home/Foo/Bar
三个组件均通过 import()
语句动态导入,这使得仅当页面切换到相应路由时才会加载对应组件代码。另外,Foo
与 Bar
组件的导入语句比较特殊:
import(/* webpackChunkName: "sub-pages" */ "./Bar.vue");
webpackChunkName
用于指定该异步模块的 Chunk
名称,相同 Chunk
名称的模块最终会打包在一起,这一特性能帮助开发者将一些关联度较高,或比较细碎的模块合并到同一个产物文件,能够用于管理最终产物数量。
十四、正确使用 Hash
注意,Webpack
只是一个工程化构建工具,没有能力决定应用最终在网络分发时的缓存规则,但我们可以调整产物文件的名称(通过 Hash
)与内容(通过 Code Splitting
),使其更适配 HTTP
持久化缓存策略。
Webpack
提供了一种模板字符串(Template String
)能力,用于根据构建情况动态拼接产物文件名称(output.filename
),规则稍微有点复杂,但从性能角度看,比较值得关注的是其中的几个 Hash
占位符,包括:
-
[fullhash]
: 整个项目的内容Hash
值,项目中任意模块变化都会产生新的fullhash
; -
[chunkhash]
: 产物对应Chunk
的Hash
,Chunk
中任意模块变化都会产生新的chunkhash
; -
[contenthash
: 产物内容Hash
值,仅当产物内容发生变化时才会产生新的contenthash
,因此实用性较高。
用法很简单,只需要在 output.filename
值中插入相应占位符即可,如 [name]-[contenthash].js
。我们来看个完整例子,假设对于下述源码结构:
src/
├── index.css
├── index.js
└── foo.js
之后,使用下述配置:
module.exports = {
// ...
entry: { index: "./src/index.js", foo: "./src/foo.js" },
output: {
filename: "[name]-[contenthash].js",
path: path.resolve(__dirname, "dist"),
},
plugins: [new MiniCssExtractPlugin({ filename: "[name]-[contenthash].css" })],
};
示例包含 index.js
与 foo.js
两个入口,且分别在 ouput.filename
与 MiniCssExtractPlugin.filename
中使用 [contenthash]
占位符,最终构建结果:
提示: 也可以通过占位符传入 Hash
位数,如 [contenthash:7]
,即可限定生成的 Hash
长度。
可以看到每个产物文件名都会带上一段由产物内容计算出的唯一 Hash
值,文件内容不变,Hash
也不会变化,这就很适合用作 HTTP
持久缓存资源。
此时,产物文件不会被重复下载,一直到文件内容发生变化,引起 Hash
变化生成不同 URL
路径之后,才需要请求新的资源文件,能有效提升网络性能,因此,生产环境下应尽量使用 [contenthash]
生成有版本意义的文件名。
Hash
规则很好用,不过有一个边际 Case
需要注意:异步模块变化会引起主 Chunk Hash
同步发生变化,例如对于下面这种模块关系:
构建后将生成入口 index.js
与异步模块 async-a.js
两个 Chunk
对应的产物:
此时,若异步模块 async-a
或其子模块 sync-c
发生变化,理论上应该只会影响 src_async-a
的 Hash
值,但实际效果却是:
父级 Chunk(index)
也受到了影响,生成新的 Hash
值,这是因为在 index
中需要记录异步 Chunk
的真实路径:
异步 Chunk
的路径变化自然也就导致了父级 Chunk
内容变化,此时可以用 optimization.runtimeChunk
将这部分代码抽取为单独的 Runtime Chunk
,例如:
module.exports = {
entry: { index: "./src/index.js" },
mode: "development",
devtool: false,
output: {
filename: "[name]-[contenthash].js",
path: path.resolve(__dirname, "dist")
},
// 将运行时代码抽取到 `runtime` 文件中
optimization: { runtimeChunk: { name: "runtime" } },
};
之后,async-a.js
模块的变更只会影响 Runtime Chunk
内容,不再影响主 Chunk
。
综上,建议至少为生成环境启动 [contenthash]
功能,并搭配 optimization.runtimeChunk
将运行时代码抽离为单独产物文件。
十五、使用外置依赖
设想一个场景,假如我们手头上有 10
个用 React
构建的 SPA
应用,这 10
个应用都需要各自安装、打包、部署、分发同一套相似的 React
基础依赖,最终用户在访问这些应用时也需要重复加载相同基础包代码,那有没有办法节省这部分流量呢?有 —— 使用 Webpack
的 externals
特性。
externals
的主要作用是将部分模块排除在 Webpack
打包系统之外,例如:
module.exports = {
// ...
externals: {
lodash: "_",
},
};
使用上述配置后,Webpack
会预设运行环境中已经内置 Lodash
库 —— 无论是通过 CDN
还是其它方式注入,所以不需要再将这些模块打包到产物中。
提示: externals
不仅适用于优化产物性能,在特定环境下还能用于跳过若干运行时模块,例如 Node
中的 fs/net
等,避免将这部分源码错误打包进 Bundle
注意,使用 externals
时必须确保这些外置依赖代码已经被正确注入到上下文环境中,这在 Web
应用中通常可以通过 CDN
方式实现,例如:
module.exports = {
// ...
externals: {
react: "React",
lodash: "_",
},
plugins: [
new HtmlWebpackPlugin({
templateContent: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Webpack App</title>
<script defer crossorigin src="//unpkg.com/react@18/umd/react.development.js"></script>
<script defer crossorigin src="//unpkg.com/lodash@4.17.21/lodash.min.js"></script>
</head>
<body>
<div id="app" />
</body>
</html>
`,
}),
],
};
示例中,externals
声明了 react
与 lodash
两个外置依赖,并在后续的 html-webpack-plugin
模板中注入这两个模块的 CDN
引用,以此构成完整 Web
应用。
虽然结果上看浏览器还是得消耗这部分流量,但结合 CDN
系统特性,一是能够就近获取资源,缩短网络通讯链路;二是能够将资源分发任务前置到节点服务器,减轻原服务器 QPS
负担;三是用户访问不同站点能共享同一份 CDN
资源副本。所以网络性能效果往往会比重复打包好很多。
十六、使用 Tree-Shaking 删除多余模块导出
Tree-Shaking
较早前由 Rich Harris
在 Rollup
中率先实现,Webpack
自 2.0
版本开始接入,是一种基于 ES Module
规范的 Dead Code Elimination
技术,它会在运行过程中静态分析模块之间的导入导出,判断哪些模块导出值没有被其它模块使用 —— 相当于模块层面的 Dead Code
,并将其删除。
Tree shaking
是基于 ES6
模板语法(import
与exports
),主要是借助ES6
模块的静态编译思想,在编译时就能确定模块的依赖关系,以及输入和输出的变量, 进而判断哪些模块已经加载, 哪些模块和变量未被使用或者引用,进而删除对应代码。
Tree Shaking
利用 ES6
模块的特点,只能作为模块顶层语句出现。import
的模块名只能是字符串常量。Tree Shaking
在去除冗余代码的过程中,程序会从入口文件出发,扫描所有的模块依赖,以及模块的子依赖,然后将它们链接起来形成一个抽象语法树(AST
)。随后,运行所有代码,查看哪些代码是用到过的,做好标记。最后,再将抽象语法树中没有用到的代码摇落。经历这样一个过程后,就去除了没有用到的代码。
前提是模块必须采用 ES6 Module
语法,因为 Tree Shaking
依赖 ES6
的静态语法:import
和 export
。不同于 ES6 Module
,CommonJS
支持动态加载模块,在加载前是无法确定模块是否有被调用,所以并不支持 Tree Shaking
。
在 Webpack
中,启动 Tree Shaking
功能必须同时满足两个条件:
-
配置
optimization.usedExports
为true
,标记模块导入导出列表; -
启动代码优化功能,可以通过如下方式实现:
-
配置
mode = production
-
配置
optimization.minimize = true
-
提供
optimization.minimizer
数组
-
例如:
// webpack.config.js
module.exports = {
mode: "production",
optimization: {
usedExports: true,
},
};
之后,Webpack
会对所有使用 ESM
方案的模块启动 Tree-Shaking
,例如对于下面的代码:
// index.js
import {bar} from './bar';
console.log(bar);
// bar.js
export const bar = 'bar';
export const foo = 'foo';
bar.js
模块导出了 bar
、foo
,但只有 bar
值被 index
模块使用,经过 Tree Shaking
处理后,foo
变量会被视作无用代码删除,最终有效的代码结构:
// index.js
import {bar} from './bar';
console.log(bar);
// bar.js
export const bar = 'bar';
十七、使用 Scope Hoisting 合并模块
默认情况下 Webpack
会将模块打包成一个个单独的函数,例如:
// common.js
export default "common";
// index.js
import common from './common';
console.log(common);
经过 Webpack
打包后会生成:
"./src/common.js":
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
const __WEBPACK_DEFAULT_EXPORT__ = ("common");
__webpack_require__.d(__webpack_exports__, {
/* harmony export */
"default": () => (__WEBPACK_DEFAULT_EXPORT__)
/* harmony export */
});
}),
"./src/index.js":
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
var _common__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( /*! ./common */ "./src/common.js");
console.log(_common__WEBPACK_IMPORTED_MODULE_0__)
})
这种处理方式需要将每一个模块都包裹进一段相似的函数模板代码中,好看是好看,但浪费网络流量啊。为此,Webpack
提供了 Scope Hoisting
功能,用于 将符合条件的多个模块合并到同一个函数空间 中,从而减少产物体积,优化性能。例如上述示例经过 Scope Hoisting
优化后,生成代码:
((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
;// CONCATENATED MODULE: ./src/common.js
/* harmony default export */ const common = ("common");
;// CONCATENATED MODULE: ./src/index.js
console.log(common);
})
Webpack
提供了三种开启 Scope Hoisting
的方法:
-
使用
mode = 'production'
开启生产模式 -
使用
optimization.concatenateModules
配置项 -
直接使用
ModuleConcatenationPlugin
插件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()]
};
三种方法最终都会调用 ModuleConcatenationPlugin
完成模块分析与合并操作。与 Tree-Shaking
类似,Scope Hoisting
底层基于 ES Module
方案的静态特性,推断模块之间的依赖关系,并进一步判断模块与模块能否合并,因此在以下场景下会失效:
-
非
ESM
模块: 遇到AMD
、CMD
一类模块时,由于导入导出内容的动态性,Webpack
无法确保模块合并后不会产生意料之外的副作用,因此会关闭Scope Hoisting
功能。这一问题在导入NPM
包尤其常见,许多框架都会自行打包后再上传到NPM
,并且默认导出的是兼容性更佳的CommonJS
包,因而无法使用Scope Hoisting
功能 -
模块被多个
Chunk
引用: 如果一个模块被多个Chunk
同时引用,为避免重复打包,Scope Hoisting
同样会失效
十八、监控产物体积
综合最近几章讨论的 Code Splitting
、压缩、缓存优化、Tree-Shaking
等技术,不难看出所谓的应用性能优化几乎都与网络有关,这是因为现代计算机网络环境非常复杂、不稳定,虽然有堪比本地磁盘吞吐速度的 5G
网络,但也还存在大量低速 2G
、3G
网络用户,整体而言通过网络实现异地数据交换依然是一种相对低效的 IO
手段,有可能成为 Web
应用执行链条中最大的性能瓶颈。
因此,站在生产者角度我们有必要尽可能优化代码在网络上分发的效率,用尽可能少的网络流量交付应用功能。所幸 Webpack
专门为此提供了一套 性能监控方案,当构建生成的产物体积超过阈值时抛出异常警告,以此帮助我们时刻关注资源体积,避免因项目迭代增长带来过大的网络传输,用法:
module.exports = {
// ...
performance: {
// 设置所有产物体积阈值
maxAssetSize: 172 * 1024,
// 设置 entry 产物体积阈值
maxEntrypointSize: 244 * 1024,
// 报错方式,支持 `error` | `warning` | false
hints: "error",
// 过滤需要监控的文件类型
assetFilter: function (assetFilename) {
return assetFilename.endsWith(".js");
},
},
};
若此时产物体积超过 172KB
,则报错:
提示: 这里的报错不会阻断构建功能, 依然能正常打包出应用产物。
那么我们应该设置多大的阈值呢?这取决于项目具体场景,不过,一个比较好的 经验法则 是确保 关键路径 资源体积始终小于 170KB
,超过这个体积就应该使用上面介绍的若干方法做好裁剪优化。