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

範文傑發表於2021-10-20

一、什麼是 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 陣列

例如:

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

1.2 理論基礎

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

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

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

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

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

1.3 示例

對於下述程式碼:

// 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 集合,轉換規則:
  • 具名匯出轉換為 HarmonyExportSpecifierDependency 物件
  • default 匯出轉換為 HarmonyExportExpressionDependency 物件

例如對於下面的模組:

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 的賦值語句並不,視具體場景有可能產生意料之外的副作用,例如:

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 語句上,因此對於下面這種匯出場景:

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

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

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。

相關文章