提取程式碼中使用到的部分,從而最小化 bundle 的大小,並避免不必要的載入。
學習了 code splitting 與 dynamic import 技巧,可將程式打包成多個 bundle chunks,並在需要時才載入對應的 chunks,從而優化效能與載入時間。
在開發專案中我們常需下載第三方套件以節省重複開發成本,但有時只需要套件模組中的特定幾個 function,而其他 function 幾乎不會使用。
所以如果載入整個套件模組來取得少數 function,會造成不必要的資源浪費。
Tree shaking 是解決此問題的方法,能夠精確地提取程式碼中使用到的部分,從而最小化 bundle 的大小,並避免不必要的載入。
在打包階段就可以分析哪些 code 或哪些 function 是用不到的,而把它們從最終的 bundle 中剔除。確保最後的 bundle 不會包含無用或多餘的程式碼與資源,減少 bundle size。
其實這個技巧跟字面上的意思很像,當用力搖一棵樹時可能會把很笨重的果實給搖落,在程式面來說就是把「用不到的程式碼給搖落下來」,上面的例子講到我們可能會為了幾個特定函式而需要載入整個套件,運用 Tree Shaking 之後,可以讓打包工具在打包階段就可以分析哪些 code 或哪些 function 是用不到的,而把它們從最終的 bundle 中剔除,換句話說就是確保最後的 bundle 不會包含無用或多餘的程式碼與資源,減少 bundle size。
用 ES5 的寫法寫一個簡單的範例:
不過卻只使用到 add_100 這個 function:
看看 webpack 打包後的 bundle 長什麼樣子:
可以看到沒有被使用到的 add_500
還是被加到 bundle 裡面了,當面對的是複雜的專案的時候,很多用不到的程式被加到 bundle 裡多少還是會對效能產生一些影響。
會產生這種結果的原因是在 CommonJS 規範中,如果要把 module export 出去給其他 module 使用,得透過 exports
這個 object 的 properties 的形式做輸出,例如剛剛的
稍微對 JavaScript 熟悉一點的讀者應該知道 JS 的 Object 其實坑很多,很多意想不到的存取屬性的方法,例如:
因為這種特性,bundler 無法肯定這些 exports object 上的屬性到底會不會被呼叫到,而在不清楚的狀況下,直接做 tree shaking 可能會導致 runtime error,所以最保險的方式就是全部都打包到 bundle 裡面。
能做到 Tree Shaking,主要得歸功於 ES6 import export module system 的幫助。
首先來看看 ES6 module 的一些特性:
所謂靜態分析就是不執行代碼,單從字面上對程式碼進行分析。
在 ES6 以前,使用 CommonJS 就只有執行後才知道引用了什麼模組,這種方式就不能通過靜態分析去做優化。
ES6 定義了一套基於 import
、export
運算子的模組規範。它與 CommonJS 規範最大的差異在 ES6 中的 import
和 export
都是靜態的。
靜態意味著一個模組要暴露或引入的所有方法在編譯階段就全部確定了,之後不能再改變。
這樣做的好處就是打包工具在打包階段就可以分析出程式碼中用到了某個模組中的哪幾個方法。
其它沒有用到的方法就可以從最終的 bundle 檔案中剔除掉。這樣既可以減少 bundle 檔案的大小,又可以提高腳本的執行速度。
在開發時通常會利用 bebel 這樣的轉譯工具將語法轉換成瀏覽器看得懂的格式,不過 bebel 預設是會將 import 與 export 轉譯成 CommonJS 的,這麼做會使 Tree Shaking 失效。需要調整一些 config 讓預設的行為被 disabled 掉。
以下三種寫法是應該避免的:
透過這幾種方式 export 的 code 要不是全部被算進 bundle 中,就是全部一起被 Tree Shaken 掉,所以在使用上要特別注意,盡量保持輸出的原子性,不然一不小心可能就在 bundle 裡加了一些不會用到的 code。
許多人在撰寫模組時會忽略 module scope side effect 帶來的影響,什麼意思呢?
請看範例:
上面這段 code 的意思是呼叫一個全域的叫做 memorize
的 higher order function(等同於 window.memorize
),並把宣告的 add_100
傳進去,得到一個 memorized
版本的 add_100。
當這個模組被引入時,window.memorize
就會被呼叫,那打包工具例如 webpack 是怎麼分析這個模組的呢?讓我們以打包工具的角度來分析看看:
add_100
的函式,看起來是一個 pure function (同樣輸入都可以獲得相同輸出),如果之後都沒人用到它,我應該可以對它做 Tree Shaking,把它從 bundle 中移除掉。window.memorize
,並把 add_100
當作參數傳進去window.memorize
會做什麼事,不排除它有呼叫 add_100
並產生 side effect 的可能性memorized_add_100
,還是先把 add_100
加到 bundle 裡。這時你生氣了:「可是我知道 window.memorize
不會觸發任何 side effect 啊!也只有 memorized_add_100
被呼叫的時候才會真的去跑 add_100,因為那可是我寫的呢!」
但是,打包工具不是你,它並不知道啊!
所以,我們得利用 ES6 的 import export 特性,給打包工具多一點資訊。
現在打包工具就有足夠的資訊可以分析到底會不會產生 side effect 了
add_100
的函式,看起來是一個 pure function (同樣輸入都可以獲得相同輸出),如果之後都沒人用到它,我應該可以對它做 Tree Shaking,把它從 bundle 中移除掉。memorize
,並把 add_100
當作參數傳進去,不知道他會不會產生 side effect,不過看起來它是從其他檔案引入進來的,到那裡(utils.js)看看,說不定會有什麼發現。memorize
function 是個 pure function 呢,應該不用擔心會產生 side effect 了。add_100
,就放心把它從 bundle 中移除掉吧!所以說,想要打包工具做到 Tree Shaking,必須給它關於模組足夠的資訊,在無法判斷的條件下,它會選擇最保險的作法,也就是都加到 bundle 裡。
因為打包工具不知道這個 module 到底會不會被載入。
例如說:
以 bundler 的角度來說,isActive
這個 boolean
很可能會動態切換,所以無法在靜態分析時就確定這個模組會不會被載入,為了保險起見,它就不會做 Tree Shaking。
曾經也有人在 webpack 的 github repo 詢問過為什麼 Dynamic Import 不能做 Tree Shaking,webpack 的維護者也親自出來回覆
在使用第三方套件時,可以多留意一下是不是有支援 Tree Shaking 的功能,是不是有不同的引入方式或是提供另一種版本的套件,可能可以減少 bundle 大小。
在開頭時有提過,通常載入第三方模組是為了減少重複造輪子,可以直接使用現成的功能,不過有些時候我們不會需要模組中全部的功能,這時候如果還把整包套件打包進最後的 bundle 就有點得不償失了。
我們用實際的套件來舉例,lodash 是一個非常熱門的第三方套件函式庫,它提供了超級多關於資料操作的 utility function。
假設今天我們要使用它提供的其中一個叫做 flatten
的 util function 來將多層級陣列攤平一個層級深度,效果如下:
我們試著在專案中引入它,再來觀察一下 webpack bundle analyzer 的狀態:
騙人的吧…有夠肥的,我才用了一個簡單的 function 耶!
我合理懷疑這個 flatten 不是普通的 flatten,一定是百年難得一見的絕世函式,擁有鋼筋鐵骨,才會肥成這樣 ?
這次換成官方建議的 ES module 版本的 lodash-es 試試看
bundle size 變小到差點找不到它,不過這樣才是合理的嘛!
所以說在使用第三方套件時,可以多留意一下是不是有支援 Tree Shaking 的功能,是不是有不同的引入方式或是提供另一種版本的套件,也許小小的改變卻能大大改變應用的 bundle size 喔!
剛剛看完使用第三方套件的狀況,那如果自己要開發一個套件,要怎麼支援 Tree Shaking 功能呢?
package.json
的設定Module Bundler 會優先透過 package.json
來判斷這個 module 有沒有支援 Tree Shaking。
在 package.json 主要有兩個部分需要做設定:
這主要是給 bundler 的一些提示,如果給 false
代表告訴 bundler 這個 modules 是沒有 side effects 的,如果發現沒有用到的模組可以勇敢的做 Tree Shaking。
如果你知道這個 modules 中的一些檔案會產生 side effects,就可以使用第二種方式把會產生 side effects 的檔案放到陣列裡。
這麼做的話就只有引入除了 side effects 陣列「之外」的檔案時,才會做 Tree Shaking。
不過 side effects 也有些需要注意的 edge case。
一個有趣的例子是使用 css loader 載入 CSS,用法可能是這樣:
不過因為它只有做到引入,但是並沒有在其他地方被直接使用,所以會被 bundler Tree Shaken 掉,所以得把它加到 side effects list 裡:
才不會不小心把 CSS 在 production mode 中移除掉。
我們通常會在 main
這個 config 中指定程式的入口檔案,例如
現在可以改成多透過一個 module config 指定 ES6 版本的入口
bundler 會優先透過 module
和 sideEffects
這兩個屬性指定的路徑來引入這個模組的 ES6 版本,並做 Tree Shaking。
如果發現 ES6 版本不能用,則會回到預設選項,也就是 main
屬性指定的比較舊且不支援 Tree Shaking 的版本。
Q: 啊…那為什麼不直接都用 ES6 版本的程式碼就好啊?
A: 有些 package 是可以在瀏覽器也可以在 Node.js 中執行的,例如 Rxjs、Lodash 等套件,在 Node.js 環境下 ES6 就不太適合了。(這個問題要看 Node.js 的版本,新一點的版本就有直接支援 ES6 了。不過程式撰寫的方式也是一個原因,目前 Node.js 在開發上還是以 require 的語法為主)