Skip to content
目录

受限于 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 序列的逻辑。

基本用法:

  1. 安装依赖

    bash
    pnpm i -D happypack
  2. 将原有 loader 配置替换为 happypack/loader,如:

    js
    /**
     * @type {import('webpack').Configuration}
     */
    module.exports = {
      mode: 'development',
      devtool: false,
      entry: './src/index.js',
      module: {
        rules: [
          {
            test: /\.js$/,
            use: 'happypack/loader', 
            // 原始配置如:
            use: [  
              { 
                loader: 'babel-loader', 
                options: { 
                  presets: ['@babel/preset-env'] 
                } 
              }, 
              'eslint-loader', 
            ] 
          }
        ]
      }
    }
  3. 创建 happypack 插件实例,并将原有 loader 配置迁移到插件中,完整配置:

    js
    const HappyPack = require('happypack') 
    
    /**
     * @type {import('webpack').Configuration}
     */
    module.exports = {
      mode: 'development',
      devtool: false,
      entry: './src/index.js',
      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',  
              options: {  
                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 实例的关联即可,例如:

js
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',
      loader: ['babel-loader', 'eslint-loader']
    }),
    new HappyPack({
      id: 'styles',
      loaders: ['style-loader', 'css-loader', 'less-loader']
    })
  ]
}

这里的重点是:

  • jsless 资源都使用 happypack/loader 作为唯一加载器,并分别赋予 id = 'js' | 'styles' 参数;
  • 创建了两个 HappyPack 插件实例并分别配置 id 属性,以及用于处理 js 与 css 的 loaders 数组;
  • 启动后,happypack/loaderHappyPack 插件实例将通过 id 值产生关联,以此实现对不同资源执行不同 Loader 序列。

上面这种多实例模式虽然能应对多种类型资源的加载需求,但默认情况下,HappyPack 插件实例 自行管理 自身所消费的进程,需要导致频繁创建、销毁进程实例 —— 这是非常昂贵的操作,反而会带来新的性能损耗。

为此,HappyPack 提供了一套简单易用的共享进程池接口,只需要创建 HappyPack.ThreadPool 对象,并通过 size 参数限定进程总量,之后将该例配置到各个 HappyPack 插件的 threadPool 属性上即可,例如:

js
const HappyPack = require('happypack')
const os = require('node:os')

// 设置进程池大小
const happyThreadPool = HappyPack.ThreadPool({
  size: os.cpus().length - 1
})

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',
      threadPool: happyThreadPool, 
      loader: ['babel-loader', 'eslint-loader']
    }),
    new HappyPack({
      id: 'styles',
      threadPool: happyThreadPool, 
      loaders: ['style-loader', 'css-loader', 'less-loader']
    })
  ]
}

使用 HappyPack.ThreadPool 接口后,HappyPack 会预先创建好一组工作进程,所有插件实例的资源转译任务会通过内置的 HappyThread 对象转发到空闲进程做处理,避免频繁创建、销毁进程。

最后,我们再来看看 HappyPack 的执行流程:

HappyPack流程

核心步骤:

  1. happypack/loader 接受到转译请求后,从 Webpack 配置中读取出相应 HappyPack 插件实例;
  2. 调用插件实例的 compile 方法,创建 HappyThread 实例(或从 HappyThreadPool 取出空闲实例);
  3. HappyThread 内部调用 child_process.fork 创建子进程,并执行HappyWorkerChannel 文件;
  4. HappyWorkerChannel 创建 HappyWorker ,开始执行 Loader 转译逻辑;

中间流程辗转了几层,最终由 HappyWorker 类重新实现了一套与 Webpack Loader 相似的转译逻辑,代码复杂度较高,大家稍作了解即可。

HappyPack 虽然确实能有效提升 Webpack 的打包构建速度,但它有一些明显的缺点:

  • 作者已经明确表示不会继续维护,扩展性与稳定性缺乏保障,随着 Webpack 本身的发展迭代,可以预见总有一天 HappyPack 无法完全兼容 Webpack😅;
  • HappyPack 底层以自己的方式重新实现了加载器逻辑,源码与使用方法都不如 Thread-loader 清爽简单,而且会导致一些意想不到的兼容性问题,如 awesome-typescript-loader
  • HappyPack 主要作用于文件加载阶段,并不会影响后续的产物生成、合并、优化等功能,性能收益有限。

WARNING

实测 webapck v5.79.0 & happypack v5.0.1 版本构建时会报错,build error#275

Thread-loader

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

  • Thread-loader 由 Webpack 官方提供,目前还处于持续迭代维护状态,理论上更可靠;
  • Thread-loader 只提供了一个 Loader 组件,用法简单很多;
  • HappyPack 启动后会创建一套 Mock 上下文环境 —— 包含 emitFile 等接口,并传递给 Loader,因此对大多数 Loader 来说,运行在 HappyPack 与运行在 Webpack 原生环境相比没有太大差异;但 Thread-loader 并不具备这一特性,所以要求 Loader 内不能调用特定上下文接口,兼容性较差😅。

说一千道一万,先来看看基本用法:

  1. 安装依赖:

    bash
    pnpm i -D thread-loader
  2. 将 Thread-loader 放在 use 数组首位,确保最先运行,如:

    js
    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 属性传递,如:

js
module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: "thread-loader",
            options: {
              workers: 2,
              workerParallelJobs: 50,
              // ...
            },
          },
          "babel-loader",
          "eslint-loader",
        ],
      },
    ],
  },
};

不过,Thread-loader 也同样面临着频繁的子进程创建、销毁所带来的性能问题,为此,Thread-loader 提供了 warmup 接口用于前置创建若干工作子进程,降低构建时延,用法:

js
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 中不能获取 compilationcompiler 等实例对象,也无法获取 Webpack 配置。

这会导致一些 Loader 无法与 Thread-loader 共同使用,大家需要仔细加以甄别、测试。

Parallel-Webpack

Thread-loader、HappyPack 这类组件所提供的并行能力都仅作用于文件加载过程,对后续 AST 解析、依赖收集、打包、优化代码等过程均没有影响,理论收益还是比较有限的。对此,社区还提供了另一种并行度更高,以多个独立进程运行 Webpack 实例的方案 —— Parallel-Webpack,基本用法:

  1. 安装依赖:

    bash
    pnpm i -D parallel-webpack
  2. webpack.config.js 配置文件中导出多个 Webpack 配置对象,如:

    js
    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 配置对象,如:

js
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 种不同的 minifieddebugtarget 组合,最终生成如下产物:

bash
[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)设置具体的并发进程数量,如:

js
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 并行构建?社区是否已经有相关组件?与多进程相比,可能存在怎么样的优缺点?

chatgpt回答

1️⃣ 有没有可能使用 Node Worker实现多线程形式的 Webpack 并行构建?

是的,可以使用 Node.js 的 Worker Threads 模块来实现多线程形式的 Webpack 并行构建。Worker Threads 可以创建多个 Node.js 子线程,并在这些子线程中执行耗时操作,从而提高程序的性能。

具体地,可以将 Webpack 构建过程拆分成多个子任务,然后将每个子任务放到不同的子线程中进行处理。每个子线程可以独立地进行模块的解析、加载和转换等操作,最后再将结果合并起来。

下面是一个简单的示例代码,演示了如何使用 Worker Threads 实现多线程形式的 Webpack 并行构建:

js
const { Worker, isMainThread, parentPort } = require('worker_threads');
const webpack = require('webpack');

// 定义 Webpack 配置
const config = {
  entry: 'src/index.js',
  output: {
    filename: 'bundle.js',
    path: __dirname + '/dist'
  }
};

if (isMainThread) {
  // 主线程负责启动子线程
  const worker = new Worker(__filename);
  worker.on('message', result => {
    console.log(result);
  });
} else {
  // 子线程负责执行 Webpack 构建
  webpack(config, (err, stats) => {
    if (err || stats.hasErrors()) {
      parentPort.postMessage({ error: err || stats.toJson().errors });
    } else {
      parentPort.postMessage({ result: 'Build completed successfully!' });
    }
  });
}

需要注意的是,使用 Worker Threads 构建 Webpack 是一种比较底层的优化方案,需要考虑到线程安全、进程间通信等问题。同时,也需要根据具体项目的需求和硬件环境,权衡使用多线程带来的性能提升和代码复杂度增加的风险。

2️⃣ 社区是否已经有相关组件?

是的,社区已经有一些相关的组件可以实现 Webpack 的多线程构建。以下是其中比较常见的几种组件:

  1. thread-loader

thread-loader 是一个 Webpack Loader,它可以将其他 Loader 执行过程放到 Worker 线程中进行,从而提高构建速度。使用 thread-loader 只需要在原有 Loader 的基础上加上 thread-loader 即可。

  1. parallel-webpack

parallel-webpack 可以让 Webpack 支持多进程并行构建,从而提高构建速度。parallel-webpack 使用 Node.js 的 Child Process API 来创建子进程,并利用 IPC 实现进程间通信。

  1. happypack

happypack 是一个 Webpack Plugin,它可以将多个 Loader 的执行过程放到 Worker 线程中进行,从而提高构建速度。happypack 的原理与 thread-loader 类似,但是 happypack 可以支持更复杂的 Loader 链。

  1. webpack-parallel-uglify-plugin

webpack-parallel-uglify-plugin 是一个压缩 JavaScript 代码的 Webpack Plugin,它可以使用多进程并行方式压缩文件,从而提高压缩速度。

以上组件都可以有效地提高 Webpack 的构建速度,具体选择哪个组件取决于项目的需求和开发者的个人喜好。

3️⃣ 与多进程相比,可能存在怎么样的优缺点?

使用多线程或多进程来实现 Webpack 的并行构建,都有其优点和缺点,下面是一些常见的优缺点:

优点:

  1. 提高构建速度:多线程或多进程可以同时处理多个任务,从而提高构建速度。
  2. 充分利用 CPU 资源:对于计算密集型任务,多线程或多进程可以充分利用 CPU 资源,从而提高性能。
  3. 扩展性好:多线程或多进程的方案具有良好的扩展性,可以根据项目需求增加或减少进程数目,从而适应不同的硬件环境和工作负载。

缺点:

  1. 增加复杂度:使用多线程或多进程会增加代码的复杂度,需要考虑到线程安全、进程间通信等问题。
  2. 需要额外配置:由于多线程或多进程需要与 Webpack 良好地集成,因此需要进行额外的配置,可能会增加开发难度。
  3. 可能导致内存消耗过大:当进程数量过多时,可能会导致内存消耗过大,从而影响系统稳定性和性能。

总的来说,使用多线程或多进程来实现 Webpack 的并行构建是一种有效的优化方案,可以显著提高构建速度和性能。但是需要权衡其优缺点,根据具体项目的需求和硬件环境选择最适合的优化方案。

2023年04月18日11:47:16