Webpack 原理系列九:Tree-Shaking 實現原理

語言: CN / TW / HK

theme: condensed-night-purple

本文已參與「掘力星計劃」,贏取創作大禮包,挑戰創作激勵金。

一、什麼是 Tree Shaking

Tree-Shaking 是一種基於 ES Module 規範的 Dead Code Elimination 技術,它會在運行過程中靜態分析模塊之間的導入導出,確定 ESM 模塊中哪些導出值未曾其它模塊使用,並將其刪除,以此實現打包產物的優化。

Tree Shaking 較早前由 Rich Harris 在 Rollup 中率先實現,Webpack 自 2.0 版本開始接入,至今已經成為一種應用廣泛的性能優化手段。

1.1 在 Webpack 中啟動 Tree Shaking

在 Webpack 中,啟動 Tree Shaking 功能必須同時滿足三個條件:

  • 使用 ESM 規範編寫模塊代碼
  • 配置 optimization.usedExportstrue,啟動標記功能
  • 啟動代碼優化功能,可以通過如下方式實現:
  • 配置 mode = production
  • 配置 optimization.minimize = true
  • 提供 optimization.minimizer 數組

例如:

js // webpack.config.js module.exports = { entry: "./src/index", mode: "production", devtool: false, optimization: { usedExports: true, }, };

1.2 理論基礎

在 CommonJs、AMD、CMD 等舊版本的 JavaScript 模塊化方案中,導入導出行為是高度動態,難以預測的,例如:

js if(process.env.NODE_ENV === 'development'){ require('./bar'); exports.foo = 'foo'; }

而 ESM 方案則從規範層面規避這一行為,它要求所有的導入導出語句只能出現在模塊頂層,且導入導出的模塊名必須為字符串常量,這意味着下述代碼在 ESM 方案下是非法的:

js if(process.env.NODE_ENV === 'development'){ import bar from 'bar'; export const foo = 'foo'; }

所以,ESM 下模塊之間的依賴關係是高度確定的,與運行狀態無關,編譯工具只需要對 ESM 模塊做靜態分析,就可以從代碼字面量中推斷出哪些模塊值未曾被其它模塊使用,這是實現 Tree Shaking 技術的必要條件。

1.3 示例

對於下述代碼:

``` js // index.js import {bar} from './bar'; console.log(bar);

// bar.js export const bar = 'bar'; export const foo = 'foo'; ```

示例中,bar.js 模塊導出了 barfoo ,但只有 bar 導出值被其它模塊使用,經過 Tree Shaking 處理後,foo 變量會被視作無用代碼刪除。

二、實現原理

Webpack 中,Tree-shaking 的實現一是先標記出模塊導出值中哪些沒有被用過,二是使用 Terser 刪掉這些沒被用到的導出語句。標記過程大致可劃分為三個步驟:

  • Make 階段,收集模塊導出變量並記錄到模塊依賴關係圖 ModuleGraph 變量中
  • Seal 階段,遍歷 ModuleGraph 標記模塊導出變量有沒有被使用
  • 生成產物時,若變量沒有被其它模塊使用則刪除對應的導出語句

標記功能需要配置 optimization.usedExports = true 開啟

也就是説,標記的效果就是刪除沒有被其它模塊使用的導出語句,比如:

示例中,bar.js 模塊(左二)導出了兩個變量:barfoo,其中 foo 沒有被其它模塊用到,所以經過標記後,構建產物(右一)中 foo 變量對應的導出語句就被刪除了。作為對比,如果沒有啟動標記功能(optimization.usedExports = false 時),則變量無論有沒有被用到都會保留導出語句,如上圖右二的產物代碼所示。

注意,這個時候 foo 變量對應的代碼 const foo='foo' 都還保留完整,這是因為標記功能只會影響到模塊的導出語句,真正執行“Shaking”操作的是 Terser 插件。例如在上例中 foo 變量經過標記後,已經變成一段 Dead Code —— 不可能被執行到的代碼,這個時候只需要用 Terser 提供的 DCE 功能就可以刪除這一段定義語句,以此實現完整的 Tree Shaking 效果。

接下來我會展開標記過程的源碼,詳細講解 Webpack 5 中 Tree Shaking 的實現過程,對源碼不感興趣的同學可以直接跳到下一章。

2.1 收集模塊導出

首先,Webpack 需要弄清楚每個模塊分別有什麼導出值,這一過程發生在 make 階段,大體流程:

關於 Make 階段的更多説明,請參考前文 [萬字總結] 一文吃透 Webpack 核心原理

  1. 將模塊的所有 ESM 導出語句轉換為 Dependency 對象,並記錄到 module 對象的 dependencies 集合,轉換規則:

  2. 具名導出轉換為 HarmonyExportSpecifierDependency 對象

  3. default 導出轉換為 HarmonyExportExpressionDependency 對象

例如對於下面的模塊:

``` js export const bar = 'bar'; export const foo = 'foo';

export default 'foo-bar' ```

對應的dependencies 值為:

  1. 所有模塊都編譯完畢後,觸發 compilation.hooks.finishModules 鈎子,開始執行 FlagDependencyExportsPlugin 插件回調
  2. FlagDependencyExportsPlugin 插件從 entry 開始讀取 ModuleGraph 中存儲的模塊信息,遍歷所有 module 對象
  3. 遍歷 module 對象的 dependencies 數組,找到所有 HarmonyExportXXXDependency 類型的依賴對象,將其轉換為 ExportInfo 對象並記錄到 ModuleGraph 體系中

經過 FlagDependencyExportsPlugin 插件處理後,所有 ESM 風格的 export 語句都會記錄在 ModuleGraph 體系內,後續操作就可以從 ModuleGraph 中直接讀取出模塊的導出值。

參考資料: 1. [萬字總結] 一文吃透 Webpack 核心原理 2. 有點難的 webpack 知識點:Dependency Graph 深度解析

2.2 標記模塊導出

模塊導出信息收集完畢後,Webpack 需要標記出各個模塊的導出列表中,哪些導出值有被其它模塊用到,哪些沒有,這一過程發生在 Seal 階段,主流程:

  1. 觸發 compilation.hooks.optimizeDependencies 鈎子,開始執行 FlagDependencyUsagePlugin 插件邏輯
  2. FlagDependencyUsagePlugin 插件中,從 entry 開始逐步遍歷 ModuleGraph 存儲的所有 module 對象
  3. 遍歷 module 對象對應的 exportInfo 數組
  4. 為每一個 exportInfo 對象執行 compilation.getDependencyReferencedExports 方法,確定其對應的 dependency 對象有否被其它模塊使用
  5. 被任意模塊使用到的導出值,調用 exportInfo.setUsedConditionally 方法將其標記為已被使用。
  6. exportInfo.setUsedConditionally 內部修改 exportInfo._usedInRuntime 屬性,記錄該導出被如何使用
  7. 結束

上面是極度簡化過的版本,中間還存在非常多的分支邏輯與複雜的集合操作,我們抓住重點:標記模塊導出這一操作集中在 FlagDependencyUsagePlugin 插件中,執行結果最終會記錄在模塊導出語句對應的 exportInfo._usedInRuntime 字典中。

2.3 生成代碼

經過前面的收集與標記步驟後,Webpack 已經在 ModuleGraph 體系中清楚地記錄了每個模塊都導出了哪些值,每個導出值又沒那塊模塊所使用。接下來,Webpack 會根據導出值的使用情況生成不同的代碼,例如:

重點關注 bar.js 文件,同樣是導出值,barindex.js 模塊使用因此對應生成了 __webpack_require__.d 調用 "bar": ()=>(/* binding */ bar),作為對比 foo 則僅僅保留了定義語句,沒有在 chunk 中生成對應的 export。

關於 Webpack 產物的內容及 __webpack_require__.d 方法的含義,可參考 Webpack 原理系列六: 徹底理解 Webpack 運行時 一文。

這一段生成邏輯均由導出語句對應的 HarmonyExportXXXDependency 類實現,大體的流程:

  1. 打包階段,調用 HarmonyExportXXXDependency.Template.apply 方法生成代碼
  2. apply 方法內,讀取 ModuleGraph 中存儲的 exportsInfo 信息,判斷哪些導出值被使用,哪些未被使用
  3. 對已經被使用及未被使用的導出值,分別創建對應的 HarmonyExportInitFragment 對象,保存到 initFragments 數組
  4. 遍歷 initFragments 數組,生成最終結果

基本上,這一步的邏輯就是用前面收集好的 exportsInfo 對象未模塊的導出值分別生成導出語句。

2.4 刪除 Dead Code

經過前面幾步操作之後,模塊導出列表中未被使用的值都不會定義在 __webpack_exports__ 對象中,形成一段不可能被執行的 Dead Code 效果,如上例中的 foo 變量:

在此之後,將由 Terser、UglifyJS 等 DCE 工具“搖”掉這部分無效代碼,構成完整的 Tree Shaking 操作。

2.5 總結

綜上所述,Webpack 中 Tree Shaking 的實現分為如下步驟:

  • FlagDependencyExportsPlugin 插件中根據模塊的 dependencies 列表收集模塊導出值,並記錄到 ModuleGraph 體系的 exportsInfo
  • FlagDependencyUsagePlugin 插件中收集模塊的導出值的使用情況,並記錄到 exportInfo._usedInRuntime 集合中
  • HarmonyExportXXXDependency.Template.apply 方法中根據導出值的使用情況生成不同的導出語句
  • 使用 DCE 工具刪除 Dead Code,實現完整的樹搖效果

上述實現原理對背景知識要求較高,建議讀者同步配合以下文檔食用:

  1. [萬字總結] 一文吃透 Webpack 核心原理
  2. 有點難的 webpack 知識點:Dependency Graph 深度解析
  3. Webpack 原理系列六: 徹底理解 Webpack 運行時

三、最佳實踐

雖然 Webpack 自 2.x 開始就原生支持 Tree Shaking 功能,但受限於 JS 的動態特性與模塊的複雜性,直至最新的 5.0 版本依然沒有解決許多代碼副作用帶來的問題,使得優化效果並不如 Tree Shaking 原本設想的那麼完美,所以需要使用者有意識地優化代碼結構,或使用一些補丁技術幫助 Webpack 更精確地檢測無效代碼,完成 Tree Shaking 操作。

3.1 避免無意義的賦值

使用 Webpack 時,需要有意識規避一些不必要的賦值操作,觀察下面這段示例代碼:

示例中,index.js 模塊引用了 bar.js 模塊的 foo 並賦值給 f 變量,但後續並沒有繼續用到 foof 變量,這種場景下 bar.js 模塊導出的 foo 值實際上並沒有被使用,理應被刪除,但 Webpack 的 Tree Shaking 操作並沒有生效,產物中依然保留 foo 導出:

造成這一結果,淺層原因是 Webpack 的 Tree Shaking 邏輯停留在代碼靜態分析層面,只是淺顯地判斷:

  • 模塊導出變量是否被其它模塊引用
  • 引用模塊的主體代碼中有沒有出現這個變量

沒有進一步,從語義上分析模塊導出值是不是真的被有效使用。

更深層次的原因則是 JavaScript 的賦值語句並不,視具體場景有可能產生意料之外的副作用,例如:

``` js 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); ```

示例中,對 mock 對象施加的 Object.defineProperty 調用,導致 mock.f = foo 賦值語句對 count 變量產生了副作用,這種場景下即使用複雜的動態語義分析也很難在確保正確副作用的前提下,完美地 Shaking 掉所有無用的代碼枝葉。

因此,在使用 Webpack 時開發者需要有意識地規避這些無意義的重複賦值操作。

3.3 使用 #pure 標註純函數調用

與賦值語句類似,JavaScript 中的函數調用語句也可能產生副作用,因此默認情況下 Webpack 並不會對函數調用做 Tree Shaking 操作。不過,開發者可以在調用語句前添加 /*#__PURE__*/ 備註,明確告訴 Webpack 該次函數調用並不會對上下文環境產生副作用,例如:

示例中,foo('be retained') 調用沒有帶上 /*#__PURE__*/ 備註,代碼被保留;作為對比,foo('be removed') 帶上 Pure 聲明後則被 Tree Shaking 刪除。

3.3 禁止 Babel 轉譯模塊導入導出語句

Babel 是一個非常流行的 JavaScript 代碼轉換器,它能夠將高版本的 JS 代碼等價轉譯為兼容性更佳的低版本代碼,使得前端開發者能夠使用最新的語言特性開發出兼容舊版本瀏覽器的代碼。

但 Babel 提供的部分功能特性會致使 Tree Shaking 功能失效,例如 Babel 可以將 import/export 風格的 ESM 語句等價轉譯為 CommonJS 風格的模塊化語句,但該功能卻導致 Webpack 無法對轉譯後的模塊導入導出內容做靜態分析,示例:

示例使用 babel-loader 處理 *.js 文件,並設置 Babel 配置項 modules = 'commonjs',將模塊化方案從 ESM 轉譯到 CommonJS,導致轉譯代碼(右圖上一)沒有正確標記出未被使用的導出值 foo。作為對比,右圖 2 為 modules = false 時打包的結果,此時 foo 變量被正確標記為 Dead Code。

所以,在 Webpack 中使用 babel-loader 時,建議將 babel-preset-envmoduels 配置項設置為 false,關閉模塊導入導出語句的轉譯。

3.4 優化導出值的粒度

Tree Shaking 邏輯作用在 ESM 的 export 語句上,因此對於下面這種導出場景:

js export default { bar: 'bar', foo: 'foo' }

即使實際上只用到 default 導出值的其中一個屬性,整個 default 對象依然會被完整保留。所以實際開發中,應該儘量保持導出值顆粒度和原子性,上例代碼的優化版本:

``` js const bar = 'bar' const foo = 'foo'

export { bar, foo } ```

3.5 使用支持 Tree Shaking 的包

如果可以的話,應儘量使用支持 Tree Shaking 的 npm 包,例如:

  • 使用 lodash-es 替代 lodash ,或者使用 babel-plugin-lodash 實現類似效果

不過,並不是所有 npm 包都存在 Tree Shaking 的空間,諸如 React、Vue2 一類的框架原本已經對生產版本做了足夠極致的優化,此時業務代碼需要整個代碼包提供的完整功能,基本上不太需要進行 Tree Shaking。