常見(jiàn)面試題:Tree-Shaking 實(shí)現(xiàn)原理
一、什么是 Tree Shaking
Tree-Shaking 是一種基于 ES Module 規(guī)范的 Dead Code Elimination 技術(shù),它會(huì)在運(yùn)行過(guò)程中靜態(tài)分析模塊之間的導(dǎo)入導(dǎo)出,確定 ESM 模塊中哪些導(dǎo)出值未曾其它模塊使用,并將其刪除,以此實(shí)現(xiàn)打包產(chǎn)物的優(yōu)化。

Tree Shaking 較早前由 Rich Harris 在 Rollup 中率先實(shí)現(xiàn),Webpack 自 2.0 版本開(kāi)始接入,至今已經(jīng)成為一種應(yīng)用廣泛的性能優(yōu)化手段。
1.1 在 Webpack 中啟動(dòng) Tree Shaking
在 Webpack 中,啟動(dòng) Tree Shaking 功能必須同時(shí)滿(mǎn)足三個(gè)條件:
使用 ESM 規(guī)范編寫(xiě)模塊代碼 配置 optimization.usedExports為true,啟動(dòng)標(biāo)記功能啟動(dòng)代碼優(yōu)化功能,可以通過(guò)如下方式實(shí)現(xiàn): 配置 mode = production配置 optimization.minimize = true提供 optimization.minimizer數(shù)組
例如:
// webpack.config.js
module.exports = {
entry: "./src/index",
mode: "production",
devtool: false,
optimization: {
usedExports: true,
},
};
1.2 理論基礎(chǔ)
在 CommonJs、AMD、CMD 等舊版本的 JavaScript 模塊化方案中,導(dǎo)入導(dǎo)出行為是高度動(dòng)態(tài),難以預(yù)測(cè)的,例如:
if(process.env.NODE_ENV === 'development'){
require('./bar');
exports.foo = 'foo';
}
而 ESM 方案則從規(guī)范層面規(guī)避這一行為,它要求所有的導(dǎo)入導(dǎo)出語(yǔ)句只能出現(xiàn)在模塊頂層,且導(dǎo)入導(dǎo)出的模塊名必須為字符串常量,這意味著下述代碼在 ESM 方案下是非法的:
if(process.env.NODE_ENV === 'development'){
import bar from 'bar';
export const foo = 'foo';
}
所以,ESM 下模塊之間的依賴(lài)關(guān)系是高度確定的,與運(yùn)行狀態(tài)無(wú)關(guān),編譯工具只需要對(duì) ESM 模塊做靜態(tài)分析,就可以從代碼字面量中推斷出哪些模塊值未曾被其它模塊使用,這是實(shí)現(xiàn) Tree Shaking 技術(shù)的必要條件。
1.3 示例
對(duì)于下述代碼:
// index.js
import {bar} from './bar';
console.log(bar);
// bar.js
export const bar = 'bar';
export const foo = 'foo';
示例中,bar.js 模塊導(dǎo)出了 bar 、foo ,但只有 bar 導(dǎo)出值被其它模塊使用,經(jīng)過(guò) Tree Shaking 處理后,foo 變量會(huì)被視作無(wú)用代碼刪除。
二、實(shí)現(xiàn)原理
Webpack 中,Tree-shaking 的實(shí)現(xiàn)一是先「標(biāo)記」出模塊導(dǎo)出值中哪些沒(méi)有被用過(guò),二是使用 Terser 刪掉這些沒(méi)被用到的導(dǎo)出語(yǔ)句。標(biāo)記過(guò)程大致可劃分為三個(gè)步驟:
Make 階段,收集模塊導(dǎo)出變量并記錄到模塊依賴(lài)關(guān)系圖 ModuleGraph 變量中 Seal 階段,遍歷 ModuleGraph 標(biāo)記模塊導(dǎo)出變量有沒(méi)有被使用 生成產(chǎn)物時(shí),若變量沒(méi)有被其它模塊使用則刪除對(duì)應(yīng)的導(dǎo)出語(yǔ)句
標(biāo)記功能需要配置 optimization.usedExports = true 開(kāi)啟也就是說(shuō),標(biāo)記的效果就是刪除沒(méi)有被其它模塊使用的導(dǎo)出語(yǔ)句,比如:

示例中,bar.js 模塊(左二)導(dǎo)出了兩個(gè)變量:bar 與 foo,其中 foo 沒(méi)有被其它模塊用到,所以經(jīng)過(guò)標(biāo)記后,構(gòu)建產(chǎn)物(右一)中 foo 變量對(duì)應(yīng)的導(dǎo)出語(yǔ)句就被刪除了。作為對(duì)比,如果沒(méi)有啟動(dòng)標(biāo)記功能(optimization.usedExports = false 時(shí)),則變量無(wú)論有沒(méi)有被用到都會(huì)保留導(dǎo)出語(yǔ)句,如上圖右二的產(chǎn)物代碼所示。
注意,這個(gè)時(shí)候 foo 變量對(duì)應(yīng)的代碼 const foo='foo' 都還保留完整,這是因?yàn)闃?biāo)記功能只會(huì)影響到模塊的導(dǎo)出語(yǔ)句,真正執(zhí)行“「Shaking」”操作的是 Terser 插件。例如在上例中 foo 變量經(jīng)過(guò)標(biāo)記后,已經(jīng)變成一段 Dead Code —— 不可能被執(zhí)行到的代碼,這個(gè)時(shí)候只需要用 Terser 提供的 DCE 功能就可以刪除這一段定義語(yǔ)句,以此實(shí)現(xiàn)完整的 Tree Shaking 效果。
接下來(lái)我會(huì)展開(kāi)標(biāo)記過(guò)程的源碼,詳細(xì)講解 Webpack 5 中 Tree Shaking 的實(shí)現(xiàn)過(guò)程,對(duì)源碼不感興趣的同學(xué)可以直接跳到下一章。
2.1 收集模塊導(dǎo)出
首先,Webpack 需要弄清楚每個(gè)模塊分別有什么導(dǎo)出值,這一過(guò)程發(fā)生在 make 階段,大體流程:
關(guān)于 Make 階段的更多說(shuō)明,請(qǐng)參考前文 [萬(wàn)字總結(jié)] 一文吃透 Webpack 核心原理 。
將模塊的所有 ESM 導(dǎo)出語(yǔ)句轉(zhuǎn)換為 Dependency 對(duì)象,并記錄到 module對(duì)象的dependencies集合,轉(zhuǎn)換規(guī)則:
具名導(dǎo)出轉(zhuǎn)換為 HarmonyExportSpecifierDependency對(duì)象default導(dǎo)出轉(zhuǎn)換為HarmonyExportExpressionDependency對(duì)象
例如對(duì)于下面的模塊:
export const bar = 'bar';
export const foo = 'foo';
export default 'foo-bar'
對(duì)應(yīng)的dependencies 值為:

所有模塊都編譯完畢后,觸發(fā) compilation.hooks.finishModules鉤子,開(kāi)始執(zhí)行FlagDependencyExportsPlugin插件回調(diào)FlagDependencyExportsPlugin插件從 entry 開(kāi)始讀取 ModuleGraph 中存儲(chǔ)的模塊信息,遍歷所有module對(duì)象遍歷 module對(duì)象的dependencies數(shù)組,找到所有HarmonyExportXXXDependency類(lèi)型的依賴(lài)對(duì)象,將其轉(zhuǎn)換為ExportInfo對(duì)象并記錄到 ModuleGraph 體系中
經(jīng)過(guò) FlagDependencyExportsPlugin 插件處理后,所有 ESM 風(fēng)格的 export 語(yǔ)句都會(huì)記錄在 ModuleGraph 體系內(nèi),后續(xù)操作就可以從 ModuleGraph 中直接讀取出模塊的導(dǎo)出值。
參考資料:
2.2 標(biāo)記模塊導(dǎo)出
模塊導(dǎo)出信息收集完畢后,Webpack 需要標(biāo)記出各個(gè)模塊的導(dǎo)出列表中,哪些導(dǎo)出值有被其它模塊用到,哪些沒(méi)有,這一過(guò)程發(fā)生在 Seal 階段,主流程:
觸發(fā) compilation.hooks.optimizeDependencies鉤子,開(kāi)始執(zhí)行FlagDependencyUsagePlugin插件邏輯在 FlagDependencyUsagePlugin插件中,從 entry 開(kāi)始逐步遍歷 ModuleGraph 存儲(chǔ)的所有module對(duì)象遍歷 module對(duì)象對(duì)應(yīng)的exportInfo數(shù)組為每一個(gè) exportInfo對(duì)象執(zhí)行compilation.getDependencyReferencedExports方法,確定其對(duì)應(yīng)的dependency對(duì)象有否被其它模塊使用被任意模塊使用到的導(dǎo)出值,調(diào)用 exportInfo.setUsedConditionally方法將其標(biāo)記為已被使用。exportInfo.setUsedConditionally內(nèi)部修改exportInfo._usedInRuntime屬性,記錄該導(dǎo)出被如何使用結(jié)束
上面是極度簡(jiǎn)化過(guò)的版本,中間還存在非常多的分支邏輯與復(fù)雜的集合操作,我們抓住重點(diǎn):標(biāo)記模塊導(dǎo)出這一操作集中在 FlagDependencyUsagePlugin 插件中,執(zhí)行結(jié)果最終會(huì)記錄在模塊導(dǎo)出語(yǔ)句對(duì)應(yīng)的 exportInfo._usedInRuntime 字典中。
2.3 生成代碼
經(jīng)過(guò)前面的收集與標(biāo)記步驟后,Webpack 已經(jīng)在 ModuleGraph 體系中清楚地記錄了每個(gè)模塊都導(dǎo)出了哪些值,每個(gè)導(dǎo)出值又沒(méi)那塊模塊所使用。接下來(lái),Webpack 會(huì)根據(jù)導(dǎo)出值的使用情況生成不同的代碼,例如:

重點(diǎn)關(guān)注 bar.js 文件,同樣是導(dǎo)出值,bar 被 index.js 模塊使用因此對(duì)應(yīng)生成了 __webpack_require__.d 調(diào)用 "bar": ()=>(/* binding */ bar),作為對(duì)比 foo 則僅僅保留了定義語(yǔ)句,沒(méi)有在 chunk 中生成對(duì)應(yīng)的 export。
關(guān)于 Webpack 產(chǎn)物的內(nèi)容及__webpack_require__.d方法的含義,可參考 Webpack 原理系列六:徹底理解 Webpack 運(yùn)行時(shí) 一文。
這一段生成邏輯均由導(dǎo)出語(yǔ)句對(duì)應(yīng)的 HarmonyExportXXXDependency 類(lèi)實(shí)現(xiàn),大體的流程:
打包階段,調(diào)用 HarmonyExportXXXDependency.Template.apply方法生成代碼在 apply方法內(nèi),讀取 ModuleGraph 中存儲(chǔ)的exportsInfo信息,判斷哪些導(dǎo)出值被使用,哪些未被使用對(duì)已經(jīng)被使用及未被使用的導(dǎo)出值,分別創(chuàng)建對(duì)應(yīng)的 HarmonyExportInitFragment對(duì)象,保存到initFragments數(shù)組遍歷 initFragments數(shù)組,生成最終結(jié)果
基本上,這一步的邏輯就是用前面收集好的 exportsInfo 對(duì)象未模塊的導(dǎo)出值分別生成導(dǎo)出語(yǔ)句。
2.4 刪除 Dead Code
經(jīng)過(guò)前面幾步操作之后,模塊導(dǎo)出列表中未被使用的值都不會(huì)定義在 __webpack_exports__ 對(duì)象中,形成一段不可能被執(zhí)行的 Dead Code 效果,如上例中的 foo 變量:

在此之后,將由 Terser、UglifyJS 等 DCE 工具“搖”掉這部分無(wú)效代碼,構(gòu)成完整的 Tree Shaking 操作。
2.5 總結(jié)
綜上所述,Webpack 中 Tree Shaking 的實(shí)現(xiàn)分為如下步驟:
在 FlagDependencyExportsPlugin插件中根據(jù)模塊的dependencies列表收集模塊導(dǎo)出值,并記錄到 ModuleGraph 體系的exportsInfo中在 FlagDependencyUsagePlugin插件中收集模塊的導(dǎo)出值的使用情況,并記錄到exportInfo._usedInRuntime集合中在 HarmonyExportXXXDependency.Template.apply方法中根據(jù)導(dǎo)出值的使用情況生成不同的導(dǎo)出語(yǔ)句使用 DCE 工具刪除 Dead Code,實(shí)現(xiàn)完整的樹(shù)搖效果
上述實(shí)現(xiàn)原理對(duì)背景知識(shí)要求較高,建議讀者同步配合以下文檔食用:
三、最佳實(shí)踐
雖然 Webpack 自 2.x 開(kāi)始就原生支持 Tree Shaking 功能,但受限于 JS 的動(dòng)態(tài)特性與模塊的復(fù)雜性,直至最新的 5.0 版本依然沒(méi)有解決許多代碼副作用帶來(lái)的問(wèn)題,使得優(yōu)化效果并不如 Tree Shaking 原本設(shè)想的那么完美,所以需要使用者有意識(shí)地優(yōu)化代碼結(jié)構(gòu),或使用一些補(bǔ)丁技術(shù)幫助 Webpack 更精確地檢測(cè)無(wú)效代碼,完成 Tree Shaking 操作。
3.1 避免無(wú)意義的賦值
使用 Webpack 時(shí),需要有意識(shí)規(guī)避一些不必要的賦值操作,觀察下面這段示例代碼:

示例中,index.js 模塊引用了 bar.js 模塊的 foo 并賦值給 f 變量,但后續(xù)并沒(méi)有繼續(xù)用到 foo 或 f 變量,這種場(chǎng)景下 bar.js 模塊導(dǎo)出的 foo 值實(shí)際上并沒(méi)有被使用,理應(yīng)被刪除,但 Webpack 的 Tree Shaking 操作并沒(méi)有生效,產(chǎn)物中依然保留 foo 導(dǎo)出:

造成這一結(jié)果,淺層原因是 Webpack 的 Tree Shaking 邏輯停留在代碼靜態(tài)分析層面,只是淺顯地判斷:
模塊導(dǎo)出變量是否被其它模塊引用 引用模塊的主體代碼中有沒(méi)有出現(xiàn)這個(gè)變量
沒(méi)有進(jìn)一步,從語(yǔ)義上分析模塊導(dǎo)出值是不是真的被有效使用。
更深層次的原因則是 JavaScript 的賦值語(yǔ)句并不「純」,視具體場(chǎng)景有可能產(chǎn)生意料之外的副作用,例如:
import { bar, foo } from "./bar";
let count = 0;
const mock = {}
Object.defineProperty(mock, 'f', {
set(v) {
mock._f = v;
count += 1;
}
})
mock.f = foo;
console.log(count);
示例中,對(duì) mock 對(duì)象施加的 Object.defineProperty 調(diào)用,導(dǎo)致 mock.f = foo 賦值語(yǔ)句對(duì) count 變量產(chǎn)生了副作用,這種場(chǎng)景下即使用復(fù)雜的動(dòng)態(tài)語(yǔ)義分析也很難在確保正確副作用的前提下,完美地 Shaking 掉所有無(wú)用的代碼枝葉。
因此,在使用 Webpack 時(shí)開(kāi)發(fā)者需要有意識(shí)地規(guī)避這些無(wú)意義的重復(fù)賦值操作。
3.3 使用 #pure 標(biāo)注純函數(shù)調(diào)用
與賦值語(yǔ)句類(lèi)似,JavaScript 中的函數(shù)調(diào)用語(yǔ)句也可能產(chǎn)生副作用,因此默認(rèn)情況下 Webpack 并不會(huì)對(duì)函數(shù)調(diào)用做 Tree Shaking 操作。不過(guò),開(kāi)發(fā)者可以在調(diào)用語(yǔ)句前添加 /*#__PURE__*/ 備注,明確告訴 Webpack 該次函數(shù)調(diào)用并不會(huì)對(duì)上下文環(huán)境產(chǎn)生副作用,例如:

示例中,foo('be retained') 調(diào)用沒(méi)有帶上 /*#__PURE__*/ 備注,代碼被保留;作為對(duì)比,foo('be removed') 帶上 Pure 聲明后則被 Tree Shaking 刪除。
3.3 禁止 Babel 轉(zhuǎn)譯模塊導(dǎo)入導(dǎo)出語(yǔ)句
Babel 是一個(gè)非常流行的 JavaScript 代碼轉(zhuǎn)換器,它能夠?qū)⒏甙姹镜?JS 代碼等價(jià)轉(zhuǎn)譯為兼容性更佳的低版本代碼,使得前端開(kāi)發(fā)者能夠使用最新的語(yǔ)言特性開(kāi)發(fā)出兼容舊版本瀏覽器的代碼。
但 Babel 提供的部分功能特性會(huì)致使 Tree Shaking 功能失效,例如 Babel 可以將 import/export 風(fēng)格的 ESM 語(yǔ)句等價(jià)轉(zhuǎn)譯為 CommonJS 風(fēng)格的模塊化語(yǔ)句,但該功能卻導(dǎo)致 Webpack 無(wú)法對(duì)轉(zhuǎn)譯后的模塊導(dǎo)入導(dǎo)出內(nèi)容做靜態(tài)分析,示例:

示例使用 babel-loader 處理 *.js 文件,并設(shè)置 Babel 配置項(xiàng) modules = 'commonjs',將模塊化方案從 ESM 轉(zhuǎn)譯到 CommonJS,導(dǎo)致轉(zhuǎn)譯代碼(右圖上一)沒(méi)有正確標(biāo)記出未被使用的導(dǎo)出值 foo。作為對(duì)比,右圖 2 為 modules = false 時(shí)打包的結(jié)果,此時(shí) foo 變量被正確標(biāo)記為 Dead Code。
所以,在 Webpack 中使用 babel-loader 時(shí),建議將 babel-preset-env 的 moduels 配置項(xiàng)設(shè)置為 false,關(guān)閉模塊導(dǎo)入導(dǎo)出語(yǔ)句的轉(zhuǎn)譯。
3.4 優(yōu)化導(dǎo)出值的粒度
Tree Shaking 邏輯作用在 ESM 的 export 語(yǔ)句上,因此對(duì)于下面這種導(dǎo)出場(chǎng)景:
export default {
bar: 'bar',
foo: 'foo'
}
即使實(shí)際上只用到 default 導(dǎo)出值的其中一個(gè)屬性,整個(gè) default 對(duì)象依然會(huì)被完整保留。所以實(shí)際開(kāi)發(fā)中,應(yīng)該盡量保持導(dǎo)出值顆粒度和原子性,上例代碼的優(yōu)化版本:
const bar = 'bar'
const foo = 'foo'
export {
bar,
foo
}
3.5 使用支持 Tree Shaking 的包
如果可以的話(huà),應(yīng)盡量使用支持 Tree Shaking 的 npm 包,例如:
使用 lodash-es替代lodash,或者使用babel-plugin-lodash實(shí)現(xiàn)類(lèi)似效果
不過(guò),并不是所有 npm 包都存在 Tree Shaking 的空間,諸如 React、Vue2 一類(lèi)的框架原本已經(jīng)對(duì)生產(chǎn)版本做了足夠極致的優(yōu)化,此時(shí)業(yè)務(wù)代碼需要整個(gè)代碼包提供的完整功能,基本上不太需要進(jìn)行 Tree Shaking。
