Tree Shaking in Webpack
寫於 2018.08.30

webpack 2.0 開始引入 tree shaking 技術。在介紹技術之前,先介紹幾個相關概念:
-
AST 對 JS 程式碼進行語法分析後得出的語法樹 (Abstract Syntax Tree)。AST語法樹可以把一段 JS 程式碼的每一個語句都轉化為樹中的一個節點。
-
DCE Dead Code Elimination,在保持程式碼執行結果不變的前提下,去除無用的程式碼。這樣的好處是:
- 減少程式體積
- 減少程式執行時間
- 便於將來對程式架構進行優化
而所謂 Dead Code 主要包括:
- 程式中沒有執行的程式碼 (如不可能進入的分支,return 之後的語句等)
- 導致 dead variable 的程式碼(寫入變數之後不再讀取的程式碼)
tree shaking 是 DCE 的一種方式,它可以在打包時忽略沒有用到的程式碼。

機制簡述
tree shaking 是 rollup 作者首先提出的。這裡有一個比喻:
如果把程式碼打包比作製作蛋糕。傳統的方式是把雞蛋(帶殼)全部丟進去攪拌,然後放入烤箱,最後把(沒有用的)蛋殼全部挑選並剔除出去。而 treeshaking 則是一開始就把有用的蛋白蛋黃放入攪拌,最後直接作出蛋糕。
因此,相比於 排除不使用的程式碼 ,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 類:
- 所有
import
標記為/* harmony import */
- 被使用過的
export
標記為/* harmony export ([type]) */
,其中[type]
和 webpack 內部有關,可能是binding
,immutable
等等。 - 沒被使用過的
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
徹底清除,結果如下:

只有 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
的處理後,會變成這樣:

雖然 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% ?)

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
配置後,原來保留的 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 功能後,打包出來的程式碼如下:

首先, bye()
方法的呼叫和本體都被消除了。
其次, hello()
方法的呼叫和定義被合成到了一起,變成直接 console.log('hello')
第三就是這個功能原有的目的:程式碼量減少了。
這個功能的本意是把所有模組最終輸出到同一個方法內部,從而把呼叫和定義合併到一起。這樣像 bye()
這樣沒有副作用的方法就可以在合併之後被輕易識別出來,並加以刪除。有關這個功能更加詳細的介紹可以看這篇文章