本文將探討tree-shaking在當下(webpack@3, babel@6 如下)的現狀,以及研究爲何tree-shaking依舊舉步維艱的緣由,最終總結當下能提升tree-shaking效果的一些手段。javascript
Tree-Shaking這個名詞,不少前端coder已經耳熟能詳了,它表明的大意就是刪除沒用到的代碼。這樣的功能對於構建大型應用時是很是好的,由於平常開發常常須要引用各類庫。但大多時候僅僅使用了這些庫的某些部分,並不是須要所有,此時Tree-Shaking若是能幫助咱們刪除掉沒有使用的代碼,將會大大縮減打包後的代碼量。前端
Tree-Shaking在前端界由rollup首先提出並實現,後續webpack在2.x版本也藉助於UglifyJS實現了。自那之後,在各種討論優化打包的文章中,都能看到Tree-Shaking的身影。java
許多開發者看到就很開心,覺得本身引用的elementUI、antd 等庫終於能夠刪掉一大半了。然而理想是豐滿的,現實是骨幹的。升級以後,項目的壓縮包並無什麼明顯變化。node
我也遇到了這樣的問題,前段時間,須要開發個組件庫。我很是納悶我開發的組件庫在打包後,爲何引用者經過ES6引用,最終依舊會把組件庫中沒有使用過的組件引入進來。webpack
下面跟你們分享下,我在Tree-Shaking上的摸索歷程。git
這裏我很少冗餘闡述,直接貼百度外賣前端的一篇文章:Tree-Shaking性能優化實踐 - 原理篇。es6
若是懶得看文章,能夠看下以下總結:github
很好,原理很是完美,那爲何咱們的代碼又刪不掉呢?web
先說緣由:都是反作用的鍋!npm
瞭解過函數式編程的同窗對反作用這詞確定不陌生。它大體能夠理解成:一個函數會、或者可能會對函數外部變量產生影響的行爲。
舉個例子,好比這個函數:
function go (url) {
window.location.href = url
}
複製代碼
這個函數修改了全局變量location,甚至還讓瀏覽器發生了跳轉,這就是一個有反作用的函數。
如今咱們瞭解了反作用了,可是細想來,我寫的組件庫也沒有什麼反作用啊,我每個組件都是一個類,簡化一下,以下所示:
// componetns.js
export class Person {
constructor ({ name, age, sex }) {
this.className = 'Person'
this.name = name
this.age = age
this.sex = sex
}
getName () {
return this.name
}
}
export class Apple {
constructor ({ model }) {
this.className = 'Apple'
this.model = model
}
getModel () {
return this.model
}
}
複製代碼
// main.js
import { Apple } from './components'
const appleModel = new Apple({
model: 'IphoneX'
}).getModel()
console.log(appleModel)
複製代碼
用rollup在線repl嘗試了下tree-shaking,也確實刪掉了Person,傳送門
但是爲何當我經過webpack打包組件庫,再被他人引入時,卻沒辦法消除未使用代碼呢?
由於我忽略了兩件事情:babel編譯 + webpack打包
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"); } }
var _createClass = function() {
function defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || !1, descriptor.configurable = !0,
"value" in descriptor && (descriptor.writable = !0), Object.defineProperty(target, descriptor.key, descriptor);
}
}
return function(Constructor, protoProps, staticProps) {
return protoProps && defineProperties(Constructor.prototype, protoProps), staticProps && defineProperties(Constructor, staticProps),
Constructor;
};
}()
var Person = function () {
function Person(_ref) {
var name = _ref.name, age = _ref.age, sex = _ref.sex;
_classCallCheck(this, Person);
this.className = 'Person';
this.name = name;
this.age = age;
this.sex = sex;
}
_createClass(Person, [{
key: 'getName',
value: function getName() {
return this.name;
}
}]);
return Person;
}();
複製代碼
咱們的Person類被封裝成了一個IIFE(當即執行函數),而後返回一個構造函數。那它怎麼就產生反作用了呢?問題就出如今_createClass這個方法上,你只要在上一個rollup的repl連接中,將Person的IIFE中的_createClass
調用刪了,Person類就會被移除了。至於_createClass
爲何會產生反作用,咱們先放一邊。由於你們可能會產生另一個疑問:Babel爲何要這樣去聲明構造函數的?
假如是個人話,我可能會這樣去編譯:
var Person = function () {
function Person() {
}
Person.prototype.getName = function () { return this.name };
return Person;
}();
複製代碼
由於咱們之前就是這麼寫「類」的,那babel爲何要採用Object.defineProperty
這樣的形式呢,用原型鏈有什麼不妥呢?天然是很是的不妥的,由於ES6的一些語法是有其特定的語義的。好比:
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配置以下:
// .babelrc
{
"presets": [["env", { "loose": false }]]
}
複製代碼
一樣的,我放個在線repl示例方便你們直接查看效果:loose-mode
咦,若是咱們真的不關心類方法可否被枚舉,開啓了loose
模式,這樣是否是就沒有反作用產生,就能完美tree-shaking類了呢?
咱們開啓了loose
模式,使用rollup打包,發現還真是如此!傳送門
然而不要開心的太早,當咱們用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 () {
function V8Engine () {}
V8Engine.prototype.toString = function () { return 'V8' }
return V8Engine
}())
var V6Engine = (function () {
function V6Engine () {}
V6Engine.prototype = V8Engine.prototype // <---- side effect
V6Engine.prototype.toString = function () { return 'V6' }
return V6Engine
}())
console.log(new V8Engine().toString())
複製代碼
貢獻者:
V6Engine
雖然沒有被使用,可是它修改了V8Engine原型鏈上的屬性,這就產生反作用了。你看rollup
(樓主特地註明截至當時)目前就是這樣的策略,直接把V6Engine 給刪了,實際上是不對的。
樓主以及一些路人甲乙丙丁,紛紛提出本身的建議與方案。最終定下,能夠在代碼上經過
/*@__PURE__*/
這樣的註釋聲明此函數無反作用。
這個issue信息量比較大,也挺有意思,其中那位uglify貢獻者kzc,當時提出rollup存在的問題後還給rollup提了issue,rollup認爲問題不大不緊急,這位貢獻者還順手給rollup提了個PR,解決了問題。。。
我再從這個issue中總結下幾點關鍵信息:
getter
或者setter
,而getter
、setter
是不透明的,有可能會產生反作用。有的同窗可能會想,連獲取對象的屬性也會產生反作用致使不能刪除代碼,這也太過度了吧!事實還真是如此,我再貼個示例演示一下:傳送門
代碼以下:
// maths.js
export function square ( x ) {
return x.a
}
square({ a: 123 })
export function cube ( x ) {
return x * x * x;
}
複製代碼
//main.js
import { cube } from './maths.js';
console.log( cube( 5 ) ); // 125
複製代碼
打包結果以下:
function square ( x ) {
return x.a
}
square({ a: 123 });
function cube ( x ) {
return x * x * x;
}
console.log( cube( 5 ) ); // 125
複製代碼
而若是將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將項目打包爲JS庫的文檔。能夠看到webpack有多種導出模式,通常你們都會選擇最具通用性的umd
方式,可是webpack卻沒支持導出ES模塊的模式。
因此,假如你把全部的資源文件經過webpack打包到一個bundle文件裏的話,那這個庫文件今後與Tree-shaking無緣。
那怎麼辦呢?也不是沒有辦法。目前業界流行的組件庫可能是將每個組件或者功能函數,都打包成單獨的文件或目錄。而後能夠像以下的方式引入:
import clone from 'lodash/clone'
import Button from 'antd/lib/button';
複製代碼
可是這樣呢也比較麻煩,並且不能同時引入多個組件。因此這些比較流行的組件庫大哥如antd,element專門開發了babel插件,使得用戶能以import { Button, Message } form 'antd'
這樣的方式去按需加載。本質上就是經過插件將上一句的代碼又轉化成以下:
import Button from 'antd/lib/button';
import Message from 'antd/lib/button';
複製代碼
這樣彷佛是最完美的變相tree-shaking方案。惟一不足的是,對於組件庫開發者來講,須要專門開發一個babel插件;對於使用者來講,須要引入一個babel插件,稍微略增長了開發成本與使用成本。
除此以外,其實還有一個比較前沿的方法。是rollup的一個提案,在package.json中增長一個key:module,以下所示:
{
"name": "my-package",
"main": "dist/my-package.umd.js",
"module": "dist/my-package.esm.js"
}
複製代碼
這樣,當開發者以es6模塊的方式去加載npm包時,會以module
的值爲入口文件,這樣就可以同時兼容多種引入方式,(rollup以及webpack2+都已支持)。可是webpack不支持導出爲es6模塊,因此webpack仍是要拜拜。咱們得上rollup!
(有人會好奇,那乾脆把未打包前的資源入口文件暴露到module
,讓使用者本身去編譯打包好了,那它就能用未編譯版的npm包進行tree-shaking了。這樣確實也不是不能夠。可是,不少工程化項目的babel編譯配置,爲了提升編譯速度,實際上是會忽略掉node_modules
內的文件的。因此爲了保證這些同窗的使用,咱們仍是應該要暴露出一份編譯過的ES6 Module。)
吃了那麼多虧後,咱們終於明白,打包工具庫、組件庫,仍是rollup好用,爲何呢?
咱們只要經過rollup打出兩份文件,一份umd版,一份ES模塊版,它們的路徑分別設爲main
,module
的值。這樣就能方便使用者進行tree-shaking。
那麼問題又來了,使用者並非用rollup打包本身的工程化項目的,因爲生態不足以及代碼拆分等功能限制,通常仍是用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也是低版本的,它沒有uglify
ES6代碼的能力,故而若是咱們有這樣的需求,須要在工程中從新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: [
new UglifyJsPlugin(),
new BabelPlugin()
]
複製代碼
可是這樣呢,有一個毛病,因爲babel在最後階段去編譯比較大的文件,耗時比較長,因此建議區分下開發模式與生產模式。另外還有個更大的問題,webpack
自己採用的編譯器acorn不支持對象的擴展運算符(...)以及某些還未正式成爲ES標準的特性,因此。。。。。
因此若是特性用的很是超前,仍是須要babel-loader
,可是babel-loader
要作專門的配置,把還在es stage階段的代碼編譯成ES2017的代碼,以便於webpack
自己作處理。
感謝掘金熱心網友的提示,還有一個插件BabelMinifyWebpackPlugin,它所依賴的babel/minify也集成了uglifyjs。使用此插件便等同於上述使用UglifyJsPlugin + BabelPlugin的效果,如如有此方面需求,建議使用此插件。
上面講了這麼多,我最後再總結下,在當下階段,在tree-shaking上可以盡力的事。
loose
模式,這個要根據自身項目判斷,如:是否真的要不可枚舉class的屬性。module
字段。pure_getters: true
,刪除一些強制認爲不會產生反作用的代碼。故而,在當下階段,依舊沒有比較簡單好用的方法,便於咱們完整的進行tree-shaking。因此說,想作好一件事真難啊。不只須要靠我的的努力,還須要考慮到歷史的進程。
PS: 此文中涉及到的代碼,我也傳到了github,能夠點擊閱讀原文下載查看。
--閱讀原文
--轉載請先通過本人受權。