你的Tree-Shaking並沒什麼卵用

本文將探討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的原理

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

若是懶得看文章,能夠看下以下總結:github

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

很好,原理很是完美,那爲何咱們的代碼又刪不掉呢?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,敗也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"); } }

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的一些語法是有其特定的語義的。好比:

  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配置以下:

// .babelrc
{
  "presets": [["env", { "loose": false }]]
}
複製代碼

一樣的,我放個在線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 () {
  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中總結下幾點關鍵信息:

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

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

代碼以下:

// 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打包JavaScript庫

先貼下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打包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: [
  new UglifyJsPlugin(),
  new BabelPlugin()
]
複製代碼

可是這樣呢,有一個毛病,因爲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. 若是是工程項目開發,對於依賴的組件,只能看組件提供者是否有對應上述三、4點的優化。對於自身的代碼,除一、2兩點外,對於項目有極致要求的話,能夠先進行打包,最終再進行編譯。
  6. 若是對項目很是有把握,能夠經過uglify的一些編譯配置,如:pure_getters: true,刪除一些強制認爲不會產生反作用的代碼。

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

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

--閱讀原文

@丁香園F2E @相學長

--轉載請先通過本人受權。

相關文章
相關標籤/搜索