為什麼還需要 Tree Shaking#

上一章節透過 Code Splitting 與動態載入(Dynamic Import)把 bundle 切成多塊、需要時才載入。但即使切開了,每一塊 chunk 自己仍可能塞了一堆「載進來但根本沒被用到」的程式碼。這個現象在引入第三方套件時最明顯——通常我們只用其中幾個 function,卻為了它們把整包套件都吞下去。

Tree Shaking 就是針對這個情境的優化技巧。

核心概念#

名字其實就點題了:「用力搖樹,沒附著住的果實會掉下來。」對應到打包過程,就是讓 bundler 在打包階段分析哪些程式碼或函式從未被使用,然後把它們從最終 bundle 中剔除。簡單說,就是確保最後 bundle 不會夾帶無用程式碼

Tree Shaking 與 Dead Code Elimination#

接觸過編譯器原理的讀者可能會想到 Dead Code Elimination(DCE,死碼刪除)。乍看兩者很像,但思路其實相反:

  • DCE:先把所有程式碼包進來,再去掉不會用到的部分。
  • Tree Shaking:一開始就只保留確定會用到的部分。

換個比喻,DCE 是把所有口味的車輪餅內餡混在一起,等顧客點口味再把不要的挑掉;Tree Shaking 則是依顧客的選擇直接準備需要的內餡。表面結果類似,過程完全不同。實際上,因為 JavaScript 語言本身有很多動態特性,靜態分析(Static Analysis)能達到的程度有限,兩種方式產生的 bundle 不會完全相同。

沒有 Tree Shaking 時會發生什麼#

用 ES5 的寫法寫一個簡單模組:

// calculate.js
exports.add_100 = function (x) {
  return x + 100;
};

exports.add_500 = function (x) {
  return x + 500;
};
// index.js
import { add_100 } from "./calculate";

add_100(10);

打包後會發現連沒被使用的 add_500 也照樣被塞進 bundle 裡。原因在於 CommonJS 的 module export 是透過 exports 物件的屬性,而 JavaScript 物件本身存取屬性的方式太靈活:

const testObj = { ironman: "kyle mo" };

testObj["iron" + "man"];
// 'kyle mo'

const a = "ir";
const b = "on";
const c = "man";

testObj[a + b + c];
// 'kyle mo'

bundler 沒辦法 100% 確定 exports 上某個屬性會不會被以動態方式存取。為了避免移除掉其實會用到的程式碼造成 runtime error,最保險的選擇就是全部保留。

Tree Shaking 為什麼可行#

要做到 Tree Shaking,幾乎完全仰賴 ESM (ECMAScript Modules) 的 import / export 系統。

ESM 在語言層級規定了一些限制:

  • import / export 只能出現在模組頂層(dynamic import 不在此限)。
  • 模組路徑必須是字串字面值,不能是動態變數。
  • import 綁定(binding)是不可變的。

這些限制讓 ESM 的依賴關係在編譯期完全可確定,bundler 因此能可靠地做靜態分析。靜態分析指的是不執行程式碼,僅從字面結構推論依賴關係。CommonJS 因為要跑起來才知道引用了什麼,所以無法享受同等待遇。

實作上的注意事項#

不要讓 Babel 把 ESM 轉成 CommonJS#

Babel 預設會把 import / export 轉成 CommonJS 的形式,這會直接讓 Tree Shaking 失效。需要把對應的 preset 設定關掉,例如 @babel/preset-envmodules 設為 false,讓 ESM 結構保留到 bundler 那一步。

盡量讓 export 保持原子化#

以下幾種寫法應該避免:

  • export 一個含有大量屬性與方法的物件。
  • export default 裡塞進一大坨內容。
  • export 一個含有多個方法的類別。

它們的共通問題是「綁在一起」——bundler 只能整包納入或整包剔除,難以做粒度更細的篩選。比較理想的寫法是逐個 named export:

// 不建議
export default {
  add_100(x) {
    return x + 100;
  },
  add_500(x) {
    return x + 500;
  },
};

// 建議
export function add_100(x) {
  return x + 100;
}

export function add_500(x) {
  return x + 500;
}

避開 Module Level 的副作用#

副作用(Side Effects)指的是函式或模組除了回傳值以外,還對外部世界造成影響的行為,例如修改全域變數、寫入 DOM、發 HTTP request 等。當 bundler 在分析模組時,只要懷疑某段程式碼有副作用,它就傾向保守地把它留下來。

來看一個容易踩雷的例子:

function add_100(x) {
  return x + 100;
}

// 等同於 window.memorize
export const memorized_add_100 = memorize(add_100);

這段程式碼在模組載入的當下就呼叫了全域的 memorize。對 bundler 來說,它無法確定 memorize 內部會不會做出帶有副作用的行為,因此即便 memorized_add_100 從未被使用,bundler 仍會保險地把 add_100 留下來。

修正方式是把 memorize 改為從另一個明確 export 的模組引入,讓 bundler 能追溯它的實作:

// utils.js
export const memorize = () => {
  // ... memorize implementation
};

// other file
import { memorize } from "./utils";

function add_100(x) {
  return x + 100;
}

export const memorized_add_100 = memorize(add_100);

當 bundler 知道 memorize 是 pure function 時,整段就可以放心做 Tree Shaking。

為什麼 Dynamic Import 不能 Tree Shaking#

Dynamic Import 因為是動態決定要不要載入,bundler 無法在靜態分析時確認模組是否會被使用:

if (isActive) {
  import('./someModule').then(module => /* ... */);
}

isActive 是個 runtime 才能得知的值,bundler 為了保險起見,不會對 someModule 做 Tree Shaking。

第三方套件的引入要謹慎#

選用第三方套件時要特別注意它的引入方式是否支援 Tree Shaking。經典案例是 lodash:

import { flatten } from "lodash";

const flattenArray = flatten([1, [2, [3, [4]], 5]]);

只用了一個 flatten,bundle 卻肥到不行。原因就是 lodash 的 CommonJS 版本不支援 Tree Shaking。換成官方推薦的 ESM 版本 lodash-es,bundle size 立刻有顯著縮減:

import { flatten } from "lodash-es";

引入第三方套件時,記得確認它是否提供 ESM 版本、是否支援 Tree Shaking。一個小小的引入路徑切換可能就讓 bundle size 差出十倍。

自己開發套件時要做哪些事#

如果你要釋出一個對外的 npm package,並希望它支援 Tree Shaking,重點都在 package.json 的設定。

sideEffects 欄位#

{
  "sideEffects": false
}

設為 false 等於告訴 bundler「這個 package 完全沒有副作用,請放心搖」。如果某些檔案確實會帶副作用(例如某些初始化邏輯、CSS 引入),可以列舉出來:

{
  "sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}

CSS 檔的 import 是沒有具體使用點的,常會被誤以為無副作用而被搖掉。把 *.css 加進 sideEffects 陣列才能避免它在 production build 中消失。

main 與 module 欄位#

{
  "main": "./legacy.cjs",
  "module": "./modern.js"
}

main 指定預設的入口(通常是相容性較佳的版本),module 則指定 ESM 版本的入口。bundler 會優先採用 module 對應的 ESM 版本以啟用 Tree Shaking;不支援時再退回 main

一個小總結#

Tree Shaking 是 ESM 帶來的重要紅利。要讓它真的生效,需要在 Babel 設定、export 寫法、第三方套件選擇、套件 package.json 等多個層面同時注意。一個 sideEffects 設錯、一個 export 寫法太「結團」,都可能讓 Tree Shaking 形同虛設。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10274978