Webpack 之 treeShaking

來源: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)鍵詞玩法期待你的探索~

    最后編輯于
    ?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
    【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
    平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

    相關(guān)閱讀更多精彩內(nèi)容

    友情鏈接更多精彩內(nèi)容