你的Tree-Shaking並沒什麼卵用

本文將探討tree-shaking在當下的現狀,以及研究為什麼tree-shaking依舊舉步維艱的原因,最終總結當下能提高tree-shaking效果的一些手段。

Tree-Shaking這個名詞,很多前端coder已經耳熟能詳了,它代表的大意就是刪除沒用到的代碼。這樣的功能對於構建大型應用時是非常好的,因為日常開發經常需要引用各種庫。但大多時候僅僅使用了這些庫的某些部分,並非需要全部,此時Tree-Shaking如果能幫助我們刪除掉沒有使用的代碼,將會大大縮減打包後的代碼量。

Tree-Shaking在前端界由rollup首先提出並實現,後續webpack在2.x版本也藉助於UglifyJS實現了。自那以後,在各類討論優化打包的文章中,都能看到Tree-Shaking的身影。

許多開發者看到就很開心,以為自己引用的elementUI、antd 等庫終於可以刪掉一大半了。然而理想是豐滿的,現實是骨幹的。升級之後,項目的壓縮包並沒有什麼明顯變化。

我也遇到了這樣的問題,前段時間,需要開發個組件庫。我非常納悶我開發的組件庫在打包後,為什麼引用者通過ES6引用,最終依舊會把組件庫中沒有使用過的組件引入進來。

下面跟大家分享下,我在Tree-Shaking上的摸索歷程。

Tree-Shaking的原理

這裡我不多冗餘闡述,直接貼百度外賣前端的一篇文章:Tree-Shaking性能優化實踐 - 原理篇。

如果懶得看文章,可以看下如下總結:

  1. ES6的模塊引入是靜態分析的,故而可以在編譯時正確判斷到底載入了什麼代碼。
  2. 分析程序流,判斷哪些變數未被使用、引用,進而刪除此代碼。

很好,原理非常完美,那為什麼我們的代碼又刪不掉呢?

先說原因:都是副作用的鍋!

副作用

了解過函數式編程的同學對副作用這詞肯定不陌生。它大致可以理解成:一個函數會、或者可能會對函數外部變數產生影響的行為。

舉個例子,比如這個函數:

function go (url) {n window.location.href = urln}n

這個函數修改了全局變數location,甚至還讓瀏覽器發生了跳轉,這就是一個有副作用的函數。

現在我們了解了副作用了,但是細想來,我寫的組件庫也沒有什麼副作用啊,我每一個組件都是一個類,簡化一下,如下所示:

// componetns.jsnexport class Person {n constructor ({ name, age, sex }) {n this.className = Personn this.name = namen this.age = agen this.sex = sexn }n getName () {n return this.namen }n}nexport class Apple {n constructor ({ model }) {n this.className = Applen this.model = modeln }n getModel () {n return this.modeln }n}n// main.jsnimport { Apple } from ./componentsnnconst appleModel = new Apple({n model: IphoneXn}).getModel()nnconsole.log(appleModel)n

用rollup在線repl嘗試了下tree-shaking,也確實刪掉了Person,傳送門

可是為什麼當我通過webpack打包組件庫,再被他人引入時,卻沒辦法消除未使用代碼呢?

因為我忽略了兩件事情:babel編譯 + webpack打包

成也Babel,敗也Babel

Babel不用我多解釋了,它能把ES6/ES7的代碼轉化成指定瀏覽器能支持的代碼。正是由於它,我們前端開發者才能有今天這樣美好的開發環境,能夠不用考慮瀏覽器兼容性地、暢快淋漓地使用最新的JavaScript語言特性。

然而也是由於它的編譯,一些我們原本看似沒有副作用的代碼,便轉化為了(可能)有副作用的。

比如我如上的示例,如果我們用babel先編譯一下,再貼到rollup的repl,那麼結果如下:傳送門

如果懶得點開鏈接,可以看下Person類被babel編譯後的結果:

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }nnvar _createClass = function() {n function defineProperties(target, props) {n for (var i = 0; i < props.length; i++) {n var descriptor = props[i];n descriptor.enumerable = descriptor.enumerable || !1, descriptor.configurable = !0,n "value" in descriptor && (descriptor.writable = !0), Object.defineProperty(target, descriptor.key, descriptor);n }n }n return function(Constructor, protoProps, staticProps) {n return protoProps && defineProperties(Constructor.prototype, protoProps), staticProps && defineProperties(Constructor, staticProps),n Constructor;n };n}()nnvar Person = function () {n function Person(_ref) {n var name = _ref.name, age = _ref.age, sex = _ref.sex;n _classCallCheck(this, Person);nn this.className = Person;n this.name = name;n this.age = age;n this.sex = sex;n }nn _createClass(Person, [{n key: getName,n value: function getName() {n return this.name;n }n }]);n return Person;n}();n

我們的Person類被封裝成了一個IIFE(立即執行函數),然後返回一個構造函數。那它怎麼就產生副作用了呢?問題就出現在_createClass這個方法上,你只要在上一個rollup的repl鏈接中,將Person的IIFE中的_createClass調用刪了,Person類就會被移除了。至於_createClass為什麼會產生副作用,我們先放一邊。因為大家可能會產生另外一個疑問:Babel為什麼要這樣去聲明構造函數的?

假如是我的話,我可能會這樣去編譯:

var Person = function () {n function Person() {nn }n Person.prototype.getName = function () { return this.name };n return Person;n}();n

因為我們以前就是這麼寫「類」的,那babel為什麼要採用Object.defineProperty這樣的形式呢,用原型鏈有什麼不妥呢?自然是非常的不妥的,因為ES6的一些語法是有其特定的語義的。比如:

  1. 類內部聲明的方法,是不可枚舉的,而通過原型鏈聲明的方法是可以枚舉的。這裡可以參考下阮老師介紹Class 的基本語法
  2. for...of的循環是通過遍歷器(Iterator)迭代的,循環數組時並非是i++,然後通過下標尋值。這裡依舊可以看下阮老師關於遍歷器與for...of的介紹,以及一篇babel關於for...of編譯的說明transform-es2015-for-of

所以,babel為了符合ES6真正的語義,編譯類時採取了Object.defineProperty來定義原型方法,於是導致了後續這些一系列問題。

眼尖的同學可能在我上述第二點中發的鏈接transform-es2015-for-of中看到,babel其實是有一個loose模式的,直譯的話叫做寬鬆模式。它是做什麼用的呢?它會不嚴格遵循ES6的語義,而採取更符合我們平常編寫代碼時的習慣去編譯代碼。比如上述的Person類的屬性方法將會編譯成直接在原型鏈上聲明方法。

這個模式具體的babel配置如下:

// .babelrcn{n "presets": [["env", { "loose": false }]]n}n

同樣的,我放個在線repl示例方便大家直接查看效果:loose-mode

咦,如果我們真的不關心類方法能否被枚舉,開啟了loose模式,這樣是不是就沒有副作用產生,就能完美tree-shaking類了呢?

我們開啟了loose模式,使用rollup打包,發現還真是如此!傳送門

不夠屌的UglifyJS

然而不要開心的太早,當我們用Webpack配合UglifyJS打包文件時,這個Person類的IIFE又被打包進去了? What???

為了徹底搞明白這個問題,我搜到一條UglifyJS的issue:Class declaration in IIFE considered as side effect,仔細看了好久。對此有興趣、並且英語還ok的同學,可以快速去了解這條issue,還是挺有意思的。我大致闡述下這條issue下都說了些啥。

issue樓主-blacksonic 好奇為什麼UglifyJS不能消除未引用的類。

UglifyJS貢獻者-kzc說,uglify不進行程序流分析,所以不能排除有可能有副作用的代碼。

樓主:我的代碼沒什麼副作用啊。要不你們來個配置項,設置後,可以認為它是沒有副作用的,然後放心的刪了它們吧。

貢獻者:我們沒有程序流分析,我們幹不了這事兒,實在想刪除他們,出門左轉 rollup 吼吧,他們屌,做了程序流分析,能判斷到底有沒有副作用。

樓主:遷移rollup成本有點高啊。我覺得加個配置不難啊,比如這樣這樣,巴拉巴拉。

貢獻者:歡迎提PR。

樓主:別嘛,你們項目上千行代碼,我咋提PR啊。我的代碼也沒啥副作用啊,您能詳細的說明下么?

貢獻者:變數賦值就是有可能產生副作用的!我舉個例子:

var V8Engine = (function () {n function V8Engine () {}n V8Engine.prototype.toString = function () { return V8 }n return V8Enginen}())nvar V6Engine = (function () {n function V6Engine () {}n V6Engine.prototype = V8Engine.prototype // <---- side effectn V6Engine.prototype.toString = function () { return V6 }n return V6Enginen}())nconsole.log(new V8Engine().toString())n

貢獻者:V6Engine雖然沒有被使用,但是它修改了V8Engine原型鏈上的屬性,這就產生副作用了。你看rollup(樓主特意註明截至當時)目前就是這樣的策略,直接把V6Engine 給刪了,其實是不對的。

樓主以及一些路人甲乙丙丁,紛紛提出自己的建議與方案。最終定下,可以在代碼上通過/*@__PURE__*/這樣的注釋聲明此函數無副作用。

這個issue信息量比較大,也挺有意思,其中那位uglify貢獻者kzc,當時提出rollup存在的問題後還給rollup提了issue,rollup認為問題不大不緊急,這位貢獻者還順手給rollup提了個PR,解決了問題。。。

我再從這個issue中總結下幾點關鍵信息:

  1. 函數的參數若是引用類型,對於它屬性的操作,都是有可能會產生副作用的。因為首先它是引用類型,對它屬性的任何修改其實都是改變了函數外部的數據。其次獲取或修改它的屬性,會觸發getter或者setter,而gettersetter是不透明的,有可能會產生副作用。
  2. uglify沒有完善的程序流分析。它可以簡單的判斷變數後續是否被引用、修改,但是不能判斷一個變數完整的修改過程,不知道它是否已經指向了外部變數,所以很多有可能會產生副作用的代碼,都只能保守的不刪除。
  3. rollup有程序流分析的功能,可以更好的判斷代碼是否真正會產生副作用。

有的同學可能會想,連獲取對象的屬性也會產生副作用導致不能刪除代碼,這也太過分了吧!事實還真是如此,我再貼個示例演示一下:傳送門

代碼如下:

// maths.jsnexport function square ( x ) {ntreturn x.an}nsquare({ a: 123 })nnexport function cube ( x ) {ntreturn x * x * x;n}n//main.jsnimport { cube } from ./maths.js;nconsole.log( cube( 5 ) ); // 125n

打包結果如下:

function square ( x ) {n return x.an}nsquare({ a: 123 });nnfunction cube ( x ) {ntreturn x * x * x;n}nconsole.log( cube( 5 ) ); // 125n

而如果將square方法中的return x.a 改為 return x,則最終打包的結果則不會出現square方法。當然啦,如果不在maths.js文件中執行這個square方法,自然也是不會在打包文件中出現它的。

所以我們現在理解了,當時babel編譯成的_createClass方法為什麼會有副作用。現在再回頭一看,它簡直渾身上下都是副作用。

查看uglify的具體配置,我們可以知道,目前uglify可以配置pure_getters: true來強制認為獲取對象屬性,是沒有副作用的。這樣可以通過它刪除上述示例中的square方法。不過由於沒有pure_setters這樣的配置,_createClass方法依舊被認為是有副作用的,無法刪除。

那到底該怎麼辦?

聰明的同學肯定會想,既然babel編譯導致我們產生了副作用代碼,那我們先進行tree-shaking打包,最後再編譯bundle文件不就好了嘛。這確實是一個方案,然而可惜的是:這在處理項目自身資源代碼時是可行的,處理外部依賴npm包就不行了。因為人家為了讓工具包具有通用性、兼容性,大多是經過babel編譯的。而最占容量的地方往往就是這些外部依賴包。

那先從根源上討論,假如我們現在要開發一個組件庫提供給別人用,該怎麼做?

如果是使用webpack打包JavaScript庫

先貼下webpack將項目打包為JS庫的文檔。可以看到webpack有多種導出模式,一般大家都會選擇最具通用性的umd方式,但是webpack卻沒支持導出ES模塊的模式。

所以,假如你把所有的資源文件通過webpack打包到一個bundle文件里的話,那這個庫文件從此與Tree-shaking無緣。

那怎麼辦呢?也不是沒有辦法。目前業界流行的組件庫多是將每一個組件或者功能函數,都打包成單獨的文件或目錄。然後可以像如下的方式引入:

import clone from lodash/clonennimport Button from antd/lib/button;n

但是這樣呢也比較麻煩,而且不能同時引入多個組件。所以這些比較流行的組件庫大哥如antd,element專門開發了babel插件,使得用戶能以import { Button, Message } form antd這樣的方式去按需載入。本質上就是通過插件將上一句的代碼又轉化成如下:

import Button from antd/lib/button;nimport Message from antd/lib/button;n

這樣似乎是最完美的變相tree-shaking方案。唯一不足的是,對於組件庫開發者來說,需要專門開發一個babel插件;對於使用者來說,需要引入一個babel插件,稍微略增加了開發成本與使用成本。

除此之外,其實還有一個比較前沿的方法。是rollup的一個提案,在package.json中增加一個key:module,如下所示:

{n "name": "my-package",n "main": "dist/my-package.umd.js",n "module": "dist/my-package.esm.js"n}n

這樣,當開發者以es6模塊的方式去載入npm包時,會以module的值為入口文件,這樣就能夠同時兼容多種引入方式,(rollup以及webpack2+都已支持)。但是webpack不支持導出為es6模塊,所以webpack還是要拜拜。我們得上rollup!

(有人會好奇,那乾脆把未打包前的資源入口文件暴露到module,讓使用者自己去編譯打包好了,那它就能用未編譯版的npm包進行tree-shaking了。這樣確實也不是不可以。但是,很多工程化項目的babel編譯配置,為了提高編譯速度,其實是會忽略掉node_modules內的文件的。所以為了保證這些同學的使用,我們還是應該要暴露出一份編譯過的ES6 Module。)

使用rollup打包JavaScript庫

吃了那麼多虧後,我們終於明白,打包工具庫、組件庫,還是rollup好用,為什麼呢?

  1. 它支持導出ES模塊的包。
  2. 它支持程序流分析,能更加正確的判斷項目本身的代碼是否有副作用。

我們只要通過rollup打出兩份文件,一份umd版,一份ES模塊版,它們的路徑分別設為mainmodule的值。這樣就能方便使用者進行tree-shaking。

那麼問題又來了,使用者並不是用rollup打包自己的工程化項目的,由於生態不足以及代碼拆分等功能限制,一般還是用webpack做工程化打包。

使用webpack打包工程化項目

之前也提到了,我們可以先進行tree-shaking,再進行編譯,減少編譯帶來的副作用,從而增加tree-shaking的效果。那麼具體應該怎麼做呢?

首先我們需要去掉babel-loader,然後webpack打包結束後,再執行babel編譯文件。但是由於webpack項目常有多入口文件或者代碼拆分等需求,我們又需要寫一個配置文件,對應執行babel,這又略顯麻煩。所以我們可以使用webpack的plugin,讓這個環節依舊跑在webpack的打包流程中,就像uglifyjs-webpack-plugin一樣,不再是以loader的形式對單個資源文件進行操作,而是在打包最後的環節進行編譯。這裡可能需要大家了解下webpack的plugin機制。

關於uglifyjs-webpack-plugin,這裡有一個小細節,webpack默認會帶一個低版本的,可以直接用webpack.optimize.UglifyJsPlugin別名去使用。具體可以看webpack的相關說明

webpack =< v3.0.0 currently contains v0.4.6 of this plugin under webpack.optimize.UglifyJsPlugin as an alias. For usage of the latest version (v1.0.0), please follow the instructions below. Aliasing v1.0.0 as webpack.optimize.UglifyJsPlugin is scheduled for webpack v4.0.0

而這個低版本的uglifyjs-webpack-plugin使用的依賴uglifyjs也是低版本的,它沒有uglifyES6代碼的能力,故而如果我們有這樣的需求,需要在工程中重新npm install uglifyjs-webpack-plugin -D,安裝最新版本的uglifyjs-webpack-plugin,重新引入它並使用。

這樣之後,我們再使用webpack的babel插件進行編譯代碼。

問題又來了,這樣的需求比較少,因此webpack和babel官方都沒有這樣的插件,只有一個第三方開發者開發了一個插件babel-webpack-plugin。可惜的是這位作者已經近一年沒有維護這個插件了,並且存在著一個問題,此插件不會用項目根目錄下的.babelrc文件進行babel編譯。有人對此提了issue,卻也沒有任何回應。

那麼又沒有辦法,就我來寫一個新的插件吧----webpack-babel-plugin,有了它之後我們就能讓webpack在最後打包文件之前進行babel編譯代碼了,具體如何安裝使用可以點開項目查看。注意這個配置需要在uglifyjs-webpack-plugin之後,像這樣:

plugins: [n new UglifyJsPlugin(),n new BabelPlugin()n]n

但是這樣呢,有一個毛病,由於babel在最後階段去編譯比較大的文件,耗時比較長,所以建議區分下開發模式與生產模式。另外還有個更大的問題,webpack本身採用的編譯器acorn不支持對象的擴展運算符(...)以及某些還未正式成為ES標準的特性,所以。。。。。

所以如果特性用的非常超前,還是需要babel-loader,但是babel-loader要做專門的配置,把還在es stage階段的代碼編譯成ES2017的代碼,以便於webpack本身做處理。

感謝掘金熱心網友的提示,還有一個插件BabelMinifyWebpackPlugin,它所依賴的babel/minify也集成了uglifyjs。使用此插件便等同於上述使用UglifyJsPlugin + BabelPlugin的效果,如若有此方面需求,建議使用此插件。

總結

上面講了這麼多,我最後再總結下,在當下階段,在tree-shaking上能夠儘力的事。

  1. 盡量不寫帶有副作用的代碼。諸如編寫了立即執行函數,在函數里又使用了外部變數等。
  2. 如果對ES6語義特性要求不是特別嚴格,可以開啟babel的loose模式,這個要根據自身項目判斷,如:是否真的要不可枚舉class的屬性。
  3. 如果是開發JavaScript庫,請使用rollup。並且提供ES6 module的版本,入口文件地址設置到package.json的module欄位。
  4. 如果JavaScript庫開發中,難以避免的產生各種副作用代碼,可以將功能函數或者組件,打包成單獨的文件或目錄,以便於用戶可以通過目錄去載入。如有條件,也可為自己的庫開發單獨的webpack-loader,便於用戶按需載入。
  5. 如果是工程項目開發,對於依賴的組件,只能看組件提供者是否有對應上述3、4點的優化。對於自身的代碼,除1、2兩點外,對於項目有極致要求的話,可以先進行打包,最終再進行編譯。
  6. 如果對項目非常有把握,可以通過uglify的一些編譯配置,如:pure_getters: true,刪除一些強制認為不會產生副作用的代碼。

故而,在當下階段,依舊沒有比較簡單好用的方法,便於我們完整的進行tree-shaking。所以說,想做好一件事真難啊。不僅需要靠個人的努力,還需要考慮到歷史的進程。

PS: 此文中涉及到的代碼,我也傳到了github,可以點擊閱讀原文下載查看。

--閱讀原文

@丁香園F2E @相學長

--轉載請先經過本人授權。

推薦閱讀:

Webpack 之 Loader 的使用
基於 Webpack 3 的 Vue.js 工程項目腳手架
阿里雲前端工程化方案dawn
webpack真的適合SPA么?
webpack如何全局引入jquery和插件?

TAG:webpack | rollup | 前端开发 |