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阅读 162,050评论 4 370
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,538评论 1 306
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 111,673评论 0 254
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,622评论 0 218
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,047评论 3 295
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,974评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,129评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,893评论 0 209
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,654评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,828评论 2 254
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,297评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,619评论 3 262
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,326评论 3 243
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,176评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,975评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,118评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,909评论 2 278

推荐阅读更多精彩内容

  • 无意中看到zhangwnag大佬分享的webpack教程感觉受益匪浅,特此分享以备自己日后查看,也希望更多的人看到...
    小小字符阅读 8,088评论 7 35
  • 作者:小 boy (沪江前端开发工程师)本文原创,转载请注明作者及出处。原文地址:https://www.smas...
    iKcamp阅读 2,712评论 0 18
  • 最近在学习 Webpack,网上大多数入门教程都是基于 Webpack 1.x 版本的,我学习 Webpack 的...
    My_Oh_My阅读 8,098评论 40 247
  • 前端将大型项目分成一个个单独的模块,一般封装好的每个模块都会实现一个目的明确的完成的功能。如何处理这些模块以及模块...
    pixels阅读 3,351评论 1 14
  • 写在开头 先说说为什么要写这篇文章, 最初的原因是组里的小朋友们看了webpack文档后, 表情都是这样的: (摘...
    Lefter阅读 5,236评论 4 31