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