Webpack 性能之多進程打包
在上一篇《Webpack 性能系列一: 使用 Cache 提升構建性能》中,我們討論了 Webpack 語境下如何應用各種緩存措施提升構建性能,接下來我們繼續聊聊 Webpack 中一些行之有效的并行計算方案。緩存的本質是首輪計算后將結果保存下來,下次直接復用計算結果而跳過計算過程;并行的本質則是在同一時間內并發執行多個運算,提升單位時間計算效率,兩者都是計算機科學常見的提升性能優化手段。
受限于 Node.js 的單線程架構,原生 Webpack 對所有資源文件做的所有解析、轉譯、合并操作本質上都是在同一個線程內串行執行,CPU 利用率極低,因此,理所當然地社區出現了一些基于多進程方式運行 Webpack,或 Webpack 構建過程某部分工作的方案,例如:
- HappyPack:多進程方式運行資源加載邏輯
- Thread-loader:Webpack 官方出品,同樣以多進程方式運行資源加載邏輯
- TerserWebpackPlugin:支持多進程方式執行代碼壓縮、uglify 功能
- Parallel-Webpack:多進程方式運行多個 Webpack 構建實例
這些方案的核心設計都很類似:針對某種計算任務創建子進程,之后將運行所需參數通過 IPC 傳遞到子進程并啟動計算操作,計算完畢后子進程再將結果通過 IPC 傳遞回主進程,寄宿在主進程的組件實例再將結果提交給 Webpack。
下面,我將展開介紹每種方案的使用方法、原理及缺點,讀者可按需選用。
使用 HappyPack
HappyPack 是一個使用多進程方式運行文件加載器 —— Loader 序列,從而提升構建性能的 Webpack 組件庫,算得上 Webpack 社區內最先流行的并發方案,不過作者已經明確表示不會繼續維護,推薦讀者優先使用 Webpack 官方推出的相似方案:Thread-loader。
官方鏈接:https://github.com/amireh/happypack
使用方法
基本用法
使用上,首先安裝依賴:
- yarn add happypack
之后,需要將原有 loader 配置替換為 happypack/loader,如:
- module.exports = {
- // ...
- module: {
- rules: [{
- test: /\.js$/,
- // 使用 happypack/loader 替換原來的 Loader 配置
- 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({
- 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%。
配置多實例
上述簡單示例只能以相同的 Loader 序列處理同種文件類型,實際應用中還可以為不同的文件配置多個 相應的加載器數組,例如:
- const HappyPack = require('happypack');
- module.exports = {
- // ...
- module: {
- rules: [{
- test: /\.js?$/,
- use: 'happypack/loader?id=js'
- },
- {
- test: /\.less$/,
- use: 'happypack/loader?id=styles'
- },
- ]
- },
- plugins: [
- new HappyPack({
- id: 'js',
- loaders: ['babel-loader', 'eslint-loader']
- }),
- new HappyPack({
- id: 'styles',
- loaders: ['style-loader', 'css-loader', 'less-loader']
- })
- ]
- };
示例中,js、less 資源都使用 happypack/loader 作為唯一 loader,并分別賦予 id = 'js' | 'styles' 參數;其次,示例中創建了兩個 HappyPack 插件實例并分別配置了用于處理 js 與 css 的 loaders 數組,happypack/loader 與 HappyPack 實例之間通過 id 值關聯起來,以此實現多資源配置。
共享線程池
上述多實例模式更接近實際應用場景,但默認情況下,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 會預先創建好一組共享的 HappyThread 對象,所有插件實例的資源轉譯需求最終都會通過 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 清爽簡單
不支持部分 Loader,如 awesome-typescript-loader
使用 Thread-loader
Thread-loader 也是一個以多進程方式運行 loader 從而提升 Webpack 構建性能的組件,功能上與HappyPack 極為相近,兩者主要區別:
- Thread-loader由 Webpack 官方提供,目前還處于持續迭代維護狀態,理論上更可靠
- Thread-loader 只提供了一個單一的 loader 組件,用法上相對更簡單
- HappyPack 啟動后,會向其運行的 loader 注入 emitFile 等接口,而 Thread-loader 則不具備這一特性,因此對 loader 的要求會更高,兼容性較差
官方鏈接:https://github.com/webpack-contrib/thread-loader
使用方法
首先,需要安裝 Thread-loader 依賴:
- yarn add -D thread-loader
其次,需要將 Thread-loader 配置到 loader 數組首位,確保最先運行,如:
- module.exports = {
- module: {
- rules: [{
- test: /\.js$/,
- use: [
- 'thread-loader',
- 'babel-loader',
- 'eslint-loader'
- ],
- }, ],
- },
- };
配置完畢后,再次啟動 npx webpack 命令即可。依然以 Three.js 為例,開啟 Thread-loader 前,構建耗時大約為 11000ms 到 18000ms 之間,開啟后耗時降低到 8000ms 左右,提升約37%。
原理
Webpack 將執行 Loader 相關邏輯都抽象到 loader-runner 庫,Thread-loader 也同樣復用該庫完成 Loader 的運行邏輯,核心步驟:
- 啟動時,以 pitch 方式攔截 Loader 執行鏈
- 分析 Webpack 配置對象,獲取 thread-loader 后面的 Loader 列表
- 調用 child_process.spawn 創建工作子進程,并將Loader 列表、文件路徑、上下文等參數傳遞到子進程
- 子進程中調用 loader-runner,轉譯文件內容
- 轉譯完畢后,將結果傳回主進程
參考:
https://github.com/webpack/loader-runner
Webpack 原理系列七:如何編寫loader
缺點
Thread-loader 是 Webpack 官方推薦的并行處理組件,實現與使用都非常簡單,但它也存在一些問題:
- Loader 中不能調用 emitAsset 等接口,這會導致 style-loader 這一類 Loader 無法正常工作,解決方案是將這類組件放置在 thread-loader 之前,如 ['style-loader', 'thread-loader', 'css-loader']
- Loader 中不能獲取 compilation、compiler 等實例對象,也無法獲取 Webpack 配置
這會導致一些 Loader 無法與 Thread-loader 共同使用,讀者需要仔細加以甄別、測試。
使用 Parallel-Webpack
Thread-loader、HappyPack 這類組件所提供的并行能力都僅作用于執行加載器 —— Loader 的過程,對后續 AST 解析、依賴收集、打包、優化代碼等過程均沒有影響,理論收益還是比較有限的。對此,社區還提供了另一種并行度更高,以多個獨立進程運行 Webpack 實例的方案 —— Parallel-Webpack。
官方鏈接:https://github.com/trivago/parallel-webpack
使用方法
基本用法
使用前,依然需要安裝依賴:
- yarn add -D parallel-webpack
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 即可完成構建,上面的示例配置會同時打包出 pageA.js 與 pageB.js 兩份產物。
組合變量
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 的實現非常簡單,基本上就是在 Webpack 上套了個殼,核心邏輯:
- 根據傳入的配置項數量,調用 worker-farm 創建復數個工作進程
- 工作進程內調用 Webpack 執行構建
- 工作進程執行完畢后,調用 node-ipc 向主進程發送結束信號
到這里,所有工作就完成了。
缺點
雖然,parallel-webpack 相對于 Thread-loader、HappyPack 有更高的并行度,但進程實例與實例之間并沒有做任何形式的通訊,這可能導致相同的工作在不同進程 —— 或者說不同 CPU 核上被重復執行。例如需要對同一份代碼同時打包出壓縮和非壓縮版本時,在 parallel-webpack 方案下,前置的資源加載、依賴解析、AST 分析等操作會被重復執行,僅僅最終階段生成代碼時有所差異。
這種技術實現,對單 entry 的項目沒有任何收益,只會徒增進程創建成本;但特別適合 MPA 等多 entry 場景,或者需要同時編譯出 esm、umd、amd 等多種產物形態的類庫場景。
并行壓縮
Webpack 語境下通常使用 Uglify-js、Uglify-es、Terser 做代碼混淆壓縮,三者都不同程度上原生實現了多進程并行壓縮功能。
TerserWebpackPlugin 完整介紹:https://webpack.js.org/plugins/terser-webpack-plugin/
以 Terser 為例,插件 TerserWebpackPlugin 默認已開啟并行壓縮能力,通常情況下保持默認配置即 parallel = true 即可獲得最佳的性能收益。開發者也可以通過 parallel 參數關閉或設定具體的并行進程數量,例如:
- const TerserPlugin = require("terser-webpack-plugin");
- module.exports = {
- optimization: {
- minimize: true,
- minimizer: [new TerserPlugin({
- parallel: 2 // number | boolean
- })],
- },
- };
上述配置即可設定最大并行進程數為2。
對于 Webpack 4 及之前的版本,代碼壓縮插件 UglifyjsWebpackPlugin 也有類似的功能與配置項,此處不再贅述。
最佳實踐
理論上,并行確實能夠提升系統運行效率,但 Node 單線程架構下,所謂的并行計算都只能依托與派生子進程執行,而創建進程這個動作本身就有不小的消耗 —— 大約 600ms,因此建議讀者按實際需求斟酌使用上述多進程方案。
對于小型項目,構建成本可能很低,但引入多進程技術反而導致整體成本增加。
對于大型項目,由于 HappyPack 官方已經明確表示不維護,所以建議盡量使用 Thread-loader 組件提升 Make 階段性能。生產環境下還可配合 terser-webpack-plugin 的并行壓縮功能,提升整體效率。
【編輯推薦】