Webpack4的SourceMap階段的性能優化和踩坑
Hello,大家好,我是松寶寫代碼,寫寶寫的不止是代碼。
由于優化都是在 Webpack 4 上做的,當時 Webpack 5 還未穩定,現在使用 Webpack 5 時可能有些優化方案不再需要或方案不一致,這里主要分享思路,可供參考。
背景
在接觸一些大型項目構建速度慢的很離譜,有些項目在 編譯構建上30分鐘超時,有些構建到一半內存溢出。但當時一些通用的 Webpack 構建優化方案要么已經接入,要么場景不適用:
- 已接入的方案效果有限。比如 cache-loader、thread-loader,能優化編譯階段的速度,但對于依賴解析、代碼壓縮、SourceMap 生成等環節無能為力
- 作為前端基建方案,業務依賴差異極大,難以針對特定依賴優化,如 DllPlugin 方案
- 作為移動端打包方案,追求極致的首屏加載速度,難以接受頻繁的異步資源請求,如 Module Federation、Common Chunk 方案
- 存在一碼多產物場景,需要單倉庫多模式構建(1.0/2.0 * 主包/分包)下緩存復用,難以接受耦合度高的緩存方案,如 Persistent Caching
在這種情況下,只好另辟蹊徑去尋找更多優化方案,這篇文章主要就是介紹這些“非主流”的優化方案,以及引發的思考。
今天帶來的是webapck4sourceMap階段。
SourceMap階段
SourceMap生成流程 SourceMap 生成過程中,由于項目過大導致需要計算處理的映射節點(SourceNode)特別多(遇到過10^6數量級的項目),這也導致 SourceMap 生成過程中內存飆升頻繁 GC,構建十分緩慢甚至 OOM。
Webpack 內部有大量的代碼拼接工作,而每一次代碼拼接都涉及到 SourceMap 的處理,因此 Webpack 內封裝了 webpack-sources,其中 SourceMapSource 用于保存 SourceMap,ConcatSource 用于代碼拼接, SourceMap 操作使用 source-map 和 source-list-map 庫來處理。
而其內部實際上是在運行 sourceAndMap()/map() 方法時才進行計算:
// webpack-sources/SourceMapSource
class SourceMapSource extends Source {
// ...
node(options) {
// 此處進行真正的計算
var sourceMap = this._sourceMap;
var node = SourceNode.fromStringWithSourceMap(this._value, new SourceMapConsumer(sourceMap));
node.setSourceContent(this._name, this._originalSource);
var innerSourceMap = this._innerSourceMap;
if(innerSourceMap) {
node = applySourceMap(node, new SourceMapConsumer(innerSourceMap), this._name, this._removeOriginalSource);
}
return node;
}
// ...
}
// webpack-sources/SourceAndMapMixin
proto.sourceAndMap = function (options) {
options = options || {};
if (options.columns === false) {
return this.listMap(options).toStringWithSourceMap({
file: "x",
});
}
var res = this.node(options).toStringWithSourceMap({
file: "x",
});
return {
source: res.code,
map: res.map.toJSON(),
};
};
SourceMap 優化方案
很顯然,如果把所有模塊的 SourceMap 都放到最后一起來計算,對主進程長時間占用導致 SourceMap 生成緩慢。可以通過如下方法進行優化:
- 使用行映射 SourceMap
- SourceMap 并行化
- SourceNode 內存優化
行映射 SourceMap
SourceMap 的本質就是大量的 SourceNode 組成,每一個 SourceNode 存儲了產物的位置到源碼位置的映射關系。通常映射關系是行號+列號,但我們排查bug時候一般只看哪一行,具體哪一列看的不多。如果忽略列號則可以大幅度減少 SourceNode 的數量。
SourceMapDevToolPlugin 中的 columns 設為 true 時就是行映射 SourceMap。但這個插件處理的邏輯已經是在最后產物生成階段,而在整個 Webpack 構建流程中流轉的 SourceMap 依然是行列映射。因此可以直接代理掉 SourceMapSource 的 map 方法,寫死 columns 為 true。
SourceMap 并行化
SourceMap 最后一起堆積在主進程中生成是非常緩慢的,因此可以考慮在模塊級壓縮的時候,手動模擬 node() 方法,觸發一下 applySourceMap 方法提前生成 SourceNode,并將 SourceNode 序列化傳遞回主進程,當主進程需要使用時直接獲取即可。
SourceNode 內存優化
當字符串被 split 時,行為與 substr 不太一樣,split 會生成字符串的拷貝,占用額外的內存(chrome memory profile 中為string),而 substr 會生成引用,字符串不會拷貝占用額外內存(chrome memory profile 中為 sliced string),但與此同時也意味著父字符串無法被 GC 回收。
const bigstring = '00000\n'.repeat(50000);
console.log(bigstring); // 觸發生成
const array = bigstring.split('\n');
const bigstring = '00000\n'.repeat(500000);
console.log(bigstring)
const array = [];
for (let i = 0; i < 100000;i++) {
array.push(bigstring.substr(i*5,i*5+5));
}
而看 source-map 中 SourceNode 的代碼可以發現:
- SourceNode 會將完整代碼根據換行符 split 切分(生成大量 string 內存占用)。
- 根據 mapping 對代碼求子串并保存(此時意味著這些 string 無法被釋放)。
// source-map/SourceNode
SourceNode.fromStringWithSourceMap = function SourceNode_fromStringWithSourceMap(
aGeneratedCode,
aSourceMapConsumer,
aRelativePath
) {
// ...
// 此處進行了代碼切分
var remainingLines = aGeneratedCode.split(REGEX_NEWLINE);
// ...
aSourceMapConsumer.eachMapping(function (mapping) {
if (lastMapping !== null) {
if (lastGeneratedLine < mapping.generatedLine) {
// ...
} else {
var nextLine = remainingLines[remainingLinesIndex] || '';
// 此處獲取子串并長久保存
var code = nextLine.substr(0, mapping.generatedColumn - lastGeneratedColumn);
// ...
addMappingWithCode(lastMapping, code);
// No more remaining code, continue
lastMapping = mapping;
return;
}
}
//...
}, this);
// ...
};
那么這個昂貴的 "code" 字段干什么用的呢?實際上只有如下兩個功能:
- 每一個 code 都會生成一個子 SourceNode,而最終遞歸生成的子 SourceNode 在 walk 階段又會拼接回產物代碼。
- 如果包含了換行符,則會用來做映射位置的偏移計算。
// source-map/SourceNode
SourceNode.prototype.toStringWithSourceMap = function SourceNode_toStringWithSourceMap(aArgs) {
// ...
this.walk(function (chunk, original) {
generated.code += chunk;
//...
for (var idx = 0, length = chunk.length; idx < length; idx++) {
if (chunk.charCodeAt(idx) === NEWLINE_CODE) {
generated.line++;
generated.column = 0;
// Mappings end at eol
// ...
} else {
generated.column++;
}
}
});
this.walkSourceContents(function (sourceFile, sourceContent) {
map.setSourceContent(sourceFile, sourceContent);
});
return { code: generated.code, map: map };
};
那么問題來了,產物代碼有很多其他渠道能夠獲取不需要在這里計算。而僅僅為了換行計算浪費如此大量的內存顯然是不合理的。因此可以在一開始就把換行符的位置計算出來,保留在 SourceNode 內部,然后讓切分出來的字符被 GC 回收,等到 walk 的時候直接拿這些換行符記錄進行計算即可。
衍生的應用場景
思路
前面構建生成了緩存,我們希望緩存是可移植、可拼接、預生成的:
- 可移植:中間產物不依賴特定環境,放到其他場景下依然能夠使用。
- 可拼接:對于每一個項目都有自己的中間產物,而當一個聚合的項目使用這些項目時,也可以通過聚合生成自己的中間產物。
- 預生成:中間產物可以提前生成,存放到云端,在任何有需要的場景下載使用。
通過預生成,按需下發,動態拼接的方式,就能真正做到“絕不構建第二次”。
可移植緩存
緩存與環境解耦是可以讓緩存跨機器使用,遺憾的是 Webpack 在其模塊的 request 中包含絕對路徑(要找到對應的文件),導致與其相關的 AST 解析、模塊 ID 生成等等都受到影響。因此要做到可移植緩存,需要如下改造:
- 統一的緩存管理:不受控的緩存難以做后續的環境解耦。
- 路徑替換&復原:對于寫入緩存的所有內容,一旦出現了本地路徑,都需要替換成占位符。讀取時則需要將占位符恢復成新環境的路徑。
- AST 偏移矯正:由于路徑替換過程中,路徑長度發生變化,從而導致上述依賴解析階段的 AST 位置信息緩存失效,因此需要根據路徑長度差異對 AST 位置進行矯正。
- Hash 代理:由于構建流程中有大量的 Hash 生成場景,而一旦包含了本地路徑字符串加入到 Hash 生成中,則必然導致 Hash 在新環境下無法被匹配。
增量的構建
有了可移植的緩存,就能實現增量的構建。核心思路如下:
- 項目某個特定版本源碼作為項目基線,基線初始化構建生成基線緩存和基線文件元數據
- 當文件發生變化時:
- 收集變化的文件生成變更元數據。
- 變更元數據 + 基線緩存 + 基線文件元數據,構建生成變更后產物+熱更新產物,同時產出增量補丁。
- 增量補丁主要包含文件目錄的增量、緩存的增量。
- 如果有前代增量補丁,可以合并。
- 當環境發生變化時,在新環境下:
- 增量補丁+基線緩存+基線文件元數據,通過增量消費構建,也可以再次產出構建產物。
- 當需要提升一個特定增量補丁的版本作為基線時,將其增量變更與基線緩存、基線文件元數據合并即可。
增量構建最大的好處:解決長迭代鏈導致的緩存存儲成本爆炸問題。
舉個例子:如果要做一個類似于 codepen、jsfiddle 那樣的 playground,可以在線編輯項目代碼,迭代中的每次編輯都可以回退,同時也能隨時將一次修改派生成為一個新的迭代。
在這種場景下,顯然不能給每次代碼修改都完整復刻一套緩存。增量的構建僅需要保存一個基線和對應版本相對于基線的增量,當切換到一個特定版本時,使用基線+增量就可以編譯出最新的產物,實現版本的快速恢復。這個同理可以應用在項目自身迭代過程的構建緩存池中。
最后
一些思考
- 函數編寫:牢記“引用透明”原則,這是緩存、并行化的基本前提。
- 模型設計:保證可序列化/反序列化,為緩存、并行化打好基礎。
- 緩存設計:所有緩存應當結構簡單且路徑無關,保證緩存可移植、可拼接。
- 對象引用:盡早釋放巨大對象的引用,僅保留需要的數據。
- 插件機制:tapable 這種 pub/sub 機制是否真的合理且靈活,也許高階函數更加合適。
- TypeScript:非 TS 代碼閱讀難度很大,運行時的數據流不去 debug 無法理解。
一些腦洞: