avatar
instagramthreads

什麼是 tree-shaking?

tree-shaking banner

前言

這陣子剛好工作上碰到相關議題,興趣使然也想深入了解一下,確保專案在 tree-shaking 的各種眉角上設定完,是能正確有效的增進效能,因此把筆記整理出來讓未來有同樣需求的人有跡可循。

這篇文章將由淺入深地探討 tree-shaking,從基本的 Why、What、How 介紹,並透過實例 demo 來理解這些不太好懂的設定們。

另外本文會用 Webpack、Babel 設定來講解原理與注意事項,因為當你經手的是以前使用 create-react-appVue CLI 或更近代的 create-next-app 來建立 repo 的專案,預設都仍會使用 Webpack。

而關於 bundler 相關生態圈 (包含 transpiler、minifier 等) 的愛恨糾葛效能大比拼,如 Vite/Rollup、rspack、Turbopack、SWC、esbuild、Bun.build 等,因為不完全是 tree-shaking 相關,可能之後有機會再用另一篇文章來說明。

什麼是 tree-shaking?

參考 Webpack 官方文件形容,你可以想像你整個專案的程式碼是一棵依賴樹,而那些你實際有用到的程式碼與套件就像樹上綠色的葉子,沒用到的部分則是枯黃的葉子,為了要在最後打包程式碼時,將整個專案中沒用到的部分被去除掉,你需要去搖動這棵樹。

Tree-shaking 最早的起源來自 Rollup 的其中一個特性,是為了在 JavaScript 模組的打包過程中,讓最後的輸出檔案中,只包含實際用到的程式內容。

此概念其實與編譯器原理中的死碼刪除 (Dead Code Elimination, DCE) 有些類似,因此當年社群有人抱怨為何 Rollup 還要重新定義 DCE。

Rollup 作者 Rich Harris 在 2015 的文章中提到,儘管 tree-shaking 與 DCE 都是為了得到「更少的程式碼」,但其實是不一樣的概念。

JavaScript 的限制

在理解為什麼 tree-shaking 與 DCE 不是同一件事前,要先知道一個 JavaScript 的特性。程式語言分為動態語言、靜態語言,而 JavaScript 屬於動態語言。

動態語言最大的優點是語法簡潔、有較高的彈性,諸如變數型別、物件屬性等都可以在程式執行時被改變。但這也是一把雙面刃,缺點就是容易遇到 runtime error 問題,以及較無法被靜態分析。

而這個靜態分析,也就成了 JavaSciprt 打包工具的一個難題:「既然我無法確切知道這些程式在執行後會不會被用上,我又要怎麼放心的刪除它呢?」

Tree-shaking vs DCE

想像你今天要做蛋糕,DCE 的做法是「把所有雞蛋都丟進攪拌碗裡一起搗碎後,拿去烤箱烤完再去除蛋殼碎片。」

而 tree-shaking 的思維是「如果我今天想做蛋糕,我需要在攪拌碗中加入哪些成分?」

從上述例子中,可以知道 DCE 的做法是將成品中不需要的內容刪掉,而 tree-shaking 指的是從一開始就只加入需要的部分。

前面提到 JavaScript 不太好被靜態分析的限制,因此 DCE 這個策略是不可行的,也因此才需要 tree-shaking 的這種做法:「從源頭判斷哪些模組沒被用到可以被移除」。

要怎麼做到 tree-shaking?

Tree-shaking 的原理是透過靜態分析的方式,在打包前從程式碼進入點開始去分析整顆依賴樹,每個模組是否有被使用到需要打包進來。

這件事在早期仍在使用 CommonJS (CJS)、AMD、UMD 等程式中是做不到的,因為這些模組化寫法都允許自由奔放的動態載入。

一直到後來 ES2015 module (ESM) 出現後,提供了靜態的 import / export 寫法,讓打包工具可以在編譯時就明確知道導入、導出內容,才有辦法做到 tree-shaking。

Tree-shaking 的實例 Demo

Talk is cheap,我們先來直接看個例子體驗一下什麼是 tree-shaking。

假設我們在 utils.js 中宣告了 2 個 function,addmultiply

// ./src/utils.js
console.log('[add] declared here!');
const add = (a, b) => {
  return a + b;
};

console.log('[multiply] declared here!');
const multiply = (a, b) => {
  return a * b;
};

export { add, multiply };

另外在 index.js 中去引入其中的 add 來使用:

// ./src/index.js
import { add } from './utils';

const init = (a, b) => {
  return add(a, b) / add(b, a);
};

console.log(init(5, 6));

讓我們看看執行 Webpack 打包後的結果,為了方便觀察行為,我們先使用 mode: development 打包,以下為其中一段較關鍵的輸出:

/******/ (() => { // webpackBootstrap
/******/ 	"use strict";
/******/ 	var __webpack_modules__ = ({

/***/ "./src/utils.js":
/*!**********************!*\
  !*** ./src/utils.js ***!
  \**********************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   add: () => (/* binding */ add)
/* harmony export */ });
/* unused harmony export multiply */
// utils.js
console.log("[add] declared here!");
var add = function add(a, b) {
  return a + b;
};
console.log("[multiply] declared here!");
var multiply = function multiply(a, b) {
  return a * b;
};

可以注意到這裡有兩個關鍵字:

  • /* harmony export */
  • /* unused harmony export */

Webpack 的 module parser,在打包過程會有以下步驟:

  • 靜態分析:對整個 ESM 依賴樹的 import、export 去分析哪些被 export 的部分並沒有被使用。
  • 辨識標記:將沒被使用的 ESM 做 unused harmony export 標記,但還不會實際去刪除,而是會留到後續交給 Minifier 去刪除。

接著如果再試著執行 mode: production 打包後的全部輸出,為方便解析套了 formatter 做排版:

(() => {
  'use strict';
  console.log('[add] declared here!');
  var e = function (e, l) {
    return e + l;
  };
  console.log('[multiply] declared here!'), console.log(e(5, 6) / e(6, 5));
})();

可以看到 multiply 函式因為沒被使用而有成功被 tree-shake 掉。

實現 tree-shaking 需要注意哪些事

實際看完 tree-shaking 的效果後,接著就要來了解如果要做到 tree-shaking 需要注意哪些眉角。

根據 Webpack 官方文件結論及實驗結果,要做到 tree-shaking 的話有以下幾點是必備的:

  • 使用 ESM 語法
  • 確保 transpiler 如 Babel 的設定中,沒有把你專案中的 ESM 轉成 CommonJS (CJS)、AMD、UMD 模組
  • 使用 mode: production 啟動預設各種優化 plugins 與 optimization 選項,包含 minification 與 tree-shaking。

也因為 tree-shaking 在進行 production build 時預設就有開啟,也就是說當你前兩點都滿足的話其實不用特別設定什麼。

另外也有一些可以自定義的選項以及注意事項:

  • sideEffects 選項加到專案的 package.json
  • 注意 Webpack tree-shaking 不支援 dynamic import(參考官方 issue
  • 確認 Webpack config 中的 devtool 設定是否正確設定,有些模式在 production mode 下不適用
    • devtool 是指定產生 source map 的方式
    • 通常在 production mode 會用 SourceMapDevToolPlugin 來做更多進階設定
  • import / export 方式對 tree-shaking 的影 響

References

參考資料及延伸閱讀統一放在最後一篇中。