跳到主要内容

性能优化

2024年03月21日
柏拉文
越努力,越幸运

一、认识


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 及之前版本原生还没有相关实现,只能借助一些第三方组件实现类似效果,包括:

  1. 使用 cache-loader

  2. 使用 hard-source-webpack-plugin

  3. 使用 Loader(如 babel-loadereslint-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 结果ChunksAssets 等,效果几乎与 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 = true 即可开启,如

// 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 参数做好 LoaderPlugin 实例的关联即可,例如:

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 核心点如下:

  1. 使用 happypack/loader 代替原本的 Loader 序列, 使用 HappyPack 插件注入代理执行 Loader 序列的逻辑

  2. 可以创建多个 HappyPack 插件实例,来加载多种资源类型, 只需要用 id 参数做好 LoaderPlugin 实例的关联即可

  3. 默认情况下,HappyPack 插件实例 自行管理 自身所消费的进程,需要导致频繁创建、销毁进程实例,这是非常昂贵的操作,反而会带来新的性能损耗。为此,HappyPack 提供了一套简单易用的共享进程池接口,只需要创建 HappyPack.ThreadPool 对象,并通过 size 参数限定进程总量,之后将该例配置到各个 HappyPack 插件的 threadPool 属性上即可。使用 HappyPack.ThreadPool 接口后,HappyPack 会预先创建好一组工作进程,所有插件实例的资源转译任务会通过内置的 HappyThread 对象转发到空闲进程做处理,避免频繁创建、销毁进程。

HappyPack 特点如下:

  1. 作者已经明确表示不会继续维护,扩展性与稳定性缺乏保障,随着 Webpack 本身的发展迭代,可以预见总有一天 HappyPack 无法完全兼容 Webpack

  2. HappyPack 底层以自己的方式重新实现了加载器逻辑,源码与使用方法都不如 Thread-loader 清爽简单,而且会导致一些意想不到的兼容性问题,如 awesome-typescript-loader

  3. HappyPack 主要作用于文件加载阶段,并不会影响后续的产物生成、合并、优化等功能,性能收益有限。

HappyPack 底层运行机制如下:

  1. happlypack/loader 接受到转译请求后,从 Webpack 配置中读取出相应 HappyPack 插件实例;

  2. 调用插件实例的 compile 方法,创建 HappyThread 实例(或从 HappyThreadPool 取出空闲实例)

  3. HappyThread 内部调用 child_process.fork 创建子进程,并执行 HappyWorkerChannel 文件

  4. HappyWorkerChannel 创建 HappyWorker ,开始执行 Loader 转译逻辑;

总结: 对于 Webpack4 之前的项目,可以使用 HappyPack 实现并行文件加载

3.2 Thread-loader

Thread-loaderHappyPack 功能类似,都是以多进程方式加载文件的 Webpack 组件,两者主要区别:

  1. Thread-loaderWebpack 官方提供,目前还处于持续迭代维护状态,理论上更可靠;

  2. Thread-loader 只提供了一个 Loader 组件,用法简单很多

  3. 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 官方团队,后续有长期维护计划,稳定性有保障;二是用法更简单。但它不可避免的也存在一些问题:

  1. Thread-loader 中运行的 Loader 不能调用 emitAsset 等接口,这会导致 style-loader 这一类加载器无法正常工作,解决方案是将这类组件放置在 thread-loader 之前,如 ['style-loader', 'thread-loader', 'css-loader']

  2. Loader 中不能获取 compilationcompiler 等实例对象,也无法获取 Webpack 配置。

总结: Webpack4 之后则建议使用 Thread-loader

3.3 Parallel-Webpack

Thread-loaderHappyPack 这类组件所提供的并行能力都仅作用于文件加载过程,对后续 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 上套了个壳:

  1. 根据传入的配置项数量,调用 worker-farm 创建复数个工作进程;

  2. 工作进程内调用 Webpack 执行构建;

  3. 工作进程执行完毕后,调用 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-loaderHappyPack 有更高的并行度,但进程实例之间并没有做任何形式的通讯,这可能导致相同的工作在不同进程 —— 或者说不同 CPU 核上被重复执行。

例如需要对同一份代码同时打包出压缩和非压缩版本时,在 parallel-webpack 方案下,前置的资源加载、依赖解析、AST 分析等操作会被重复执行,仅仅最终阶段生成代码时有所差异。

这种技术实现,对单 entry 的项目没有任何收益,只会徒增进程创建成本;但特别适合 MPA 等多 entry 场景,或者需要同时编译出 esmumdamd 等多种产物形态的类库场景。

总结: 多实例并行构建场景建议使用 Parallel-Webpack 实现并行

四、并行代码压缩


代码压缩 是指在不改变代码功能的前提下,从声明式(HTMLCSS)或命令式(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 使用。

这些配置项总结下来有两个值得关注的逻辑:

  1. 可以通过 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,不单独抽取备注内容。

  1. terser-webpack-plugin 插件并不只是 Terser 的简单包装,它更像是一个代码压缩功能骨架,底层还支持使用 SWCUglifyJSESBuild 作为压缩器,使用时只需要通过 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

    不同压缩器功能、性能差异较大,据我了解,ESBuildSWC 这两个基于 GoRust 编写的压缩器性能更佳,且效果已经基本趋于稳定,虽然功能还比不上 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-pluginCSS 代码抽取为单独的 CSS 产物文件,这样才能命中 css-minimizer-webpack-plugin 默认的 test 逻辑;二是使用 css-minimizer-webpack-plugin 压缩 CSS 代码。效果:

Preview

terser-webpack-plugin 类似,css-minimizer-webpack-plugin 也支持 testincludeexcludeminifyminimizerOptions 配置,其中 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-cssESBuild 压缩性能相对较佳, 但两者功能与兼容性稍弱,多数情况下推荐使用 cssnano

Preview

4.3 使用 HtmlMinifierTerser 压缩 HTML

现代 Web 应用大多会选择使用 ReactVueMVVM 框架,这衍生出来的一个副作用是原生 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: 删除 HTMLBoolean 属性值,如:

    <!-- 原始代码: -->
    <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 代码,效果:

Preview

上图中左边是正常构建结果,右图是经过 html-minimizer-plugin 压缩后的构建结果,可以看到如 doctype 标签被删掉若干不重要的声明,文档中的备注也被删除,等等。

terser-webpack-plugin 类似,html-minimizer-webpack-plugin 也支持 includetestminimizerOptions 等等一系列配置,此处不再赘述。

五、约束 Loader 执行范围


Loader 组件用于将各式文件资源转换为可被 Webpack 理解、构建的标准 JavaScript 代码,正是这一特性支撑起 Webpack 强大的资源处理能力。不过,Loader 在执行内容转换的过程中可能需要比较密集的 CPU 运算,如 babel-loadereslint-loadervue-loader 等,需要反复执行代码到 ASTAST 到代码的转换。因此, Loader 对文件的转换操作很耗时,需要让尽可能少的文件被 Loader 处理。

因此开发者可以根据实际场景,使用 module.rules.includemodule.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 库已经提前做好打包处理(文件合并、PolyfillESMCJS 等),不需要二次编译就可以直接放在浏览器上运行,例如:

  • Vue2node_modules/vue/dist/vue.runtime.esm.js 文件;

  • React node_modules/react/umd/react.production.min.js 文件;

  • Lodashnode_modules/lodash/lodash.js 文件

对我们来说,这些资源文件都是独立、内聚的代码片段,没必要重复做代码解析、依赖分析、转译等操作,此时可以使用 module.noParse 配置项跳过这些资源,例如:

// webpack.config.js
module.exports = {
//...
module: {
noParse: /lodash|react/,
},
};

配置后,所有匹配该正则的文件都会跳过前置的构建、分析动作,直接将内容合并进 Chunk,从而提升构建速度。不过,使用 noParse 时需要注意:

  1. 由于跳过了前置的 AST 分析动作,构建过程无法发现文件中可能存在的语法错误,需要到运行(或 Terser 做压缩)时才能发现问题,所以必须确保 noParse 的文件内容正确性;

  2. 由于跳过了依赖分析的过程,所以文件中,建议不要包含 import/export/require/define 等模块导入导出语句 —— 换句话说,noParse 文件不能存在对其它文件的依赖,除非运行环境支持这种模块化方案;

  3. 由于跳过了内容分析过程,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-ShakingSplitChunksMinimizer 等,这些能力能够有效减少最终产物的尺寸,提升生产环境下的运行性能,但这些优化在开发环境中意义不大,反而会增加构建器的负担(都是性能大户)。因此,开发模式下建议关闭这一类优化功能,具体措施:

  • 确保 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 干嘛?”,很简单,我们可以:

  1. 可以借助编辑器的 TypeScript 插件实现代码检查;

  2. 使用 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,减少对构建流程的影响,如:

  1. 使用编辑器插件完成 ESLint 检查、错误提示、自动 Fix,如 VS Codedbaeumer.vscode-eslint 插件;

  2. 使用 husky,仅在代码提交前执行 ESLint 代码检查;

  3. 仅在 production 构建中使用 ESLint,能够有效提高开发阶段的构建效率。

十一、慎用 source-map


source-map 是一种将经过编译、压缩、混淆的代码映射回源码的技术,它能够帮助开发者迅速定位到更有意义、更结构化的源码中,方便调试。不过,source-map 操作本身也有很大构建性能开销,建议读者根据实际场景慎重选择最合适的 source-map 方案。

针对 source-map 功能,Webpack 提供了 devtool 选项,可以配置 evalsource-mapcheap-source-map 等值,不考虑其它因素的情况下,最佳实践:

  • 开发环境使用 eval ,确保最佳编译速度;

  • 生产环境使用 source-map,获取最高质量。

十二、设置 resolve 缩小搜索范围


Webpack 默认提供了一套同时兼容 CMDAMDESM 等模块化方案的资源搜索规则 —— 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 内置能力之一,我们不需要做任何额外配置就可以通过动态导入语句(importrequire.ensure)轻易实现。但请 注意,这一特性有时候反而会带来一些新的性能问题:一是过度使用会使产物变得过度细碎,产物文件过多,运行时 HTTP 通讯次数也会变多,在 HTTP 1.x 环境下这可能反而会降低网络性能,得不偿失二是使用时 Webpack 需要在客户端注入一大段用于支持动态加载特性的 Runtime

Preview

这段代码即使经过压缩也高达 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() 语句动态导入,这使得仅当页面切换到相应路由时才会加载对应组件代码。另外,FooBar 组件的导入语句比较特殊:

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]: 产物对应 ChunkHashChunk 中任意模块变化都会产生新的 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.jsfoo.js 两个入口,且分别在 ouput.filenameMiniCssExtractPlugin.filename 中使用 [contenthash] 占位符,最终构建结果:

Preview

提示: 也可以通过占位符传入 Hash 位数,如 [contenthash:7] ,即可限定生成的 Hash 长度。

可以看到每个产物文件名都会带上一段由产物内容计算出的唯一 Hash 值,文件内容不变,Hash 也不会变化,这就很适合用作 HTTP 持久缓存资源。

此时,产物文件不会被重复下载,一直到文件内容发生变化,引起 Hash 变化生成不同 URL 路径之后,才需要请求新的资源文件,能有效提升网络性能,因此,生产环境下应尽量使用 [contenthash] 生成有版本意义的文件名。

Hash 规则很好用,不过有一个边际 Case 需要注意:异步模块变化会引起主 Chunk Hash 同步发生变化,例如对于下面这种模块关系:

Preview

构建后将生成入口 index.js 与异步模块 async-a.js 两个 Chunk 对应的产物:

Preview

此时,若异步模块 async-a 或其子模块 sync-c 发生变化,理论上应该只会影响 src_async-aHash 值,但实际效果却是:

Preview

父级 Chunk(index) 也受到了影响,生成新的 Hash 值,这是因为在 index 中需要记录异步 Chunk 的真实路径:

Preview

异步 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 基础依赖,最终用户在访问这些应用时也需要重复加载相同基础包代码,那有没有办法节省这部分流量呢?有 —— 使用 Webpackexternals 特性。

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 声明了 reactlodash 两个外置依赖,并在后续的 html-webpack-plugin 模板中注入这两个模块的 CDN 引用,以此构成完整 Web 应用。

虽然结果上看浏览器还是得消耗这部分流量,但结合 CDN 系统特性,一是能够就近获取资源,缩短网络通讯链路二是能够将资源分发任务前置到节点服务器,减轻原服务器 QPS 负担三是用户访问不同站点能共享同一份 CDN 资源副本。所以网络性能效果往往会比重复打包好很多。

十六、使用 Tree-Shaking 删除多余模块导出


Tree-Shaking 较早前由 Rich HarrisRollup 中率先实现,Webpack2.0 版本开始接入,是一种基于 ES Module 规范的 Dead Code Elimination 技术,它会在运行过程中静态分析模块之间的导入导出,判断哪些模块导出值没有被其它模块使用 —— 相当于模块层面的 Dead Code,并将其删除。

Tree shaking 是基于 ES6 模板语法(importexports),主要是借助ES6模块的静态编译思想,在编译时就能确定模块的依赖关系,以及输入和输出的变量, 进而判断哪些模块已经加载, 哪些模块和变量未被使用或者引用,进而删除对应代码。

Tree Shaking 利用 ES6模块的特点,只能作为模块顶层语句出现。import的模块名只能是字符串常量。Tree Shaking 在去除冗余代码的过程中,程序会从入口文件出发,扫描所有的模块依赖,以及模块的子依赖,然后将它们链接起来形成一个抽象语法树(AST)。随后,运行所有代码,查看哪些代码是用到过的,做好标记。最后,再将抽象语法树中没有用到的代码摇落。经历这样一个过程后,就去除了没有用到的代码。

前提是模块必须采用 ES6 Module 语法,因为 Tree Shaking 依赖 ES6 的静态语法:importexport。不同于 ES6 ModuleCommonJS 支持动态加载模块,在加载前是无法确定模块是否有被调用,所以并不支持 Tree Shaking

Webpack 中,启动 Tree Shaking 功能必须同时满足两个条件:

  • 配置 optimization.usedExportstrue,标记模块导入导出列表;

  • 启动代码优化功能,可以通过如下方式实现:

    • 配置 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 方案的静态特性,推断模块之间的依赖关系,并进一步判断模块与模块能否合并,因此在以下场景下会失效:

  1. ESM 模块: 遇到 AMDCMD 一类模块时,由于导入导出内容的动态性,Webpack 无法确保模块合并后不会产生意料之外的副作用,因此会关闭 Scope Hoisting 功能。这一问题在导入 NPM 包尤其常见,许多框架都会自行打包后再上传到 NPM,并且默认导出的是兼容性更佳的 CommonJS 包,因而无法使用 Scope Hoisting 功能

  2. 模块被多个 Chunk 引用: 如果一个模块被多个 Chunk 同时引用,为避免重复打包,Scope Hoisting 同样会失效

十八、监控产物体积


综合最近几章讨论的 Code Splitting、压缩、缓存优化、Tree-Shaking 等技术,不难看出所谓的应用性能优化几乎都与网络有关,这是因为现代计算机网络环境非常复杂、不稳定,虽然有堪比本地磁盘吞吐速度的 5G 网络,但也还存在大量低速 2G3G 网络用户,整体而言通过网络实现异地数据交换依然是一种相对低效的 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,则报错:

Preview

提示: 这里的报错不会阻断构建功能, 依然能正常打包出应用产物。

那么我们应该设置多大的阈值呢?这取决于项目具体场景,不过,一个比较好的 经验法则 是确保 关键路径 资源体积始终小于 170KB,超过这个体积就应该使用上面介绍的若干方法做好裁剪优化