淺談ES 模塊和Webpack Tree-shaking

blank

淺談ES 模塊和Webpack Tree-shaking

這是我在參加GSoC(Google Summer of Code) 2018 裡的項目:為webpack 改進tree-shaking。本人是19 屆應屆生,目前在今日頭條效率工程部(深圳研發)實習。

Tree-shaking 是前端比較重要的技術之一,因為減少代碼包的體積意味著減少每一次網絡傳輸的耗時,對用戶體驗有比較大的提升。對於一個包管理工具來說,DCE 是必不可少的feature 之一了。

Tree-shaking最早由打包工具Rollup提出,而作者也在一篇Medium文章解釋了Tree-shaking和DCE的區別:

Rather than excluding dead code , we're including live code .

Webpack 的maintainer 之一Tobias 也跟我解釋了他的看法,DCE 作用於模塊內(webpack 的DCE 通過UglifyJS 完成),而Tree-shaking 則是在打包的時候通過模塊之間的訊息打包必須的代碼。

Webpack與Tree-shaking

Webpack 從2 開始也支持Tree-shaking,對於一個模塊,沒有被使用過的引入代碼並不會被打包:

blank

上圖代碼中,變量isString並沒有被使用,所以webpack的tree-shaking功能不會把isString打包進來。但是這樣做的作用不大, 上圖中VSCode 已經用透明度提醒了寫代碼的人這個變量沒有用到,加上eslint 等工具的提示,一般人不會在代碼裡面引入用不到的變量。所以webpack 需要更加強大的tree-shaking 機制。

我們看如下代碼,VSCode 對引入變量是不會有透明度提示的,因為對於這個模塊所有引入都被用到了:

file1.js:

blank

index.js:

blank

file1中導出兩個變量,分別使用了兩個導入變量,但是在entry(index.js)中我們只使用了one ,那麼,webpack是否會把twoisString打包進來呢?答案是是的☑️ ,因為目前來說webpack並不支持這種級別的tree-shaking。這也是今年年初webpack 一個issue指出的問題。

解決辦法

解決辦法就是使用我的作用域分析插件: Github地址

對於上文中提到的代碼,使用插件使用前:

blank

使用後:

blank

使用方法:

blank

這裡看到減少的體積不多,是因為引入的代碼本來就不多,所以效果不明顯,但是插件消去的代碼就是那個沒有用到的isString 。

插件的原理

這個之所以能夠實現,靠的是ES6 優秀的模塊設計。 CommonJS 的設計過於靈活,對靜態分析不友好。 ES6 module 則有諸多限制:比如說只能在文件的頂部import(CommonJS 的require 語法允許在文件的任意位置調用),export { ... } 語法保證了導出的變量不會是getter/setter 之類奇怪的東西(這個block 不是一個Object),變量也不能被重新綁定。以上種種設計可以讓分析器一定程度上判斷出導入和導出變量的關係,讓這個插件的實現成為了可能。

而插件本身的原理則是作用域分析。在編譯器領域,還有超級多各種高大上的靜態分析方法(比如說數據流分析),但是對於ES 來說,他們實現的難度太大。據我所知,現在還沒有針對JS 的,能在生產環境能用的,基於數據流分析的優化器。這也是為啥現在這些打包器還不能去除沒有用到的類成員方法(class method)。

所謂作用域分析,就是可以分析出代碼裡面變量所屬的作用域以及他們之間的引用關係。有了這些訊息,就可以推導出導出變量導入變量之間的引用關係。我在Medium裡面貼的一張我自己畫的圖片可能能說明插件的原理:

blank

而對於webpack 來說,webpack 可以通過entry 和module 之間的調用得知對於一個module 來說,哪個變量是會被使用到的。就如同上文的例子:我的插件可以從webpack得知file1.js的導出變量one被使用了。我的插件通過分析出模塊中的作用域,遍歷引用到的作用域,找到真正需要import的變量,比如說isNumber ,然後再把結果返回webpack。

當然這一切也得益於webpack優秀的插件機制,讓這一個feature可以通過插件來解決。

合理模塊設計才是減少代碼體積的關鍵

Tree-shaing 其實只是一個打包器的特性,但是工具始終只是工具,良好的模塊拆分才是減少代碼體積的關鍵‍ 。

對於ES6模塊來說,會有defaut exportnamed export的區別。有些開發者喜歡把所有東西都弄成一個對象塞到default 裡面。 Default export 在概念上來說並不僅僅一個名字叫做default 的export,雖然它會被這樣轉譯。把一切東西都塞到default 裡面是一個錯誤的選擇,會讓tree-shaking 無效。從語意上來說,default export用來說明這個模塊是什麼,named export用來說明這個模塊有什麼。合理的模塊拆分是一定可以讓編譯器只打包到所需的代碼的。

另一方面,插件本身也有許多注意事項:

  • 使用ES6 Module:不僅是項目本身,引入的庫最好也是es版本,比如用lodash-es代替lodash。另外注意TypeScript 和Babel 的配置是否會把代碼編譯成非es module 版本。
  • 最純函數調用使用PURE註釋:由於無法判斷副作用,所以對於導出的函數調用最好使用PURE註釋,不過一般來說有相關的babel插件自動添加。

總結

插件發布之後,有人跟我反饋說這個插件幫助他們項目打包減少了1m 多的體積,也有人跟我反饋說一點體積都沒有減少。對於後者,一方面是可能是有些項目本身設計得很好,引入的代碼都是有用的,這個插件已經沒啥好優化了,也有可能是代碼本身被編譯成其他module 導致分析不生效。另一方面,對於webpack 來說,這可以算是一個在production mode 下很重要的feature 了。我和webpack 的maintainer 溝通過,有望在不久之後會在webpack 的production mode 默認開啟這個插件。

PS

幫我們團隊招人啦,今日頭條效率工程團隊(Efficiency Engineering)招人啦,投遞鏈接: job.bytedance.com/ ,我的內推碼:B59SSE7。

What do you think?

Written by marketer

blank

原形還是原型?

blank

深入探究immutable.js的實現機制(一)