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