來源:easonyq?
github.com/easonyq/easonyq.github.io/blob/master/學(xué)習(xí)記錄/webpack/treeShaking.md
在 github 上直接觀看 markdown 會把圖片轉(zhuǎn)存到緩存中,github 轉(zhuǎn)存后的圖片清晰度很有問題,因此如果圖片看不清,可以移步知乎上的相同文章
webpack 2.0 開始引入 tree shaking 技術(shù)。在介紹技術(shù)之前,先介紹幾個相關(guān)概念:
AST 對 JS 代碼進行語法分析后得出的語法樹 (Abstract Syntax Tree)。AST語法樹可以把一段 JS 代碼的每一個語句都轉(zhuǎn)化為樹中的一個節(jié)點。
DCE Dead Code Elimination,在保持代碼運行結(jié)果不變的前提下,去除無用的代碼。這樣的好處是:
減少程序體積
減少程序執(zhí)行時間
便于將來對程序架構(gòu)進行優(yōu)化
而所謂 Dead Code 主要包括:
程序中沒有執(zhí)行的代碼 (如不可能進入的分支,return 之后的語句等)
導(dǎo)致 dead variable 的代碼(寫入變量之后不再讀取的代碼)
tree shaking 是 DCE 的一種方式,它可以在打包時忽略沒有用到的代碼。
機制簡述
tree shaking 是 rollup 作者首先提出的。這里有一個比喻:
如果把代碼打包比作制作蛋糕。傳統(tǒng)的方式是把雞蛋(帶殼)全部丟進去攪拌,然后放入烤箱,最后把(沒有用的)蛋殼全部挑選并剔除出去。而 treeshaking 則是一開始就把有用的蛋白蛋黃放入攪拌,最后直接作出蛋糕。
因此,相比于 排除不使用的代碼,tree shaking 其實是 找出使用的代碼。
基于 ES6 的靜態(tài)引用,tree shaking 通過掃描所有 ES6 的 export,找出被 import 的內(nèi)容并添加到最終代碼中。 webpack 的實現(xiàn)是把所有 import 標記為有使用/無使用兩種,在后續(xù)壓縮時進行區(qū)別處理。因為就如比喻所說,在放入烤箱(壓縮混淆)前先剔除蛋殼(無使用的 import),只放入有用的蛋白蛋黃(有使用的 import)
使用方法
首先源碼必須遵循 ES6 的模塊規(guī)范 (import & export),如果是 CommonJS 規(guī)范 (require) 則無法使用。
根據(jù)webpack官網(wǎng)的提示,webpack2 支持 tree-shaking,需要修改配置文件,指定babel處理js文件時不要將ES6模塊轉(zhuǎn)成CommonJS模塊,具體做法就是:
在.babelrc設(shè)置babel-preset-es2015的modules為fasle,表示不對ES6模塊進行處理。
// .babelrc
{
????"presets": [
????????["es2015", {"modules": false}]
????]
}
經(jīng)過測試,webpack 3 和 4 不增加這個 .babelrc 文件也可以正常 tree shaking
Tree shaking 兩步走
webpack 負責(zé)對代碼進行標記,把 import & export 標記為 3 類:
1、所有 import 標記為 /* harmony import */
2、被使用過的 export 標記為 /* harmony export ([type]) */,其中 [type] 和 webpack 內(nèi)部有關(guān),可能是 binding, immutable 等等。
3、沒被使用過的 import 標記為 /* 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 徹底清除,結(jié)果如下:
只有 hello 的定義和調(diào)用。
類(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 只處理頂層內(nèi)容,例如類和對象內(nèi)部都不會再被分別處理。
這主要也是由于 JS 的動態(tài)語言特性所致。如果把 bye() 刪除,考慮如下代碼:
// index.js
import Util from './util'
let util = new Util()
let result1 = util[Math.random() > 0.5 ? 'hello', 'bye']()
console.log(result1)
編譯器并不能識別一個方法名字究竟是以直接調(diào)用的形式出現(xiàn) (util.hello()) 還是以字符串的形式 (util['hello']()) 或者其他更加離奇的方式。因此誤刪方法只會導(dǎo)致運行出錯,得不償失。
副作用
副作用的意思某個方法或者文件執(zhí)行了之后,還會對全局其他內(nèi)容產(chǎn)生影響的代碼。例如 polyfill 在各類 prototype 加入方法,就是副作用的典型。(也可以看出,程序和吃藥不同,副作用不全是貶義的)
副作用總共有兩種形態(tài),是精簡代碼不得不考慮的問題。我們平時在重構(gòu)代碼時,也應(yīng)當以相類似的思維去進行,否則總有踩坑的一天。
模塊引入帶來的副作用
// 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'
如上代碼經(jīng)過 webpack + uglify 的處理后,會變成這樣:
雖然 Util 類被引入之后沒有進行任何使用,但是不能當做沒引用過而直接刪除。在混合后的代碼中,可以看到 Util 類的本體 (export 的內(nèi)容) 已經(jīng)沒有了,但是前后的 console.log 和對 Array.prototype 的擴展依然保留。這就是編譯器為了確保代碼執(zhí)行效果不變而做的妥協(xié),因為它不知道這兩句代碼到底是干嘛的,所以他默認認定所有代碼 均有 副作用。
方法調(diào)用帶來的副作用
// 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'
}
我們引入并調(diào)用了 bye(),但是卻沒有使用它的返回值 result2,這種代碼可以刪嗎?(捫心自問,如果是你人肉重構(gòu)代碼,直接刪掉這行代碼的可能性有沒有超過 90% ?)
webpack 并沒有刪除這行代碼,至少沒有刪除全部。它確實刪除了 result2,但保留了 bye() 的調(diào)用(壓縮的代碼表現(xiàn)為 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 和之前相同,不再重復(fù)。有差別的是 webpack.config.js,需要增加參數(shù) pure_funcs,告訴 webpack Math.floor 是沒有副作用的,你可以放心刪除:
plugins: [
??new UglifyJSPlugin({
????uglifyOptions: {
??????compress: {
??????????pure_funcs: ['Math.floor']
??????}
????}
??})
],
在添加了 pure_funcs 配置后,原來保留的 Math.floor(.5) 被刪除了,達到了我們的預(yù)期效果。
但這個方法有一個很大的局限性,在于如果我們把 webpack 和 uglify 合并使用,經(jīng)過 webpack 的代碼的方法名已經(jīng)被重命名了,那么在這里配置原始的方法名也就失去了意義。而例如 Math.floor 這類全局方法不會重命名,才會生效。因此適用性不算太強。
package.json 的 sideEffects
webpack 4 在 package.json 新增了一個配置項叫做 sideEffects, 值為 false 表示整個包都沒有副作用;或者是一個數(shù)組列出有副作用的模塊。詳細的例子可以查看 webpack 官方提供的例子。
從結(jié)果來看,如果 sideEffects 值為 false,當前包 export 了 5 個方法,而我們使用了 2 個,剩下 3 個也不會被打包,是符合預(yù)期的。但這要求包作者的自覺添加,因此在當前 webpack 4 推出不久的情況下,局限性也不算小。
concatenateModule
webpack 3 開始加入了 webpack.optimize.ModuleConcatenateModulePlugin(),到了 webpack 4 直接作為 `mode = ‘production’ 的默認配置。這是對 webpack bundle 的一個優(yōu)化,把本來“每個模塊包裹在一個閉包里”的情況,優(yōu)化成“所有模塊都包裹在同一個閉包里”的情況。本身對于代碼縮小體積有很大的提升,這里也能側(cè)面解決副作用的問題。
依然選取這樣 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() 方法的調(diào)用和本體都被消除了。
其次,hello() 方法的調(diào)用和定義被合成到了一起,變成直接 console.log('hello')
第三就是這個功能原有的目的:代碼量減少了。
這個功能的本意是把所有模塊最終輸出到同一個方法內(nèi)部,從而把調(diào)用和定義合并到一起。這樣像 bye() 這樣沒有副作用的方法就可以在合并之后被輕易識別出來,并加以刪除。有關(guān)這個功能更加詳細的介紹可以看這篇文章
總結(jié)
1、使用 ES6 模塊語法編寫代碼
2、工具類函數(shù)盡量以單獨的形式輸出,不要集中成一個對象或者類
3、聲明 sideEffects
4自己在重構(gòu)代碼時也要注意副作用
感興趣的小伙伴,可以關(guān)注公眾號【grain先森】,回復(fù)關(guān)鍵詞 “小程序”,獲取更多資料,更多關(guān)鍵詞玩法期待你的探索~