Webpack 2 的 Tree-shaking 深入分析

在本章節中通過一個引入 Ladash 特定模塊的實例來展示 Tree-shaking 在 Webpack 中的重要作用。通過合理的使用 Tree-shaking 功能可以有效的減少打包后文件的大小,通過本實例我們也可以知道 Tree-shaking 的作用條件和范圍。這樣對于 Webpack 優化策略又掌握了一部分核心知識。

是否需要引入 Tree-shaking

下面是 Ladash 中對外導出的對象:

 lodash.isFunction = isFunction;
    lodash.isInteger = isInteger;
    lodash.isLength = isLength;
    lodash.isMap = isMap;
    lodash.isMatch = isMatch;
    lodash.isMatchWith = isMatchWith;
    lodash.isNaN = isNaN;
    lodash.isNative = isNative;
    lodash.isNil = isNil;
    lodash.isNull = isNull;
    lodash.isNumber = isNumber;
    lodash.isObject = isObject;
    lodash.isObjectLike = isObjectLike;
    lodash.isPlainObject = isPlainObject;
    lodash.isRegExp = isRegExp;
    lodash.isSafeInteger = isSafeInteger;
    lodash.isSet = isSet;
    lodash.isString = isString;
    lodash.isSymbol = isSymbol;
    lodash.isTypedArray = isTypedArray;
    lodash.isUndefined = isUndefined;
    lodash.isWeakMap = isWeakMap;
    lodash.isWeakSet = isWeakSet;

這是為什么我們可以通過如下方式引入方法的原因:

import { concat, sortBy, map, sample } from 'lodash';
//lodash 其實是一個對象

但是還有一種常見的方法就是只引入我們需要的函數,如下:

import sortBy from 'lodash/sortBy';
import map from 'lodash/map';
import sample from 'lodash/sample';

之所以可以通過這種方法引用是因為在 Lodash 的 npm 包中,每一個方法都對應于一個獨立的文件,并導出了該方法,例如下面就是 sortBy.js 方法的源碼:

var sortBy = baseRest(function(collection, iteratees) {
  if (collection == null) {
    return [];
  }
  var length = iteratees.length;
  if (length > 1 && isIterateeCall(collection, iteratees[0], iteratees[1])) {
    iteratees = [];
  } else if (length > 2 && isIterateeCall(iteratees[0], iteratees[1], iteratees[2])) {
    iteratees = [iteratees[0]];
  }
  return baseOrderBy(collection, baseFlatten(iteratees, 1), []);
});
module.exports = sortBy;

注意一點就是,通過后者來導入我們需要的文件比前者全部導入的文件要小的多。上面已經說了原因,即后者將每一個方法都存放在一個獨立的文件中,從而可以按需導入,所以文件也就比較小了。具體可以查看這里來學習如何減少 bundle.js 的大小。

當然,如果使用了 Webpack 3 的 Tree-shaking,那么就不需要考慮這個情況了。Tree-shaking 會讓沒用的代碼在打包的時候直接被剔除。但是,請注意,Tree-shaking 的功能要生效必須滿足一定的條件,即必須是 ES6 模塊

Webpack 引入 Tree-shaking 功能

Webpack 如何使用 Tree-shaking

為了讓 Webpack 2 支持 Tree-shaking 功能,需要對 wcf 的 babel 配置進行修改,其中修改最重要的一點就是去掉 babel-preset-es2015 ,而采用 plugin 處理。在 plugin 處理的時候還需要去掉下面的插件:

require.resolve("babel-plugin-transform-es2015-modules-amd"),
//轉化為 amd 格式,define 類型
require.resolve("babel-plugin-transform-es2015-modules-commonjs"),
//轉化為 commonjs 規范,得到:exports.default = 42,export.name="罄天"
require.resolve("babel-plugin-transform-es2015-modules-umd"),
//umd規范

采用 babel-plugin-transform-es2015-modules-commonjs 以后,代碼如下:

//imported.js
export function foo() {
    return 'foo';
}
export function bar() {
    return 'bar';
}
//下面是 index.js
import {foo} from './imported';
let elem = document.getElementById('output');
elem.innerHTML = `Output: ${foo()}`;

會被 Webpack 轉化為如下的形式:

Object.defineProperty(exports, "__esModule", {
    value: true
});
exports.foo = foo;
exports.bar = bar;
//都轉化為 commonjs 規范了
function foo() {
    return 'foo';
}
function bar() {
    return 'bar';
}
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {

"use strict";
var _imported = __webpack_require__(0);

var elem = document.getElementById('output');
elem.innerHTML = 'Output: ' + (0, _imported.foo)();
/***/ })
/******/ ]);

所以,我們沒有用到的 bar 方法也被引入了。而如果引入 babel-plugin-transform-es2015-modules-amd,打包代碼就會得到如下的內容:

/******/ ([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
var __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;!(__WEBPACK_AMD_DEFINE_ARRAY__ = [exports], __WEBPACK_AMD_DEFINE_RESULT__ = function (exports) {
    'use strict';
    Object.defineProperty(exports, "__esModule", {
        value: true
    });
    exports.foo = foo;
    exports.bar = bar;
    //沒有用到的 bar 方法也被導出了
    function foo() {
        return 'foo';
    }
    function bar() {
        return 'bar';
    }
}.apply(exports, __WEBPACK_AMD_DEFINE_ARRAY__),
        __WEBPACK_AMD_DEFINE_RESULT__ !== undefined && (module.exports = __WEBPACK_AMD_DEFINE_RESULT__));
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {

var __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;!(__WEBPACK_AMD_DEFINE_ARRAY__ = [__webpack_require__(0)], __WEBPACK_AMD_DEFINE_RESULT__ = function (_imported) {
  'use strict';

  var elem = document.getElementById('output');
  elem.innerHTML = 'Output: ' + (0, _imported.foo)();
}.apply(exports, __WEBPACK_AMD_DEFINE_ARRAY__),
        __WEBPACK_AMD_DEFINE_RESULT__ !== undefined && (module.exports = __WEBPACK_AMD_DEFINE_RESULT__));
/***/ })
/******/ ]);

而如果引入 babel-plugin-transform-es2015-modules-umd 也會面臨同樣的問題,所以我們應該去掉上面三個插件,即不再使用 amd/cmd/umd 規范打包,而使用 ES6 原生模塊打包策略。讓 ES6 模塊不受 Babel 預設(preset)的影響。Webpack 認識 ES6 模塊,只有當保留 ES6 模塊語法時才能夠應用 Tree-shaking。如果將其轉換為 CommonJS 語法,Webpack 不知道哪些代碼是使用過的,哪些不是(就不能應用 Tree-shaking 了)。最后,Webpack 將把它們轉換為 CommonJS語法。最終得到的 babel 默認配置就是如下的內容:

function getDefaultBabelConfig() {
  return {
    cacheDirectory: tmpdir(),
    //We must set!
    presets: [
      require.resolve('babel-preset-react'),
      // require.resolve('babel-preset-es2015'),
      //(1)這個必須去掉
      require.resolve('babel-preset-stage-0'),
    ],
    plugins: [
      require.resolve("babel-plugin-transform-es2015-template-literals"),
      require.resolve("babel-plugin-transform-es2015-literals"),
      require.resolve("babel-plugin-transform-es2015-function-name"),
      require.resolve("babel-plugin-transform-es2015-arrow-functions"),
      require.resolve("babel-plugin-transform-es2015-block-scoped-functions"),
      require.resolve("babel-plugin-transform-es2015-classes"),
      //這里會轉化class
      require.resolve("babel-plugin-transform-es2015-object-super"),
      require.resolve("babel-plugin-transform-es2015-shorthand-properties"),
      require.resolve("babel-plugin-transform-es2015-computed-properties"),
      require.resolve("babel-plugin-transform-es2015-for-of"),
      require.resolve("babel-plugin-transform-es2015-sticky-regex"),
      require.resolve("babel-plugin-transform-es2015-unicode-regex"),
      require.resolve("babel-plugin-syntax-object-rest-spread"),
      require.resolve("babel-plugin-transform-es2015-parameters"),
      require.resolve("babel-plugin-transform-es2015-destructuring"),
      require.resolve("babel-plugin-transform-es2015-block-scoping"),
      require.resolve("babel-plugin-transform-es2015-typeof-symbol"),
      [
        require.resolve("babel-plugin-transform-regenerator"),
        { async: false, asyncGenerators: false }
      ],
      // require.resolve("babel-plugin-add-module-exports"),
      // 交給 Webpack 2 處理,可以刪除
      require.resolve("babel-plugin-check-es2015-constants"),
      require.resolve("babel-plugin-syntax-async-functions"),
      require.resolve("babel-plugin-syntax-async-generators"),
      require.resolve("babel-plugin-syntax-class-constructor-call"),
      require.resolve("babel-plugin-syntax-class-properties"),
      require.resolve("babel-plugin-syntax-decorators"),
      require.resolve("babel-plugin-syntax-do-expressions"),
      require.resolve("babel-plugin-syntax-dynamic-import"),
      require.resolve("babel-plugin-syntax-exponentiation-operator"),
      require.resolve("babel-plugin-syntax-export-extensions"),
      require.resolve("babel-plugin-syntax-flow"),
      require.resolve("babel-plugin-syntax-function-bind"),
      require.resolve("babel-plugin-syntax-jsx"),
      require.resolve("babel-plugin-syntax-trailing-function-commas"),
      require.resolve("babel-plugin-transform-async-generator-functions"),
      require.resolve("babel-plugin-transform-async-to-generator"),
      require.resolve("babel-plugin-transform-class-constructor-call"),
      require.resolve("babel-plugin-transform-class-properties"),
      require.resolve("babel-plugin-transform-decorators"),
      require.resolve("babel-plugin-transform-decorators-legacy"),
      require.resolve("babel-plugin-transform-do-expressions"),
      require.resolve("babel-plugin-transform-es2015-duplicate-keys"),
      require.resolve("babel-plugin-transform-es2015-spread"),
      require.resolve("babel-plugin-transform-exponentiation-operator"),
      require.resolve("babel-plugin-transform-export-extensions"),
      // require.resolve("babel-plugin-transform-es2015-modules-amd"),
      // require.resolve("babel-plugin-transform-es2015-modules-commonjs"),
      // require.resolve("babel-plugin-transform-es2015-modules-umd"),
      // (2)去掉這個
      require.resolve("babel-plugin-transform-flow-strip-types"),
      require.resolve("babel-plugin-transform-function-bind"),
      require.resolve("babel-plugin-transform-object-assign"),
      require.resolve("babel-plugin-transform-object-rest-spread"),
      require.resolve("babel-plugin-transform-proto-to-assign"),
      require.resolve("babel-plugin-transform-react-display-name"),
      require.resolve("babel-plugin-transform-react-jsx"),
      require.resolve("babel-plugin-transform-react-jsx-source"),
      require.resolve("babel-plugin-transform-runtime"),
      require.resolve("babel-plugin-transform-strict-mode"),
    ]
  };
}

具體文件內容可以點擊 wcf 打包 babel 配置。當然也可以使用下面方式告訴 babel 預設不轉換模塊:

{
  "presets": [
    ["env", {
      "loose": true,
      "modules": false
    }]
  ]
}

這種方式要簡單的多。但是這種方式會存在副作用,即無法移除多余的類聲明。在使用 ES6 語法定義類時,類的成員函數會被添加到屬性 prototype,沒有什么方法能完全避免這次賦值,所以 Webpack 會認為我們添加到 prototype 上方法的操作也是對類的一種使用,導致無法移除多余的類聲明,編譯過程阻止了對類進行 Tree-shaking ,它僅對函數起作用。UglifyJS 不能夠分辨它僅僅是類聲明,還是其他有副作用的操作,因為 UglifyJS 不能做控制流分析。

Webpack 的 Tree-shaking 標記 VS rollup 標記區別

移除未使用代碼(Dead code elimination) VS 包含已使用代碼(live code inclusion)

Webpack 僅僅標記未使用的代碼而不移除,并且不將其導出到模塊外。它拉取所有用到的代碼,將剩余的(未使用的)代碼留給像 UglifyJS 這類壓縮代碼的工具來移除。UglifyJS 讀取打包結果,在壓縮之前移除未使用的代碼。而 Rollup 不同,它的打包結果只包含運行應用程序所必需的代碼。打包完成后的輸出并沒有未使用的類和函數,壓縮僅涉及實際使用的代碼。

基于 babel-minify-webpack-plugin(即 babili-webpack-plugin)移除多余的類聲明

babel-minify-webpack-plugin

能將 ES6 代碼編譯為 ES5,移除未使用的類和函數,這就像 UglifyJS 已經支持 ES6 一樣。babel-minify 會在編譯前刪除未使用的代碼。在編譯為 ES5 之前,很容易找到未使用的類,因此 Tree-shaking 也可以用于類聲明,而不再僅僅是函數。若看下 babili-webpack-plugin 的代碼,會看到下面兩句:

import { transform } from 'babel-core';
import babelPresetMinify from 'babel-preset-minify';

首先是babel-preset-minify,可以看到其內部會調用如 babel-plugin-minify-dead-code-elimination 、 babel-plugin-minify-type-constructors 等來判斷哪些代碼沒有被引用,進而可以在代碼沒有被編譯為 ES5 之前把它移除掉。而 babel-core 就是負責把處理后的 ES6 代碼繼續編譯為 ES5 代碼。

所以,我們只需用 babel-minify-webpack-plugin 替換 UglifyJS ,然后刪除 babel-loader (該 plugin 自己會處理 ES6 代碼,但是 jsx 處理需要自己添加 preset )即可。另一種方式是將babel-preset-minify作為 Babel 的預設,僅使用 babel-loader(移除 UglifyJS 插件,因為 babel-preset-minify 已經壓縮完成)。推薦使用第一種(插件的方式),因為當編譯器不是 Babel(如 Typescript)時,它也能生效。

module: {
  rules: []
},
plugins: [
  new BabiliPlugin()
  //替代 UglifyJS,它可以移除 ES6 的多余類聲明
]

我們需要將 ES6+ 代碼傳給 babel-minify ,否則它不會移除(未使用的)類。所以,這種方式就要求所有的第三方包都必須有 ES6 的代碼發布,否則無法移除。
######### 目前wcf沒有引入 babili-webpack-plugin
這種情況下我們依然會對類的代碼打包成為 ES5,然后交給 UglifyJS 處理,比如下面的例子:

//imported.js
export function foo() {
    return 'foo';
}
export function bar() {
    return 'bar';
}
export function ql(){
  return 'ql'
}
export class Test{
 toString(){
   return 'test';
 }
}
export class Test1{
 toString(){
   return 'test1';
 }
}
//index.js
import {foo} from './imported';
let elem = document.getElementById('app');
elem.innerHTML = `Output: ${foo()}`;

打包后的結果如下:

/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
/* harmony export (immutable) */ 
__webpack_exports__["a"] = foo;
/* unused harmony export bar */
/* unused harmony export ql */
/* unused harmony export Test */
/* unused harmony export Test1 */
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0_babel_runtime_helpers_classCallCheck__ = __webpack_require__(8);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0_babel_runtime_helpers_classCallCheck___default = __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0_babel_runtime_helpers_classCallCheck__);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1_babel_runtime_helpers_createClass__ = __webpack_require__(9);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1_babel_runtime_helpers_createClass___default = __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_1_babel_runtime_helpers_createClass__);
function foo() {
  return 'foo';
}
function bar() {
  return 'bar';
}
function ql() {
  return 'ql';
}
var Test = function () {
  function Test() {
    __WEBPACK_IMPORTED_MODULE_0_babel_runtime_helpers_classCallCheck___default()(this, Test);
  }
  __WEBPACK_IMPORTED_MODULE_1_babel_runtime_helpers_createClass___default()(Test, [{
    key: 'toString',
    value: function toString() {
      return 'test';
    }
  }]);

  return Test;
}();
var Test1 = function () {
  function Test1() {
    __WEBPACK_IMPORTED_MODULE_0_babel_runtime_helpers_classCallCheck___default()(this, Test1);
  }

  __WEBPACK_IMPORTED_MODULE_1_babel_runtime_helpers_createClass___default()(Test1, [{
    key: 'toString',
    value: function toString() {
      return 'test1';
    }
  }]);
  return Test1;
}();
})

此時通過查看harmony export部分,我們知道 Webpack 導出的僅僅是用到的 foo 模塊而已,而其他的不管是多余的函數聲明還是多余的類聲明都是被標記為無用代碼('unused')。通過這種方式打包,經過 UglifyJS 處理就會將類 Test 1、Test 2 的代碼移除,其實事實并不是這樣,經過 UglifyJS 處理后多余的函數是沒有了,但是多余的類聲明打包成的函數代碼依然存在!依然存在!依然存在!
終極解決方法:使用babel-minify-webpack-plugin,即 babili-webpack-plugin。完整實例代碼可以參考這里,而目前wcf沒有采用這種策略,所以多余的 class 是無法去除的。目前,我覺得這種策略是可以接受的,因為第三方發布的包很少是使用 class 發布,而都是編譯為 ES5 代碼后發布的,所以通過 UglifyJS 這種策略已經足夠了。當然,也可以使用babel-preset-minify來將代碼壓縮作為你的預設,這種方式在獨立封裝自己的打包工具的時候比較有用,它是所有 babel 代碼壓縮插件的集合。

Tree-shaking 的局限性

這一部分都是自己的理解,但是基于這樣一個事實:

import {sortBy} from "lodash";

通過 import 引入 sortBy 方法以后,以為僅僅是引入了該方法而已,但是實際上把 concat 等函數都引入了。因為 import 是基于 ES6 的靜態語法分析,而 lodash 第三方包導出的時候并不是基于 ES6 的 import/export 機制,代碼如下:

var _ = runInContext();
  if (typeof define == 'function' && typeof define.amd == 'object' && define.amd) {
    root._ = _;
    define(function() {
      return _;
    });
  }
  else if (freeModule) {
    // Export for Node.js.
    (freeModule.exports = _)._ = _;
    // Export for CommonJS support.
    freeExports._ = _;
  }
  else {
    // Export to the global object.
    root._ = _;
  }
}.call(this));

所以,我們在引入一個 lodash 模塊的時候應該使用下面的模式:
import sortBy from 'lodash/sortBy';

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,460評論 6 538
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,067評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,467評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,468評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,184評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,582評論 1 325
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,616評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,794評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,343評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,096評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,291評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,863評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,513評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,941評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,190評論 1 291
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,026評論 3 396
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,253評論 2 375

推薦閱讀更多精彩內容

  • 無意中看到zhangwnag大佬分享的webpack教程感覺受益匪淺,特此分享以備自己日后查看,也希望更多的人看到...
    小小字符閱讀 8,214評論 7 35
  • 作者:小 boy (滬江前端開發工程師)本文原創,轉載請注明作者及出處。原文地址:https://www.smas...
    iKcamp閱讀 2,767評論 0 18
  • 最近在學習 Webpack,網上大多數入門教程都是基于 Webpack 1.x 版本的,我學習 Webpack 的...
    My_Oh_My閱讀 8,199評論 40 247
  • 前端將大型項目分成一個個單獨的模塊,一般封裝好的每個模塊都會實現一個目的明確的完成的功能。如何處理這些模塊以及模塊...
    pixels閱讀 3,435評論 1 14
  • 寫在開頭 先說說為什么要寫這篇文章, 最初的原因是組里的小朋友們看了webpack文檔后, 表情都是這樣的: (摘...
    Lefter閱讀 5,311評論 4 31