概述
受限于 Node.js 的单线程架构,原生 Webpack 对所有资源文件做的所有解析、转译、合并操作本质上都是在同一个线程内串行执行,CPU 利用率极低,因此,理所当然地,社区出现了一些以多进程方式运行 Webpack,或 Webpack 构建过程某部分工作的方案(从而提升单位时间利用率),例如:
- HappyPack:多进程方式运行资源加载(Loader)逻辑;
- Thread-loader:Webpack 官方出品,同样以多进程方式运行资源加载逻辑;
- Parallel-Webpack:多进程方式运行多个 Webpack 构建实例;
- TerserWebpackPlugin:支持多进程方式执行代码压缩、uglify 功能。
这些方案的核心设计都很类似:针对某种计算任务创建子进程,之后将运行所需参数通过 IPC 传递到子进程并启动计算操作,计算完毕后子进程再将结果通过 IPC 传递回主进程,寄宿在主进程的组件实例,再将结果提交给 Webpack。
使用 HappyPack
HappyPack 能够将耗时的文件加载(Loader)操作拆散到多个子进程中并发执行,子进程执行完毕后再将结果合并回传到 Webpack 进程,从而提升构建性能。不过,HappyPack 的用法稍微有点难以理解,需要同时:
- 使用
happypack/loader
代替原本的 Loader 序列; - 使用
HappyPack
插件注入代理执行 Loader 序列的逻辑。
基本用法:
-
安装依赖:
yarn add -D happypack
-
将原有
loader
配置替换为happypack/loader
,如:module.exports = { // ... module: { rules: [ { test: /\.js$/, use: "happypack/loader", // 原始配置如: // use: [ // { // loader: 'babel-loader', // options: { // presets: ['@babel/preset-env'] // } // }, // 'eslint-loader' // ] }, ], }, };
-
创建
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", ], }), ], };
配置完毕后,再次启动 npx webpack
命令,即可使用 HappyPack 的多进程能力提升构建性能。以 Three.js 为例,该项目包含 362 份 JS 文件,合计约 3w 行代码:
开启 HappyPack 前,构建耗时大约为 11000ms 到 18000ms 之间,开启后耗时降低到 5800ms 到 8000ms 之间,提升约47%。
上述示例仅演示了使用 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']
})
]
};
这里的重点是:
js
、less
资源都使用happypack/loader
作为唯一加载器,并分别赋予id = 'js' | 'styles'
参数;- 创建了两个
HappyPack
插件实例并分别配置id
属性,以及用于处理 js 与 css 的loaders
数组; - 启动后,
happypack/loader
与HappyPack
插件实例将通过id
值产生关联,以此实现对不同资源执行不同 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 的执行流程:
核心步骤:
happlypack/loader
接受到转译请求后,从 Webpack 配置中读取出相应 HappyPack 插件实例;- 调用插件实例的
compile
方法,创建HappyThread
实例(或从HappyThreadPool
取出空闲实例); HappyThread
内部调用child_process.fork
创建子进程,并执行HappyWorkerChannel
文件;HappyWorkerChannel
创建HappyWorker
,开始执行 Loader 转译逻辑;
中间流程辗转了几层,最终由 HappyWorker
类重新实现了一套与 Webpack Loader 相似的转译逻辑,代码复杂度较高,大家稍作了解即可。
HappyPack 虽然确实能有效提升 Webpack 的打包构建速度,但它有一些明显的缺点:
- 作者已经明确表示不会继续维护,扩展性与稳定性缺乏保障,随着 Webpack 本身的发展迭代,可以预见总有一天 HappyPack 无法完全兼容 Webpack;
- HappyPack 底层以自己的方式重新实现了加载器逻辑,源码与使用方法都不如 Thread-loader 清爽简单,而且会导致一些意想不到的兼容性问题,如
awesome-typescript-loader
; - HappyPack 主要作用于文件加载阶段,并不会影响后续的产物生成、合并、优化等功能,性能收益有限。
使用 Thread-loader
Thread-loader 与 HappyPack 功能类似,都是以多进程方式加载文件的 Webpack 组件,两者主要区别:
- Thread-loader 由 Webpack 官方提供,目前还处于持续迭代维护状态,理论上更可靠;
- Thread-loader 只提供了一个 Loader 组件,用法简单很多;
- HappyPack 启动后会创建一套 Mock 上下文环境 —— 包含
emitFile
等接口,并传递给 Loader,因此对大多数 Loader 来说,运行在 HappyPack 与运行在 Webpack 原生环境相比没有太大差异;但 Thread-loader 并不具备这一特性,所以要求 Loader 内不能调用特定上下文接口,兼容性较差。
说一千道一万,先来看看基本用法:
-
安装依赖:
yarn add -D thread-loader
-
将 Thread-loader 放在
use
数组首位,确保最先运行,如:module.exports = { module: { rules: [ { test: /\.js$/, use: ["thread-loader", "babel-loader", "eslint-loader"], }, ], }, };
启动后,Thread-loader 会在加载文件时创建新的进程,在子进程中使用 loader-runner
库运行 thread-loader
之后的 Loader 组件,执行完毕后再将结果回传到 Webpack 主进程,从而实现性能更佳的文件加载转译效果。
以 Three.js 为例,使用 Thread-loader 前,构建耗时大约为 11000ms 到 18000ms 之间,开启后耗时降低到 8000ms 左右,提升约37%。
此外,Thread-loader 还提供了一系列用于控制并发逻辑的配置项,包括:
workers
:子进程总数,默认值为require('os').cpus() - 1
;workerParallelJobs
:单个进程中并发执行的任务数;poolTimeout
:子进程如果一直保持空闲状态,超过这个时间后会被关闭;poolRespawn
:是否允许在子进程关闭后重新创建新的子进程,一般设置为false
即可;workerNodeArgs
:用于设置启动子进程时,额外附加的参数。
使用方法跟其它 Loader 一样,都是通过 use.options
属性传递,如:
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.ThreadPool
相似,此处不再赘述。
与 HappyPack 相比,Thread-loader 有两个突出的优点,一是产自 Webpack 官方团队,后续有长期维护计划,稳定性有保障;二是用法更简单。但它不可避免的也存在一些问题:
- 在 Thread-loader 中运行的 Loader 不能调用
emitAsset
等接口,这会导致style-loader
这一类加载器无法正常工作,解决方案是将这类组件放置在thread-loader
之前,如['style-loader', 'thread-loader', 'css-loader']
; - Loader 中不能获取
compilation
、compiler
等实例对象,也无法获取 Webpack 配置。
这会导致一些 Loader 无法与 Thread-loader 共同使用,大家需要仔细加以甄别、测试。
使用 Parallel-Webpack
Thread-loader、HappyPack 这类组件所提供的并行能力都仅作用于文件加载过程,对后续 AST 解析、依赖收集、打包、优化代码等过程均没有影响,理论收益还是比较有限的。对此,社区还提供了另一种并行度更高,以多个独立进程运行 Webpack 实例的方案 —— Parallel-Webpack,基本用法:
-
安装依赖:
yarn add -D parallel-webpack
-
在
webpack.config.js
配置文件中导出多个 Webpack 配置对象,如:module.exports = [{ entry: 'pageA.js', output: { path: './dist', filename: 'pageA.js' } }, { entry: 'pageB.js', output: { path: './dist', filename: 'pageB.js' } }];
-
执行
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)
-
上述示例使用
createVariants
函数,根据variants
变量搭配出 16 种不同的minified
、debug
、target
组合,最终生成如下产物:[WEBPACK] Building 16 targets in parallel [WEBPACK] Started building MyLib.umd.js [WEBPACK] Started building MyLib.umd.min.js [WEBPACK] Started building MyLib.umd.debug.js [WEBPACK] Started building MyLib.umd.min.debug.js [WEBPACK] Started building MyLib.amd.js [WEBPACK] Started building MyLib.amd.min.js [WEBPACK] Started building MyLib.amd.debug.js [WEBPACK] Started building MyLib.amd.min.debug.js [WEBPACK] Started building MyLib.commonjs2.js [WEBPACK] Started building MyLib.commonjs2.min.js [WEBPACK] Started building MyLib.commonjs2.debug.js [WEBPACK] Started building MyLib.commonjs2.min.debug.js [WEBPACK] Started building MyLib.var.js [WEBPACK] Started building MyLib.var.min.js [WEBPACK] Started building MyLib.var.debug.js [WEBPACK] Started building MyLib.var.min.debug.js
-
虽然,parallel-webpack 相对于 Thread-loader、HappyPack 有更高的并行度,但进程实例之间并没有做任何形式的通讯,这可能导致相同的工作在不同进程 —— 或者说不同 CPU 核上被重复执行。
-
例如需要对同一份代码同时打包出压缩和非压缩版本时,在 parallel-webpack 方案下,前置的资源加载、依赖解析、AST 分析等操作会被重复执行,仅仅最终阶段生成代码时有所差异。
-
这种技术实现,对单 entry 的项目没有任何收益,只会徒增进程创建成本;但特别适合 MPA 等多 entry 场景,或者需要同时编译出 esm、umd、amd 等多种产物形态的类库场景。
并行压缩
Webpack4 默认使用 Uglify-js 实现代码压缩,Webpack5 之后则升级为 Terser —— 一种性能与兼容性更好的 JavaScript 代码压缩混淆工具,两种组件都原生实现了多进程并行压缩能力。
以 Terser 为例,TerserWebpackPlugin 插件默认已开启并行压缩,开发者也可以通过 parallel
参数(默认值为 require('os').cpus() - 1
)设置具体的并发进程数量,如:
const TerserPlugin = require("terser-webpack-plugin");
module.exports = {
optimization: {
minimize: true,
minimizer: [new TerserPlugin({
parallel: 2 // number | boolean
})],
},
};
上述配置即可设定最大并行进程数为 2。此外,Webpack4 所使用的 uglifyjs-webpack-plugin
也提供了类似的功能,用法与 Terser 相同,此处不再赘述。
总结
受限于 JavaScript 的单线程架构,Webpack 构建时并不能充分使用现代计算机的多核 CPU 能力,为此社区提供了若干基于多进程实现的并行构建组件,包括文中介绍的 HappyPack、Thread-loader、Parallel-Webpack、Terser。
-
对于 Webpack4 之前的项目,可以使用 HappyPack 实现并行文件加载;
-
Webpack4 之后则建议使用 Thread-loader;
-
多实例并行构建场景建议使用 Parallel-Webpack 实现并行;
-
生产环境下还可配合
terser-webpack-plugin
的并行压缩功能,提升整体效率。 -
理论上,并行确实能够提升系统运行效率,但 Node 单线程架构下,所谓的并行计算都只能依托与派生子进程执行,而创建进程这个动作本身就有不小的消耗 —— 大约 600ms,对于小型项目,构建成本可能可能很低,引入多进程技术反而导致整体成本增加,因此建议大家按实际需求斟酌使用上述多进程方案。
-
可以思考,有没有可能使用 Node Worker 实现多线程形式的 Webpack 并行构建?社区是否已经有相关组件?与多进程相比,可能存在怎么样的优缺点?