【譯】如何在 Webpack 2 中使用 tree-shaking

薛定諤的貓發表於2017-08-22

如何在 Webpack 2 中使用 tree-shaking

tree-shaking 這個術語首先源自 Rollup -- Rich Harris 寫的模組打包工具。它是指在打包時只包含用到的 Javascript 程式碼。它依賴於 ES6 靜態模組(exports 和 imports 不能在執行時修改),這使我們在打包時可以檢測到未使用的程式碼。Webpack 2 也引入了這一特性,Webpack 2 已經內建支援 ES6 模組和 tree-shaking。本文將會介紹如何在 webpack 中使用這一特性,如何克服使用中的難點。

如果想跳過,直接看例子請訪問 BabelTypescript

應用舉例

理解在 Webpack 中使用 tree-shaking 的最佳的方式是通過一個微型應用例子。我將它比作一個汽車有特定的引擎,該應用由 2 個檔案組成。第 1 個檔案有:一些 class,代表不同種類的引擎;一個函式返回其版本號 -- 都通過 export 關鍵字匯出。

export class V6Engine {
  toString() {
    return 'V6';
  }
}

export class V8Engine {
  toString() {
    return 'V8';
  }
}

export function getVersion() {
  return '1.0';
}複製程式碼

第 2 個檔案表示一個汽車擁有它自己的引擎,將這個檔案作為應用打包的入口(entry)。

import { V8Engine } from './engine';

class SportsCar {
  constructor(engine) {
    this.engine = engine;
  }

  toString() {
    return this.engine.toString() + ' Sports Car';
  }
}

console.log(new SportsCar(new V8Engine()).toString());複製程式碼

通過定義類 SportsCar,我們只使用了 V8Engine,而沒有用到 V6Engine。執行這個應用會輸出:‘V8 Sports Car’

應用了 tree-shaking 後,我們期望打包結果只包含用到的類和函式。在這個例子中,意味著它只有 V8EngineSportsCar 類。讓我們來看看它是如何工作的。

打包

我們打包時不使用編譯器(Babel 等)和壓縮工具(UglifyJS 等),可以得到如下輸出:

(function(module, __webpack_exports__, __webpack_require__) {

"use strict";
/* unused harmony export getVersion */
class V6Engine {
  toString() {
    return 'V6';
  }
}
/* unused harmony export V6Engine */

class V8Engine {
  toString() {
    return 'V8';
  }
}
/* harmony export (immutable) */ __webpack_exports__["a"] = V8Engine;

function getVersion() {
  return '1.0';
}

/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__engine__ = __webpack_require__(0);

class SportsCar {
  constructor(engine) {
    this.engine = engine;
  }

  toString() {
    return this.engine.toString() + ' Sports Car';
  }
}

console.log(new SportsCar(new __WEBPACK_IMPORTED_MODULE_0__engine__["a" /* V8Engine */]()).toString());

/***/ })複製程式碼

Webpack 用註釋 /\unused harmony export V6Engine*/ 將未使用的類和函式標記下來,用 /*harmony export (immutable)*/ webpack_exports[“a”] = V8Engine;* 來標記用到的。你應該會問未使用的程式碼怎麼還在?tree-shaking 沒有生效嗎?

移除未使用程式碼(Dead code elimination)vs 包含已使用程式碼(live code inclusion)

背後的原因是:Webpack 僅僅標記未使用的程式碼(而不移除),並且不將其匯出到模組外。它拉取所有用到的程式碼,將剩餘的(未使用的)程式碼留給像 UglifyJS 這類壓縮程式碼的工具來移除。UglifyJS 讀取打包結果,在壓縮之前移除未使用的程式碼。通過這一機制,就可以移除未使用的函式 getVersion 和類 V6Engine

而 Rollup 不同,它(的打包結果)只包含執行應用程式所必需的程式碼。打包完成後的輸出並沒有未使用的類和函式,壓縮僅涉及實際使用的程式碼。

設定

UglifyJS 不支援 ES6(又名 ES2015)及以上。我們需要用 Babel 將程式碼編譯為 ES5,然後再用 UglifyJS 來清除無用程式碼。

最重要的是讓 ES6 模組不受 Babel 預設(preset)的影響。Webpack 認識 ES6 模組,只有當保留 ES6 模組語法時才能夠應用 tree-shaking。如果將其轉換為 CommonJS 語法,Webpack 不知道哪些程式碼是使用過的,哪些不是(就不能應用 tree-shaking了)。最後,Webpack將把它們轉換為 CommonJS 語法。

我們需要告訴 Babel 預設(在這個例子中是babel-preset-env)不要轉換 module。

{
  "presets": [
    ["env", {
      "loose": true,
      "modules": false
    }]
  ]
}複製程式碼

對應 Webpack 配置:

module: {
  rules: [
    { test: /\.js$/, loader: 'babel-loader' }
  ]
},

plugins: [
  new webpack.LoaderOptionsPlugin({
    minimize: true,
    debug: false
  }),
  new webpack.optimize.UglifyJsPlugin({
    compress: {
      warnings: true
    },
    output: {
      comments: false
    },
    sourceMap: false
  })
]複製程式碼

來看一下 tree-shaking 之後的輸出: link to minified code.

可以看到函式 getVersion 被移除了,這是我們所預期的,然而類 V6Engine 並沒有被移除。這是什麼原因呢?

問題

首先 Babel 檢測到 ES6 模組將其轉換為 ES5,然後 Webpack 將所有的模組聚集起來,最後 UglifyJS 會移除未使用的程式碼。我們來看一下 UglifyJS 的輸出,就可以找到問題出在哪裡。

WARNING in car.prod.bundle.js from UglifyJs
Dropping unused function getVersion [car.prod.bundle.js:103,9]
Side effects in initialization of unused variable V6Engine [car.prod.bundle.js:79,4]

它告訴我們類 V6Engine 轉換為 ES5 的程式碼在初始化時有副作用。

var V6Engine = function () {
  function V6Engine() {
    _classCallCheck(this, V6Engine);
  }

  V6Engine.prototype.toString = function toString() {
    return 'V6';
  };

  return V6Engine;
}();複製程式碼

在使用 ES5 語法定義類時,類的成員函式會被新增到屬性 prototype,沒有什麼方法能完全避免這次賦值。UglifyJS 不能夠分辨它僅僅是類宣告,還是其它有副作用的操作 -- UglifyJS 不能做控制流分析。

編譯過程阻止了對類進行 tree-shaking。它僅對函式起作用。

在 Github 上,有一些相關的 bug report:Webpack repositoryUglifyJS repository。一個解決方案是 UglifyJS 完全支援 ES6,希望下個主版本能夠支援。另一個解決方案是將其標記為 pure(無副作用),以便 UglifyJS 能夠處理。這種方法已經實現,但要想生效,還需編譯器支援將類編譯後的賦值標記為 @__PURE__。實現進度:BabelTypescript

使用 Babili

Babel 的開發者們認為:為什麼不開發一個基於 Babel 的程式碼壓縮工具,這樣就能夠識別 ES6+ 的語法了。所以他們開發了Babili,所有 Babel 可以解析的語言特性它都支援。Babili 能將 ES6 程式碼編譯為 ES5,移除未使用的類和函式,這就像 UglifyJS 已經支援 ES6 一樣。

Babili 會在編譯前刪除未使用的程式碼。在編譯為 ES5 之前,很容易找到未使用的類,因此 tree-shaking 也可以用於類宣告,而不再僅僅是函式。

我們只需用 Babili 替換 UglifyJS,然後刪除 babel-loader 即可。另一種方式是將 Babili 作為 Babel 的預設,僅使用 babel-loader(移除 UglifyJS 外掛)。推薦使用第一種(外掛的方式),因為當編譯器不是 Babel(比如 Typescript)時,它也能生效。

module: {
  rules: []
},

plugins: [
  new BabiliPlugin()
]複製程式碼

我們需要將 ES6+ 程式碼傳給 BabiliPlugin,否則它不用移除(未使用的)類。

使用 Typescript 等編譯器時,也應當使用 ES6+。Typescript 應當輸出 ES6+ 程式碼,以便 tree-shaking 能夠生效。

現在的輸出不再包含類 V6Engine壓縮後程式碼

第三方包

對第三方包來說也是,應當使用 ES6 模組。幸運的是,越來越多的包作者同時釋出 CommonJS 格式 和 ES6 格式的模組。ES6 模組的入口由 package.json 的欄位 module 指定。

對 ES6 模組,未使用的函式會被移除,但 class 並不一定會。只有當包內的 class 定義也為 ES6 格式時,Babili 才能將其移除。很少有包能夠以這種格式釋出,但有的做到了(比如說 lodash 的 lodash-es)。

罪魁禍首是當包的單獨檔案通過擴充套件它們來修改其他模組時,匯入檔案有副作用。RxJs就是一個例子。通過匯入一個運算子來修改其中一個類,這些被認為是副作用,它們阻止程式碼進行 tree-shaking。

總結

通過 tree-shaking 你可以相當程度上減少應用的體積。Webpack 2 內建支援它,但其機制並不同於 Rollup。它會包含所有的程式碼,標記未使用的函式和函式,以便壓縮工具能夠移除。這就是對所有程式碼都進行 tree-shake 的困難之處。使用預設的壓縮工具 UglifyJS,它僅移除未使用的函式和變數;Babili 支援 ES6,可以用它來移除(未使用的)類。我們還必須特別注意第三方模組釋出的方式是否支援 tree-shaking。

希望這篇文章為您清楚闡述了 Webpack tree-shaking 背後的原理,併為您提供了克服困難的思路。

實際例子請訪問 BabelTypescript


感謝閱讀!喜歡本文請點選原文中的 ❤,然後分享到社交媒體上。歡迎關注 MediumTwitter 閱讀更多有關 Javascript 的內容!


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章