Tree Shaking in Webpack

寫於 2018.08.30webpack

webpack 2.0 開始引入 tree shaking 技術。在介紹技術以前,先介紹幾個相關概念:git

  • AST 對 JS 代碼進行語法分析後得出的語法樹 (Abstract Syntax Tree)。AST語法樹能夠把一段 JS 代碼的每個語句都轉化爲樹中的一個節點。github

  • DCE Dead Code Elimination,在保持代碼運行結果不變的前提下,去除無用的代碼。這樣的好處是:web

    • 減小程序體積
    • 減小程序執行時間
    • 便於未來對程序架構進行優化

    而所謂 Dead Code 主要包括:json

    • 程序中沒有執行的代碼 (如不可能進入的分支,return 以後的語句等)
    • 致使 dead variable 的代碼(寫入變量以後再也不讀取的代碼)

tree shaking 是 DCE 的一種方式,它能夠在打包時忽略沒有用到的代碼。數組

機制簡述

tree shaking 是 rollup 做者首先提出的。這裏有一個比喻:bash

若是把代碼打包比做製做蛋糕。傳統的方式是把雞蛋(帶殼)所有丟進去攪拌,而後放入烤箱,最後把(沒有用的)蛋殼所有挑選並剔除出去。而 treeshaking 則是一開始就把有用的蛋白蛋黃放入攪拌,最後直接做出蛋糕。babel

所以,相比於排除不使用的代碼,tree shaking 實際上是找出使用的代碼閉包

基於ES6的靜態引用,tree shaking 經過掃描全部 ES6 的export,找出被import 的內容並添加到最終代碼中。 webpack 的實現是把全部import 標記爲有使用/無使用兩種,在後續壓縮時進行區別處理。由於就如比喻所說,在放入烤箱(壓縮混淆)前先剔除蛋殼(無使用的import),只放入有用的蛋白蛋黃(有使用的import)架構

使用方法

首先源碼必須遵循 ES6 的模塊規範 (import&export),若是是 CommonJS 規範 (require) 則沒法使用。

根據 Webpack 官網的提示,webpack2 支持 tree-shaking,須要修改配置文件,指定 babel 處理 js 文件時不要將 ES6 模塊轉成 CommonJS 模塊,具體作法就是:

在 .babelrc 設置 babel-preset-es2015 的 modules 爲 fasle,表示不對 ES6 模塊進行處理。

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

通過測試,webpack 3 和 4 不增長這個 .babelrc 文件也能夠正常 tree shaking

Tree shaking 兩步走

webpack 負責對代碼進行標記,把import&export標記爲 3 類:

  1. 全部import標記爲/* harmony import */
  2. 被使用過的export標記爲/* harmony export ([type]) */,其中[type]和 webpack 內部有關,多是binding, immutable等等。
  3. 沒被使用過的export標記爲/* unused harmony export [FuncName] */,其中 [FuncName]export的方法名稱

以後在 Uglifyjs (或者其餘相似的工具) 步驟進行代碼精簡,把沒用的都刪除。

實例分析

全部實例代碼均在 demo/webpack 目錄

方法的處理

// index.js
import {hello, bye} from './util'

let result1 = hello()

console.log(result1)
複製代碼
// util.js
export function hello () {
  return 'hello'
}

export function bye () {
  return 'bye'
}
複製代碼

編譯後的 bundle.js 以下:

/******/ ([
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

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


let result1 = Object(__WEBPACK_IMPORTED_MODULE_0__util__["a" /* hello */])()

console.log(result1)


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

"use strict";
/* harmony export (immutable) */ __webpack_exports__["a"] = hello;
/* unused harmony export bye */
function hello () {
  return 'hello'
}

function bye () {
  return 'bye'
}
複製代碼

注:省略了bundle.js上邊 webpack 自定義的模塊加載代碼,那些都是固定的。

對於沒有使用的bye方法,webpack 標記爲unused harmony export bye,可是代碼依舊保留。而hello就是正常的harmony export (immutable)

以後使用UglifyJSPlugin就能夠進行第二步,把bye完全清除,結果以下:

function

只有hello的定義和調用。

類 ( class ) 的處理

// index.js
import Util from './util'

let util = new Util()
let result1 = util.hello()
console.log(result1)
複製代碼
// util.js
export default class Util {
  hello () {
    return 'hello'
  }

  bye () {
    return 'bye'
  }
}
複製代碼

編譯後的 bundle.js 以下:

/******/ ([
/* 0 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

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


let util = new __WEBPACK_IMPORTED_MODULE_0__util__["a" /* default */]()
let result1 = util.hello()
console.log(result1)


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

"use strict";
class Util {
  hello () {
    return 'hello'
  }

  bye () {
    return 'bye'
  }
}
/* harmony export (immutable) */ __webpack_exports__["a"] = Util;
複製代碼

注意到 webpack 是對Util 類總體進行標記的(標記爲被使用),而不是分別針對兩個方法。也所以,最終打包的代碼依然會包含bye方法。這代表 webpack tree shaking 只處理頂層內容,例如類和對象內部都不會再被分別處理。

這主要也是因爲 JS 的動態語言特性所致。若是把bye()刪除,考慮以下代碼:

// index.js
import Util from './util'

let util = new Util()
let result1 = util[Math.random() > 0.5 ? 'hello', 'bye']()
console.log(result1)
複製代碼

編譯器並不能識別一個方法名字到底是以直接調用的形式出現 (util.hello()) 仍是以字符串的形式 (util['hello']()) 或者其餘更加離奇的方式。所以誤刪方法只會致使運行出錯,得不償失。

反作用

反作用的意思某個方法或者文件執行了以後,還會對全局其餘內容產生影響的代碼。例如 polyfill 在各種prototype 加入方法,就是反作用的典型。(也能夠看出,程序和吃藥不一樣,反作用不全是貶義的)

反作用總共有兩種形態,是精簡代碼不得不考慮的問題。咱們平時在重構代碼時,也應當以相相似的思惟去進行,不然總有踩坑的一天。

模塊引入帶來的反作用

// index.js
import Util from './util'

console.log('Util unused')
複製代碼
// util.js
console.log('This is Util class')

export default class Util {
  hello () {
    return 'hello'
  }

  bye () {
    return 'bye'
  }
}

Array.prototype.hello = () => 'hello'
複製代碼

如上代碼通過webpack + uglify的處理後,會變成這樣:

import-side-effects

雖然Util類被引入以後沒有進行任何使用,可是不能當作沒引用過而直接刪除。在混合後的代碼中,能夠看到Util類的本體 (export的內容) 已經沒有了,可是先後的 console.log和對Array.prototype 的擴展依然保留。這就是編譯器爲了確保代碼執行效果不變而作的妥協,由於它不知道這兩句代碼究竟是幹嗎的,因此他默認認定全部代碼 均有 反作用。

方法調用帶來的反作用

// index.js
import {hello, bye} from './util'

let result1 = hello()
let result2 = bye()

console.log(result1)
複製代碼
// util.js
export function hello () {
  return 'hello'
}

export function bye () {
  return 'bye'
}
複製代碼

咱們引入並調用了bye(),可是卻沒有使用它的返回值result2,這種代碼能夠刪嗎?(捫心自問,若是是你人肉重構代碼,直接刪掉這行代碼的可能性有沒有超過 90% ?)

invoke-side-effects

webpack 並無刪除這行代碼,至少沒有刪除所有。它確實刪除了result2,但保留了 bye()的調用(壓縮的代碼表現爲Object(r.a)())以及bye()的定義。

這一樣是由於編譯器不清楚bye()裏面究竟作了什麼。若是它包含了如Array.prototye的擴展,那刪掉就又出問題了。

如何解決反作用?

咱們很感謝 webpack 如此嚴謹,但若是某個方法就是沒有反作用的,咱們該怎麼告訴 webpack 讓他放心大膽的刪除呢?

有 3 個方法,適用於不一樣的狀況。

pure_funcs
// index.js
import {hello, bye} from './util'

let result1 = hello()
let a = 1
let b = 2
let result2 = Math.floor(a / b)

console.log(result1)
複製代碼

util.js 和以前相同,再也不重複。有差異的是 webpack.config.js,須要增長參數pure_funcs,告訴webpack Math.floor是沒有反作用的,你能夠放心刪除:

plugins: [
  new UglifyJSPlugin({
    uglifyOptions: {
      compress: {
          pure_funcs: ['Math.floor']
      }
    }
  })
],
複製代碼

pure-funcs-before

pure-funcs-after

在添加了pure_funcs配置後,原來保留的Math.floor(.5)被刪除了,達到了咱們的預期效果。

但這個方法有一個很大的侷限性,在於若是咱們把 webpack 和 uglify 合併使用,通過 webpack 的代碼的方法名已經被重命名了,那麼在這裏配置原始的方法名也就失去了意義。而例如Math.floor這類全局方法不會重命名,纔會生效。所以適用性不算太強。

package.json 的 sideEffects

webpack 4 在 package.json 新增了一個配置項叫作sideEffects, 值爲false表示整個包都沒有反作用;或者是一個數組列出有反作用的模塊。詳細的例子能夠查看 webpack 官方提供的例子

從結果來看,若是sideEffects值爲false,當前包export了 5 個方法,而咱們使用了 2 個,剩下 3 個也不會被打包,是符合預期的。但這要求包做者的自覺添加,所以在當前 webpack 4 推出不久的狀況下,侷限性也不算小。

concatenateModule

webpack 3 開始加入了webpack.optimize.ModuleConcatenateModulePlugin(),到了 webpack 4 直接做爲 `mode = 'production' 的默認配置。這是對 webpack bundle 的一個優化,把原本「每一個模塊包裹在一個閉包裏」的狀況,優化成「全部模塊都包裹在同一個閉包裏」的狀況。自己對於代碼縮小體積有很大的提高,這裏也能側面解決反作用的問題。

依然選取這樣 2 個文件做爲例子:

// index.js
import {hello, bye} from './util'

let result1 = hello()
let result2 = bye()

console.log(result1)
複製代碼
// util.js
export function hello () {
  return 'hello'
}

export function bye () {
  return 'bye'
}
複製代碼

在開啓了 concatenateModule 功能後,打包出來的代碼以下:

concatenateModule

首先,bye()方法的調用和本體都被消除了。

其次,hello()方法的調用和定義被合成到了一塊兒,變成直接console.log('hello')

第三就是這個功能原有的目的:代碼量減小了。

這個功能的本意是把全部模塊最終輸出到同一個方法內部,從而把調用和定義合併到一塊兒。這樣像bye()這樣沒有反作用的方法就能夠在合併以後被輕易識別出來,並加以刪除。有關這個功能更加詳細的介紹能夠看這篇文章

總結

  1. 使用 ES6 模塊語法編寫代碼
  2. 工具類函數儘可能以單獨的形式輸出,不要集中成一個對象或者類
  3. 聲明 sideEffects
  4. 本身在重構代碼時也要注意反作用
相關文章
相關標籤/搜索