tree-shaking不完全指南

徐亞光_sunOpar發表於2019-03-03

什麼是tree-shaking以及Tree-shaking的前置依賴

關於什麼是tree-shaking可以看這篇文章有一個簡單介紹。

tree-shaking的目的

簡單來說,為了增強使用者體驗,使用者開啟頁面所需等待的時間是非常重要的一環。而在使用者開啟頁面所需等待的時間,有一部分時間就是用來載入遠端檔案,包括HTML、JavaScript、CSS以及圖片資源等檔案。

taobao

如圖就是淘寶頁面在初始載入時所載入的資源,此處只擷取部分。

因此,tree-shaking的目的,就是通過減少web專案中JavaScript的無用程式碼,以達到減少使用者開啟頁面所需的等待時間,來增強使用者體驗。對於消除無用程式碼,並不是JavaScript專利,事實上業界對於該項操作有一個名字,叫做DCE(dead code elemination),然而與其說tree-shaking是DCE的一種實現,不如說tree-shaking從另外一個思路達到了DCE的目的。

tree-shaking與dead code elemination

Bad analogy time: imagine that you made cakes by throwing whole eggs into the mixing bowl and smashing them up, instead of cracking them open and pouring the contents out. Once the cake comes out of the oven, you remove the fragments of eggshell, except that’s quite tricky so most of the eggshell gets left in there. You’d probably eat less cake, for one thing. That’s what dead code elimination consists of — taking the finished product, and imperfectly removing bits you don’t want. Tree-shaking, on the other hand, asks the opposite question: given that I want to make a cake, which bits of what ingredients do I need to include in the mixing bowl?

關於tree-shaking與DCE的區別,rollup的主要貢獻者Rich Harris用做蛋糕這樣一個例子來進行對比,假設我們需要用雞蛋這個原材料來做蛋糕,很顯然,我們要的只是雞蛋裡的蛋清或者蛋黃而不是蛋殼,關於如何去除蛋殼,DCE是這樣做的:直接把整個雞蛋放到碗裡攪拌做蛋糕,蛋糕做完後再慢慢的從裡面挑出蛋殼;相反tree-shaking在開始階段,就不會把蛋殼放進碗裡,而是拿出蛋清和蛋黃放進碗裡攪拌,蛋殼呢?蛋殼在一開始就已經丟進垃圾桶裡了。

實現tree-shaking的前提條件

首先既然要實現的是減少瀏覽器下載的資源大小,因此要tree-shaking的環境必然不能是瀏覽器,一般宿主環境是Node

其次如果JavaScript是模組化的,那麼必須遵從的是ES6 Module規範,而不是CommonJS(由於CommonJS規範所致)或者其他,這是因為ES6 Module是可以靜態分析的,故而可以實現靜態時編譯進行tree-shaking。為什麼說是可以靜態分析的,是因為ES6制定了以下規範:


Module Syntax
Module :
     ModuleBody
ModuleBody :
     ModuleItemList
ModuleItemList :
     ModuleItem
     ModuleItemList ModuleItem
ModuleItem :
     ImportDeclaration
     ExportDeclaration
     StatementListItem

複製程式碼

上述語法摘自ECMAScript 2015 spec。

關於ES6模組該寫什麼不該寫什麼,ecma-262規範上已經說的很清楚了,ModuleItem裡只能包含ImportDeclaration,ExportDeclaration以及StatementListItem,而關於StatemengListItem,規範裡又有如下說明:


## Block Syntax
BlockStatement[Yield, Return] :
    Block[?Yield, ?Return]
Block[Yield, Return] :
    { StatementList[?Yield, ?Return]opt }
StatementList[Yield, Return] :
    StatementListItem[?Yield, ?Return]
    StatementList[?Yield, ?Return] StatementListItem[?Yield, ?Return]
StatementListItem[Yield, Return] :
    Statement[?Yield, ?Return]
    Declaration[?Yield]

複製程式碼

剛才說到,一個模組只能包含StatementListItem,ImportDeclaration,ExportDeclaration,而StatementListItem中又不能包含ImportDeclaration,ExportDeclaration。這也就是說import和export語句只能出現在程式碼頂層,像如下程式碼是不符合ES6 Modules規範的:

if(a === true){
    import func from './func'
}![48041852.png](http://upload-images.jianshu.io/upload_images/656716-e1ec93b7568093ad.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

複製程式碼

這樣做的目的就是避免讓模組分析依賴程式碼執行,從而促使Modlus可以進行靜態解析。

tree-shaking的實踐分析

關於tree-shaking的實踐分析,有一篇文章介紹的非常好,其從webpack和rollup兩個主要的打包工具進行分析,描述了兩者之間的異同及侷限性。下面就對其進行一個簡單的概括和整理。

rollup與webpack的差異

1. 對於單個檔案來說,rollup不需要配置外掛就可以進行tree-shaking,而webpack要實現tree-shaking必須依賴uglifyJs

single file

左邊是原始程式碼,可以看出該程式碼真正執行的只有app,函式b並未執行。中間是rollup的打包結果,可以發現rollup的tree-shaking是符合預期的;右側webpack程式碼中,app函式和未使用的b函式均被打進webpack.bundle.js檔案中。

如果webpack配合uglifyjs外掛,結果如下:

webpack with uglify![47827657.png](http://upload-images.jianshu.io/upload_images/656716-8972d40ab305224b.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

可以看到成功移除了無用的b函式。

2. 對於模組化來說,rollup依然可以不依賴其他外掛實現tree-shaking,webpack依然依賴uglifyJs。

module

可以發現,webpack僅僅是通過註釋來標識,該模組未使用,要想真正移除,還需要依賴uglifyJs。

webpack module

加入uglifyJs後成功移除。

侷限性

難道tree-shaking真正那麼完美嗎,並不是,下面就來談談侷限性。

1. 對於未執行到的程式碼,單獨使用rollup並不能移除,依然需要依賴uglifyJs

unused

上面是未使用uglifyJs的打包結果。

rollup-uglify

可以發現,通過uglifyJs的配合,rollup成功移除了函式中未執行的程式碼。

2. 對於依賴執行時才能確定是否會使用程式碼,tree-shaking無法刪除

關於tree-shaking的侷限性,這裡有篇文章你的Tree-Shaking並沒什麼卵用,說的不錯,但是其有部分內容,在我看來是有一定歧義的。


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類就會被移除了。

這篇文章以Person類為例,想說程式碼之所以無法tree-shaking,是因為該程式碼裡含有副作用所以無法移除,以至於你的tree-shaking毫無卵用。然而事實真的是這樣嗎?

image

我同樣以IIFE為例,來說明。

這裡可以看出來,在IIFE中,同樣擁有含有副作用的程式碼,如果按照那篇文章所述,因為程式碼裡有含有副作用的程式碼,那麼即使Person沒有被使用,其所有程式碼依然都會被打進去,導致tree-shaking無任何作用。

下面來看一下rollup的打包結果。

image

可以發現,tree-shaking後的程式碼,只保留了有副作用的程式碼,對於其他無副作用的程式碼,均被刪除

該文章中Person之所以裡面的程式碼沒有被刪除,作者的先放一邊,讓讀者感覺似乎只要程式碼裡有副作用,整個程式碼就無法tree-shaking,其實並不是這樣。我們更換程式碼的寫法,會發現有不同的打包結果:

image

image

因此,同樣的有副作用,有的程式碼tree-shaking是可以分析出來的,而有的,是難以解析的。

參考連結

1. CommonJS

2. tree-shaking versus dead code elimination

3. ecma-262 sec-modules

相關文章