iT邦幫忙

2021 iThome 鐵人賽

DAY 15
3

在昨天我們學會了 code splitting 與 dynamic import 的技巧,讓程式在打包時可以形成好幾個 bundle chunks,並在真的需要使用時才載入對應的 chunks。這些方法確實讓效能與載入時間優化蠻多的,不過你有沒有想過,開發專案免不了會需要下載第三方套件來節省自己重複造輪子的成本,然而也許某些狀況我們只會使用一個套件模組之中的特定幾個 function,其他的 function 幾乎都不會用到。不過如果我們為了這幾個 function 而要載入整個模組,就似乎有點得不償失了,這時候 Tree shaking 就會是我們的救星了。

什麼是 Tree Shaking ?

其實這個技巧跟字面上的意思很像,當用力搖一棵樹時可能會把很笨重的果實給搖落,在程式面來說就是把「用不到的程式碼給搖落下來」,上面的例子講到我們可能會為了幾個特定函式而需要載入整個套件,運用 Tree Shaking 之後,可以讓打包工具在打包階段就可以分析哪些 code 或哪些 function 是用不到的,而把它們從最終的 bundle 中剔除,換句話說就是確保最後的 bundle 不會包含無用或多餘的程式碼與資源,減少 bundle size。

Tree Shaking VS Dead Code Elimination

如果你有接觸過編譯器的原理,應該會聽過死碼刪除(dead code elimination)這個技巧,看起來兩者是一樣的東西,不過其實本質上是不同的。

死碼刪除指在完成專案後把不想用到的程式碼移除掉。不過仔細想想在完成專案後再去掉沒用的程式碼似乎是一件沒那麼有效率的事,用生活化的例子來舉例就好像要做車輪餅時把奶油、紅豆、芋頭等餡料都混在一起,等到顧客指定要哪種口味時再把其他不要的口味挑掉。(嗯...越想越覺得有點噁心...)

而 Tree Shaking,相較於去掉不必要的 code,思考方向比較像是:「只保留確定會用到的程式。」以車輪餅的例子來說,就是一開始就只加入顧客要的口味。對比 Dead Code Elimination,Tree Shaking 的實作比較像是 Live Code Inclusion。

雖說看起來兩種方式最終會達到一樣的結果,不過實際上由於 JavaScript 這個語言本身太過動態,在靜態分析(Static Analysis)上會有一些限制,所以兩種方式的結果並不會相同。

先看看沒有 Tree Shaking 的狀況

用 ES5 的寫法寫一個簡單的範例

// calculate.js

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

exports.add_500 = function (x) {
  return x + 500;
};

不過卻只使用到 add_100 這個 function

// index.js

import { add_100 } from './calculate';

add_100(10);

看看 webpack 打包後的 bundle 長什麼樣子

/***/ "./src/calculate.js":
/*!**************************!*\
  !*** ./src/calculate.js ***!
  \**************************/
/***/ ((__unused_webpack_module, exports) => {

eval("exports.add_100 = function (x) {\n  return x + 100;\n};\n\nexports.add_500 = function (x) {\n  return x + 500;\n};\n\n//# sourceURL=webpack://tree-shaking-demo/./src/calculate.js?");

可以看到沒有被使用到的 add_500 還是被加到 bundle 裡面了,當面對的是複雜的專案的時候,很多用不到的程式被加到 bundle 裡多少還是會對效能產生一些影響。

會產生這種結果的原因是在 CommonJS 規範中,如果要把 module export 出去給其他 module 使用,得透過 exports 這個 object 的 properties 的形式做輸出,例如剛剛的

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

稍微對 JavaScript 熟悉一點的讀者應該知道 JS 的 Object 其實坑很多,很多意想不到的存取屬性的方法,例如:

let 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 無法肯定這些 exports object 上的屬性到底會不會被呼叫到,而在不清楚的狀況下,直接做 tree shaking 可能會導致 runtime error,所以最保險的方式就是全部都打包到 bundle 裡面。

Tree Shaking 是怎麼做到的?

能做到 Tree Shaking,主要得歸功於 ES6 import export module system 的幫助。

首先來看看 ES6 module 的一些特性:

  • 只能在 module 頂層的語句出現(dynamic import 不算在內)
  • import 的 module name 不能是動態的
  • import binding 是 immutable 的

這些特性讓 ES6 module 間的依賴關係是固定的,可以進行可靠的靜態分析,是實現 Tree Shaking 的基礎。

所謂靜態分析就是不執行代碼,單從字面上對程式碼進行分析。在 ES6 以前使用 CommonJS 就只有執行後才知道引用了什麼模組,這種方式就不能通過靜態分析去做優化。

使用 Tree Shaking 的一些 Tips

使用 bebel 時不要 transpile 成 CommonJS

在開發時通常會利用 bebel 這樣的轉譯工具將語法轉換成瀏覽器看得懂的格式,不過 bebel 預設是會將 import 與 export 轉譯成 CommonJS 的,這麼做會使 Tree Shaking 失效。需要調整一些 config 讓預設的行為被 disabled 掉。

盡量讓 exports 的模組保持原子性

以下三種寫法是應該避免的:

  • export 一個擁有許多屬性與方法的物件
  • 在 export default 一次加入許多東西
  • export 一個有許多屬性與方法的類別

透過這幾種方式 export 的 code 要不是全部被算進 bundle 中,就是全部一起被 Tree Shaken 掉,所以在使用上要特別注意,盡量保持輸出的原子性,不然一不小心可能就在 bundle 裡加了一些不會用到的 code。

// 比較不好的寫法
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 Effect

如果不知道 Side Effect 是什麼的讀者,可以先參考這篇文章

許多人在撰寫模組時會忽略 module scope side effect 帶來的影響,什麼意思呢?請看範例:

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

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

上面這段 code 的意思是呼叫一個全域的叫做 memorize 的 higher order function(等同於 window.memorize),並把宣告得 add_100 傳進去,得到一個 memorized 版本的 add_100。

當這個模組被引入時,window.memorize 就會被呼叫,那打包工具例如 webpack 是怎麼分析這個模組的呢?讓我們以打包工具的角度來分析看看:

  1. 發現模組中宣告了一個叫做 add_100 的函式,看起來是一個 pure function (同樣輸入都可以獲得相同輸出),如果之後都沒人用到它,我應該可以對它做 Tree Shaking,把它從 bundle 中移除掉。
  2. 呼叫 window.memorize,並把 add_100 當作參數傳進去
  3. 打包工具沒辦法分析出 window.memorize 會做什麼事,不排除它有呼叫 add_100 並產生 side effect 的可能性
  4. 為了安全起見,不要讓程式壞掉,就算沒看到有哪邊有用到 memorized_add_100,還是先把 add_100 加到 bundle 裡。

這時你生氣了:「可是我知道 window.memorize 不會觸發任何 side effect 啊!也只有 memorized_add_100 被呼叫的時候才會真的去跑 add_100,因為那可是我寫的呢!」

但是,打包工具不是你,它並不知道啊!

所以,我們得利用 ES6 的 import export 特性,給打包工具多一點資訊。

// utils.js

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

// other file
import { memorize } from './utils'

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

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

現在打包工具就有足夠的資訊可以分析到底會不會產生 side effect 了

  1. 發現模組中宣告了一個叫做 add_100 的函式,看起來是一個 pure function (同樣輸入都可以獲得相同輸出),如果之後都沒人用到它,我應該可以對它做 Tree Shaking,把它從 bundle 中移除掉。
  2. 呼叫 memorize,並把 add_100 當作參數傳進去,不知道他會不會產生 side effect,不過看起來它是從其他檔案引入進來的,到那裡(utils.js)看看,說不定會有什麼發現。
  3. 看起來這個 memorize function 是個 pure function 呢,應該不用擔心會產生 side effect 了。
  4. 如果沒看到其他地方有用到 add_100,就放心把它從 bundle 中移除掉吧!

所以說,想要打包工具做到 Tree Shaking,必須給它關於模組足夠的資訊,在無法判斷的條件下,它會選擇最保險的作法,也就是都加到 bundle 裡。

為什麼 Dynamic Import 不能 Tree Shaking ?

因為打包工具不知道這個 module 到底會不會被載入,例如說

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

以 bundler 的角度來說,isActive 這個 boolean 很可能會動態切換,所以無法在靜態分析時就確定這個模組會不會被載入,為了保險起見,它就不會做 Tree Shaking。

曾經也有人在 webpack 的 github repo 詢問過為什麼 Dynamic Import 不能做 Tree Shaking,webpack 的維護者也親自出來回覆


使用第三方套件時要小心謹慎

在開頭時有提過,通常載入第三方模組是為了減少重複造輪子,可以直接使用現成的功能,不過有些時候我們不會需要模組中全部的功能,這時候如果還把整包套件打包進最後的 bundle 就有點得不償失了。

我們用實際的套件來舉例,lodash 是一個非常熱門的第三方套件函式庫,它提供了超級多關於資料操作的 utility function,假設今天我們要使用它提供的其中一個叫做 flatten 的 util function 來將多層級陣列攤平一個層級深度,效果如下

_.flatten([1, [2, [3, [4]], 5]]);
// => [1, 2, [3, [4]], 5]

我們試著在專案中引入它,再來觀察一下 webpack bundle analyzer 的狀態

import { flatten } from 'lodash';

const App = () => {
  const flattenArray = flatten([1, [2, [3, [4]], 5]]);
  console.log(flattenArray);
    
  return <div>lodash test</div>
}

騙人的吧...有夠肥的,我才用了一個簡單的 function 耶!
我合理懷疑這個 flatten 不是普通的 flatten,一定是百年難得一見的絕世函式,擁有鋼筋鐵骨,才會肥成這樣 ?

換成 ES module 版本的 lodash-es

這次換成官方建議的 ES module 版本的 lodash-es 試試看

npm i lodash-es
// import { flatten } from 'lodash';
import { flatten } from 'lodash-es';

const App = () => {
  const flattenArray = flatten([1, [2, [3, [4]], 5]]);
  console.log(flattenArray);
    
  return <div>lodash test</div>
}

bundle size 變小到差點找不到它,不過這樣才是合理的嘛!

所以說在使用第三方套件時,可以多留意一下是不是有支援 Tree Shaking 的功能,是不是有不同的引入方式或是提供另一種版本的套件,也許小小的改變卻能大大改變應用的 bundle size 喔!

如何實作一個具有 Tree Shaking 功能的 npm module

剛剛看完使用第三方套件的狀況,那如果自己要開發一個套件,要怎麼支援 Tree Shaking 功能呢?

最關鍵的當然還是使用 ES6 的 module system。此外,還需要配合使用壓縮工具例如 UglifyJS,再加上一些額外設定才能把用不到的程式從 bundle 中移除。

package.json 的設定

Module Bundler 會優先透過 package.json 來判斷這個 module 有沒有支援 Tree Shaking。在 package.json 主要有兩個部分需要做設定:

side-effect

// package.json

{
  "sideEffects": false
}

// or


{
  "sideEffects": [
    "dist/*",
    "es/components/**/style/*",
    "lib/components/**/style/*",
    "*.less"
  ]
}

這主要是給 bundler 的一些提示,如果給 false 代表告訴 bundler 這個 modules 是沒有 side effects 的,如果發現沒有用到的模組可以勇敢的做 Tree Shaking。

如果你知道模組中的一些檔案會產生 side effects,就可以使用第二種方式把會產生 side effects 的檔案放到陣列裡。這麼做的話就只有引入 side effects 陣列「之外」的檔案時,才會做 Tree Shaking。

不過 side effects 也有些需要注意的 edge case,一個有趣的例子是使用 css loader 載入 CSS,用法可能是這樣:

import './index.css'

...
...

不過因為它只有做到引入,但是並沒有在其他地方被直接使用,所以會被 bundler Tree Shaken 掉,所以得把它加到 side effects list 裡:

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

才不會不小心把 CSS 在 production mode 中移除掉。

module

我們通常會在 main 這個 config 中指定程式的入口檔案,例如

// package.json
{
  "main": "src/index.js",
}

現在可以改成多透過一個 module config 指定 ES6 版本的入口

{
  "main": "src/index.js",
  "module": "es/index.js",
}

bundler 會優先透過 module 和 sideEffects 這兩個屬性指定的路徑來引入這個模組的 ES6 版本,並做 Tree Shaking,如果發現 ES6 版本不能用,則會回到預設選項,也就是 main 屬性指定的比較舊且不支援 Tree Shaking 的版本。

啊...那為什麼不直接都用 ES6 版本的程式碼就好啊?

: 有些 package 是可以在瀏覽器也可以在 Node.js 中執行的,例如 Rxjs、Lodash 等套件,在 Node.js 環境下 ES6 就不太適合了。(這個問題要看 Node.js 的版本,新一點的版本就有直接支援 ES6 了。不過程式撰寫的方式也是一個原因,目前 Node.js 在開發上還是以 require 的語法為主)

本日小結

我發現每天的小結都是總結今天介紹了什麼好像有點無聊啊...不如來點精神喊話吧...。

終於寫完一半了啊!!!!

我每天都寫到半夜,真的是快要累死了。不過心裡還是很滿足的,這次不管有沒有得獎都不重要,重要的是我把想寫的東西都寫出來了,每年熱血這一次應該很剛好吧!?也謝謝一直關注系列文的朋友,我知道我的文章篇幅以鐵人賽來說有點長,所以耐心觀看完的你絕對也值得一個掌聲!希望這 15 天過去了,能夠讓你們有點收穫,剩下的一半旅程繼續多多指教囉!

明天,將介紹 Polyfill-less Bundling Script 與 File Compression,see u tomorrow ?

References

https://medium.com/@Rich_Harris/tree-shaking-versus-dead-code-elimination-d3765df85c80
https://loveky.github.io/2018/02/26/tree-shaking-and-pkg.module/
https://medium.com/starbugs/the-correct-way-to-import-lodash-libraries-bdf613235927
https://bluepnume.medium.com/javascript-tree-shaking-like-a-pro-7bf96e139eb7


上一篇
Day014 X Code Splitting & Dynamic Import
下一篇
Day16 X Polyfill-less Bundling Script & File Compression
系列文
今晚,我想來點 Web 前端效能優化大補帖!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言