Webpack4編譯階段的性能優化和踩坑
Hello,大家好,我是松寶寫代碼,寫寶寫的不止是代碼。接下來給大家帶來的是關于Webpack4的性能優化的系列,今天帶來的是編譯階段的性能優化。
由于優化都是在 Webpack 4 上做的,當時 Webpack 5 還未穩定,現在使用 Webpack 5 時可能有些優化方案不再需要或方案不一致,這里主要介紹優化思路,僅作為參考。
背景
在接觸一些大型項目構建速度慢的很離譜,有些項目在 編譯構建上30分鐘超時,有些構建到一半內存溢出。但當時一些通用的 Webpack 構建優化方案要么已經接入,要么場景不適用:
- 已接入的方案效果有限。比如 cache-loader、thread-loader,能優化編譯階段的速度,但對于依賴解析、代碼壓縮、SourceMap 生成等環節無能為力
- 作為前端基建方案,業務依賴差異極大,難以針對特定依賴優化,如 DllPlugin 方案
- 作為移動端打包方案,追求極致的首屏加載速度,難以接受頻繁的異步資源請求,如 Module Federation、Common Chunk 方案
- 存在一碼多產物場景,需要單倉庫多模式構建(1.0/2.0 * 主包/分包)下緩存復用,難以接受耦合度高的緩存方案,如 Persistent Caching
在這種情況下,只好另辟蹊徑去尋找更多優化方案,這篇文章主要就是介紹這些“非主流”的優化方案,以及引發的思考。
分析
簡化Webpack 的構建流程后,Webpack 的構建流程大體上分為如下幾個階段:
- 模塊編譯:需要運行如 babel、postcss 等 loader 對模塊進行代碼編譯
- 依賴解析:需要使用 acorn 把代碼生成 AST 并遍歷查找下游依賴
- 代碼壓縮:需要生成 AST 并大量修改替換
- SourceMap:需要將構建流程代碼操作產生的位置映射計算、合并
而在盡可能不改變處理邏輯的情況下,常見的優化思路就是“并行”和“緩存”:
- 并行:如 thread-loader
- 緩存:如 cache-loader/Persistent Caching
但目前“并行”和“緩存”僅覆蓋模塊編譯階段,能否把“并行”和“緩存”的方案擴展到整個構建流程呢?
準備
為了讓“并行”+“緩存”能夠覆蓋整個構建流程,需要做如下準備工作:
- 引用透明改造:保證各個耗時較高的構建階段無副作用
- 緩存池:統一管理各階段生成的緩存
- 并行調度池:統一管理子進程/子線程的調度
引用透明改造
引用透明改造包括如下幾個部分:
- 以 module 的 request 作為整個生命周期中的唯一標識,模塊級粒度的構建控制參數都放到 request 的 query 中。
- 需要并行任務的配置、參數、結果都能夠序列化/反序列化。
- 函數執行不依賴全局變量,相同的參數一定能得到相同的結果。
緩存池
緩存池的核心功能:
- 讀寫時機控制:Webpack 按照 module 維度拆分緩存,而由于 node_modules 黑洞導致 module 數量巨大,因此讀寫本地文件系統開銷也較大,避免在主進程繁忙時讀寫緩存。
- 按需讀寫:通常模塊并不一定會全量重新構建,因此按需的讀取/寫入能大幅度減少文件的操作次數。
- 整體/分體緩存:不同的場景可能導致緩存的切分粒度不同,比如分體緩存能夠更好的處理按需讀寫,而整體緩存能在 faas 讀取 nas 場景下獲得較好的性能。
并行調度池
并行調度池類似于數據庫連接池,主要功能:
- 任務隊列:將處理任務放在隊列中,同時向并行調度器發送處理請求。
- 并行調度器:收到處理請求時,若有空閑并行實例優先調度,若沒有則按照最大并行數量新建。
- 子進程:使用 child_process 創建子進程,通過 IPC message 傳輸數據。
- 子線程:使用 worker_threads 創建子線程,通過 ArrayBuffer 傳輸數據(注意 nodejs 版本)。
- 并行實例:不處理實際邏輯,負責跨進程/線程通信,處理數據序列化反序列化,按需加載構建任務。
- 構建任務:執行具體的處理邏輯:
編譯任務:使用 loader-runner 編譯模塊代碼。
壓縮任務:使用 terser/esbuild 壓縮模塊代碼。
SourceMap 任務:生成序列化 SourceNode。
做好了這些準備工作后,就可以開始進行各個階段的“并行”+“緩存”改造。
編譯階段優化
編譯階段流程
Webpack 內部的單個模塊構建流程大致如下所示:
- 從 entry 開始,創建模塊。
- 模塊經過 loader 處理后,得到編譯后代碼。
- 編譯后代碼經過 AST 解析后,得到模塊的下游依賴。
- 將下游依賴創建新的模塊,回到步驟 2 遞歸處理。
- 直到所有模塊都處理完成,模塊編譯流程結束。
Cache-loader
loader 運行類似于 Express/Koa 的中間件機制,每一個 Loader 分為 pitch 和 normal 兩個階段,cache-loader 利用這一點,在 pitch 階段進行緩存檢測,如果檢測到緩存可用則直接返回。無緩存或緩存不可用則繼續運行后續流程,直到 normal 階段生成緩存寫入文件系統。
thread-loader也是同理,只不過把后續的 loader 以及相關參數交給了子進程,并在子進程中模擬了 Webpack 的 loader 運行機制。
Persistent Caching
但 cache-loader 無法解決 AST Parser + 遍歷生成依賴帶來的消耗,開源界有 hard-source-webpack-plugin 嘗試解決這個問題(但問題很多)。Webpack 團隊自己也意識到了這個問題, 因此在 Webpack 5 中增加的 Persistent caching 來優化,但它的實現思路是將 Webpack 整個上下文都緩存下來,因此 Webpack 5 給幾乎每個對象都增加了序列化/反序列化的方法:
// webpack@5.9.0/lib/NormalModule.js L1068 ~ L1105
serialize(context) {
const { write } = context;
// deserialize
write(this._source);
write(this._sourceSizes);
write(this.error);
write(this._lastSuccessfulBuildMeta);
write(this._forceBuild);
super.serialize(context);
}
deserialize(context) {
const { read } = context;
this._source = read();
this._sourceSizes = read();
this.error = read();
this._lastSuccessfulBuildMeta = read();
this._forceBuild = read();
super.deserialize(context);
}
但由于當時無法升級 Webpack 5,且 Persistent caching 脫離了統一的緩存控制,最終選擇自己實現緩存來保證可移植、可拼接、預生成,如果在 Webpack 5 上實現,理論上可以復用一部分模塊、依賴的序列化/反序列化能力,并橋接到緩存池上。
依賴解析緩存方案
方案設計
方案如下圖所示:
- 緩存管理:將緩存池橋接到 Webpack 構建的生命周期 hooks 上。
- 模塊處理器:模塊的序列化與反序列化工具。
- 緩存匹配器:判斷模塊是否可以使用緩存中的數據。
- Hash 生成器:全局統一的 Hash 生成器。
處理流程
- 通過 NormalModuleFactory 干預模塊生成,并代理掉模塊自身的 build 方法。
- 當模塊觸發構建時,先進行緩存匹配:
- 首先需要通過模塊 Request 生成 Hash 并從上面說的緩存池中找到對應的項目。
- 讀取緩存中的 metaHash,并將 Request 里的文件通過 fs.stat 讀取文件的元信息,將其中的文件名、文件大小、修改時間等信息生成 hash,與 metaHash 進行比對,相等則認為緩存可用。
- 讀取緩存中的 contentHash,并讀取文件文本內容生成 Hash 比對,相等則認為緩存可用。
- 緩存匹配時,使用模塊反序列化器將緩存恢復成模塊實例屬性,并寫入到當前模塊中,跳過構建流程直接回調。
- 未匹配時,使用 Webpack 內置的模塊 build 方法(上面被代理的方法)進行構建,但攔截其回調函數,在外面套娃進行模塊的序列化。
模塊處理器
模塊的序列化分為兩部分:模塊本體序列化、模塊依賴序列化。
模塊本體的序列化較為簡單:
- 模塊的 Request,也就是模塊的唯一 ID。
- 模塊的 source 對象,一個 Webpack Source 實例,通過 sourceAndMap 方法獲取其結果代碼和 SourceMap 并序列化。
- 模塊的構建信息對象,包括 buildInfo、buildMeta 對象。
模塊的依賴序列化較為復雜,因為依賴由 Webpack 解析 AST 后遍歷生成,依賴內部會直接保留相關聯的 AST 節點,這些 AST 節點在后續的 chunk 產物生成的 dependency template 階段會用來生成模塊引用依賴的相關代碼。
但實際上,依賴內部并不會真正使用多少 AST 的節點,僅僅是從其中讀取少量信息用來做代碼替換的位置判斷和字符串拼接,因此序列化的過程就變成了提取 AST 上依賴使用的關鍵信息,而反序列化則是將這些關鍵信息偽造成 AST 節點即可。
不過,Webpack 內部這樣的依賴有數十個(webpack/lib/dependencies目錄下),需要一個個處理。同時,對于一些特殊的場景,比如 Block 類型的依賴(通常是異步加載的代碼)無法支持。(Webpack 5 中可以直接用這些 Dependency 上面的序列化/反序列化方法)。
'use strict';
const NullDependency = require('./NullDependency');
class HarmonyExportHeaderDependency extends NullDependency {
constructor(range, rangeStatement) {
super();
this.range = range;
this.rangeStatement = rangeStatement;
}
get type() {
return 'harmony export header';
}
}
HarmonyExportHeaderDependency.Template = class HarmonyExportDependencyTemplate {
apply(dep, source) {
const content = '';
const replaceUntil = dep.range ? dep.range[0] - 1 : dep.rangeStatement[1] - 1;
source.replace(dep.rangeStatement[0], replaceUntil, content);
}
};
module.exports = HarmonyExportHeaderDependency;
如此這般,當緩存命中時,模塊的依賴解析流程會被完全跳過。但這個流程并行化難度較高,主要原因是 Webpack 內 Parser Hooks 的橋接較為復雜,可以說 Hooks 的存在本身就是副作用的一種體現。
其他優化
Resolver
對 Webpack 的 enhance-resolver 進行緩存,降低 Webpack 在文件系統中查找的成本。由于 Resolver 較為復雜,且不同的 node_modules 組織方式、不同的依賴版本、不同的起始路徑,都可能使得相同的 request 被解析到完全不同的文件,因此針對不同類型的 request,緩存的處理邏輯不同:
- Loader resolver:Loader 均由構建器統一管理,可以設置持久化緩存。
- 動態注入路徑:在構建過程中添加的依賴,而非源碼本身的依賴,受構建器統一管理,可以設置持久化緩存。
- node_modules:在一次構建中,相同 context 下的相同 request 可以使用內存緩存,但不宜使用持久化緩存。
- 項目源碼:不宜使用緩存。
Hash
構建器和 Webpack 的處理流程中存在大量的 Hash 計算。而使用 md5 作為 Hash 的成本較高,可以采用如 imurmurhash 等碰撞率高一些但性能更好的 Hash 方案進行替換。同時代理的 Hash 也可用來做后續的可移植緩存。