Tree Shaking 具體做了什麼?

語言: CN / TW / HK

前言

Javascript 絕大多數情況需要通過網路進行載入再執行,載入的檔案越小,整體執行時間更短,所以就有了 Tree Shaking 去除無用程式碼,從而減小檔案體積。

什麼是 Tree Shaking

Tree-shaking(搖樹) 是一個術語,通常指通過打包工具"搖"我們的程式碼,將未引用程式碼 (Dead Code) "搖" 掉。在 Webpack 專案中,有一個入口檔案,相當於一棵樹的主幹,入口檔案有很多依賴的模組,相當於樹枝,雖然依賴了某些模組,但其實只使用其中的某些方法,通過 Tree Shaking,將沒有使用的方法搖掉,這樣來達到刪除無用程式碼的目的。

Tree Shaking 具體做了什麼

我們通過例子來詳細瞭解一下 Webpack 中 Tree Shaking 到底做了什麼

  • 未使用的函式消除

// utils.js
export function sum(x, y) {
return x + y;
}

export function sub(x, y) {
return x - y;
}
// index.js
import { sum } from "./utils";
// import * as math from "./utils";

console.log(sum(1, 2));

我們在 utils 中定義了 sum 與 sub 兩個方法, 僅使用了 sum 方法,而 sub 方法並沒有被使用。我們一起看一下打包後的結果

(()=>{"use strict";console.log(3)})();
  • 未使用的 JSON 資料消除

// main.json
{
"a": "a",
"b": "b"
}
// index.js
import main from "./main.json";

console.log(main.a);

可以看到僅使用了 JSON 中的 a。我們一起看一下打包後的結果

(()=>{"use strict";console.log("a")})();

Tree Shaking 的原理

Tree Shaking 在去除程式碼冗餘的過程中,程式會從入口檔案出發,掃描所有的模組依賴,以及模組的子依賴,然後將它們連結起來形成一個 “抽象語法樹” (AST)。隨後,執行所有程式碼,檢視哪些程式碼是用到過的,做好標記。最後,再將“抽象語法樹”中沒有用到的程式碼“搖落”。經歷這樣一個過程後,就去除了沒有用到的程式碼。

前提是模組必須採用 ES6 Module 語法,因為 Tree Shaking 依賴 ES6 的靜態語法:import 和 export。不同於 ES6 Module,CommonJS 支援動態載入模組,在載入前是無法確定模組是否有被呼叫,所以並不支援 Tree Shaking 。如果專案中使用了 babel 的話, @babel/preset-env 預設將模組轉換成 CommonJs 語法,因此需要設定 module:false

CommonJS 與 ES6 Module 模組的依賴的區別在於,CommonJS 是 動態的 ,ES6 Module 是 靜態的

CommonJS 匯入時, require 的路徑引數是支援表示式的,路徑在程式碼執行時是可以動態改變的,所以如果在程式碼編譯階段就建立各個模組的依賴關係,那麼一定是不準確的,只有在程式碼運行了以後,才可以真正確認模組的依賴關係,因此說 CommonJS 是動態的。

ES6 模組不是物件,它的對外介面只是一種靜態定義,在程式碼編譯,靜態解析階段就會生成,這樣我們就可以使用各種工具對 JS 模組進行依賴分析,優化程式碼。

Development 模式下

// webpack.config.js

module.exports = {
// mode: "production",
mode: "development",
devtool: false,
optimization: {
usedExports: true, // 目的使未被使用的 export 被標記出來
},
};

打包後的 bundle.js

/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "sum": () => (/* binding */ sum)
/* harmony export */ });
/* unused harmony export sub */
function sum(x, y) {
return x + y;
}

function sub(x, y) {
return x - y;
}

1、可以看到未被使用的 sub 會被標記為 /* unused harmony export sub */ ,不會被 __webpack_require__.d 進行 exports 繫結;

關於 __webpack_require__.d 的含義,可參考 [深入瞭解 webpack 模組載入原理] https://segmentfault.com/a/1190000024457777  一文。

2、經過壓縮工具(UglifyJSPlugin)壓縮後,未使用的介面程式碼會被刪除。原理顯而易見,未被 __webpack_require__.d 引用,所以壓縮工具可以將其安全移除。

Production 模式下

由前面的例子可以看出 dist/bundle.js 中整個 bundle 都已經被 壓縮工具 壓縮和混淆破壞,但是如果仔細觀察,則不會看到引 sub 函式,但能看到 sum 函式的混淆破壞版本( function r(e){return e*e*e}n.a=r )。

再看一下兩次打包的檔案體積會發現,bundle 的體積明顯減少了。

Tree Shaking 和 sideEffects

提到 Tree Shaking 就要聊一下 sideEffects。什麼是 sideEffects ,sideEffects 又是與 Tree Shaking 如何搭配使用的?

sideEffect(副作用) 的定義是,在匯入時會執行特殊行為的程式碼,而不是僅僅暴露一個 export 或多個 export。

webpack v4 開始新增了一個 sideEffects 特性,通過給 package.json 加入 sideEffects: false 宣告該包模組是否包含副作用,從而可以為 Tree Shaking 提供更大的優化空間。

舉例說明

// a.js
// 無副作用,僅僅是單純的 export
function a () {
console.log('a')
}
export default {
a
}
// b.js
function b () {
console.log('b')
}

// 執行了特殊行為
Array.prototype.fun = () => {}

export default {
b
}

如果 a 在 import 後未使用,Tree Shaking 完全可以將其優化掉;但是 b 在 import 後未使用,但因為存在他還執行了為陣列原型添加了方法,副作用還是會被保留下來。這時就需要使用 sideEffects: false ,可以強制標識該包模組不存在副作用,那麼不管它是否真的有副作用,只要它沒有被引用到,整個 模組/包 都會被完整的移除。

如果你的專案中存在一些副作用程式碼 b 需要被保留下來,比如 polyfill、css、scss、less 等,可以按下面方法一樣配置;保證必要的程式碼不被 Tree Shaking

// package.json
{
"name": "your-project",
"sideEffects": ["./src/b.js", "*.css"]
}

總結

通過以上講解,使 Webpack 更精確地檢測無效程式碼,完成 Tree Shaking 操作,需要符合以下條件:

  • 使用 ES6 Module 語法(即 importexport )。
  • 確保沒有 @babel/preset-env 等工具將 ES6Module 語法轉換為 CommonJS 模組。
  • optimization: { minimize: true, usedExports: true }
  • 使用支援 Tree Shaking 的包。

參考連結

Tree shaking 原理及應用 (https://segmentfault.com/a/1190000040037144)

Tree Shaking (https://webpack.docschina.org/guides/tree-shaking/)