聊一聊面试中经常被问到的Tree Shaking
天下武功,唯快不破!最新版的 antd 以及 vue 都对 Tree Shaking 提供了支持。我们内部的组件在支持这部分功能时,也专门梳理了相关的特性。这是四月份写的文章了,长时间不用就会忘,复习一下!
JS 文件绝大多数需要通过网络进行加载,然后执行。DCE(dead code elimination)可以使得加载文件的大小更小,整体执行时间更短。tree shaking 就是通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import 和 export。
原理
ESM
- import 只能作为模块顶层的语句出现 
- import 的模块名只能是字符串常量 
- import binding 是 immutable 的 
这就是区别于CMJ,ESM 独有的静态分析特性。等等,那什么是静态分析呢,就是不执行代码。CMJ 中的 require,只有执行以后才知道引用的是什么模块。
保证了依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析。静态分析会在绘制依赖图时做DCE,减少打包体积。
ESM 也支持动态引入,类似于下面这种引入方式是不支持Tree Shacking的。
if (false) {
  import('./a.js').then(() => {// ...})
} else {
  // ...
}
// antd.js
var emptyObject = {};
if (true) {
  Object.freeze(emptyObject);
}
module.exports = emptyObject;
Dead Code
Dead Code 通常是指:
- 代码不会被执行 
- 代码执行的结果不会被用到 
- 代码只会影响死变量(只写不读) 
// 导入并赋值给 JavaScript 对象,但在接下来的代码里没有用到
// 这就会被当做“死”代码,会被 tree-shaking
import Stuff from './stuff';
doSomething();
// 导入但没有赋值给 JavaScript 对象,也没有在代码里用到
// 这会被当做“死”代码,会被 tree-shaking
import './stuff';
doSomething();
// 全部导入 (不支持 tree-shaking)
import _ from 'lodash';
// 具名导入(支持 tree-shaking)
import { debounce } from 'lodash';
// 直接导入具体的模块 (支持 tree-shaking)
import debounce from 'lodash/lib/debounce';
// 导入并赋值给 JavaScript 对象,然后在下面的代码中被用到
// 这会被看作“活”代码,不会做 tree-shaking
import Stuff from './stuff';
doSomething(Stuff);
// 导入整个库,但是没有赋值给 JavaScript 对象,也没有在代码里用到
// 非常奇怪,这竟然被当做“活”代码,因为 Webpack 对库的导入和本地代码导入的处理方式不同。
import 'my-lib';
export { default as Title } from './Title';
export { default as Options } from './Options';
export { default as AddonArea } from './AddonArea';
export { default as Answer } from './AddonArea/Answer';
export { default as Analysis } from './AddonArea/Analysis';
export { default as OriginalText } from './AddonArea/OriginalText';
export { default as Labels } from './AddonArea/Labels';
这样的文件结构是无法进行 tree-shaking 的, 因为没有 import?!
自执行的模块 import
自执行模块我们通常会使用 import 'xxx' 来进行模块引用,而不进行显式的调用。因此模块本身就有副作用。
import 'utils/refresh'
对于这种模块可以这样处理:
- 在 sideEffects 中通过数组声明,使其在 Tree Shaking 的范围之外 
- 模块改造,暴露成员支持显式调用 
unused harmony export
如果该模块被标识为 unused harmony export,则说明没有外部引用使用到该成员,webpack 认为是可以安全去除的。
harmony export
部分被标识为 harmony export 的模块也会被去除。这个是跟 UglifyJS 的机制有关系。
没有提供导出成员的模块
// ./src/modules/edu-discount/seckill/index.ts
import * as SeckillTypes from './types';
export { SeckillTypes };
对于只有暴露的成员,但是没有被引用的成员,这种模块会被直接删除。
- [x] exports provided 
- [ ] exports used 
配置
babel的配置文件
{
  "presets": [
    ["env", {
      "modules": false  // 配置了这个,babel就不会像默认那样转变成 require 形式。
    }],
    "stage-2",
    "react"
  ]
}
为 webpack 进行 tree-shaking 创造了条件。
⚠️不能引用类似 @babel/plugin-transform-modules-commonjs会把模块编译成 commonjs 的插件;
webpack 的配置文件
webpack 4 通过 optimization 取代了4个常用的插件:
| 废弃插件 | optimization 属性 | 功能 | |
|---|---|---|---|
| UglifyjsWebpackPlugin | sideEffects | minimizer | Tree Shaking & Minimize | 
| ModuleConcatenationPlugin | concatenateModules | Scope hoisting | 生产环境默认开启 | 
| CommonsChunkPlugin | splitChunks | runtimeChunk | OccurrenceOrder | 
| NoEmitOnErrorsPlugin | NoEmitOnErrors | 编译出现错误时,跳过输出阶段 | 生产环境默认开启 | 
usedExports
Webpack 将识别出它认为没有被使用的代码,并在最初的打包步骤中给它做标记。
// Base Webpack Config for Tree Shaking
const config = {
 mode: 'production',
 optimization: {
  usedExports: true,
  minimizer: [
   new TerserPlugin({...}) // 支持删除死代码的压缩器
  ]
 }
};
package.json 的配置
用过 redux 的童鞋应该对纯函数不陌生,自然也就应该了解函数式编程,函数式编程中就有副作用一说。
照顾一下不知道的同学,那什么是副作用呢?
一个函数会、或者可能会对函数外部变量产生影响的行为。
具有副作用的文件不应该做 tree-shaking,因为这将破坏整个应用程序。比如全局样式表及全局的 JS 配置文件。
webpack 总会害怕把你要用的代码删除了,所以默认所有的文件都有副作用,不能被 Tree Shaking。
// 所有文件都有副作用,全都不可 tree-shaking
{
 "sideEffects": true
}
// 没有文件有副作用,全都可以 tree-shaking,即告知 webpack,它可以安全地删除未用到的 export。
{
 "sideEffects": false
}
// 除了数组中包含的文件外有副作用,所有其他文件都可以 tree-shaking,但会保留符合数组中条件的文件
{
 "sideEffects": [
   "*.css",
   "*.less"
 ]
}
所以,首先关闭你的 sideEffects,
直接通过 module.rules 中的 sideEffects 配置可缩小你的影响范围。
加了 sideEffect 配置后,构建出来的一些 IIFE 函数也会加上/PURE/注释,便于后续 treeshaking。
组件不支持DCE?

我们的组件用的是 father,可以看到其依赖的father-build 是基于 rollup 的,那就好办了。webpack 的 Tree Shaking 还是 copy 的 rollup家的。
关键是在应用组件的业务项目里面配置optimization.sideEffects: true
// webpack.config.js
const path = require('path')
const webpackConfig = {
  module : {
    rules: [
      {
        test: /\.(jsx|js)$/,
        use: 'babel-loader',
        exclude: path.resolve(__dirname, 'node_modules')
      }   
    ]
  },
optimization : {
  sideEffects: true,
  minimizer: [
    // 这里配置成空数组是为了使最终产生的 main.js 不被压缩
  ]
},
  plugins:[]
};
module.exports = webpackConfig;
// package.json
{
  "name": "treeshaking-test",
  "version": "0.1.0",
  "description": "",
  "main": "src/index.js",
  "scripts": {
    "build": "webpack --config webpack.config.js"
  },
  "author": "lu.lu  (https://github.com/lulu27753)" ,
  "license": "MIT",
  "dependencies": {
    "big-module": "^0.1.0",
    "big-module-with-flag": "^0.1.0",
    "webpack-bundle-analyzer": "^3.7.0"
  },
  "devDependencies": {
    "babel-preset-env": "^1.7.0",
    "webpack": "^4.43.0",
    "webpack-cli": "^3.3.11"
  }
}
// .babelrc
{
  "presets": [
    ["env", { "modules": false }]
  ]
}
可以看到最终打包后的文件如下:
// dist/main.js
"use strict";
// ESM COMPAT FLAG
__webpack_require__.r(__webpack_exports__);
// CONCATENATED MODULE: ./node_modules/big-module/es/a.js
var a = 'a';
// CONCATENATED MODULE: ./node_modules/big-module/es/b.js
var b = 'b';
// CONCATENATED MODULE: ./node_modules/big-module/es/c.js
var c = 'c';
// CONCATENATED MODULE: ./node_modules/big-module/es/index.js
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/a.js
var a_a = 'a';
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/b.js
var b_b = 'b';
// CONCATENATED MODULE: ./src/index.js
console.log(a, b, a_a, b_b);
/***/ })
/******/ ]);
可以很清楚的看到 big-module-with-flag 中的 c 模块被DCE了。
做个小小的改动,将 .babelrc 中的 modules 改为"commonjs"
{
  "presets": [
    ["env", { "modules": "commonjs" }]
  ]
}
"use strict";
// ESM COMPAT FLAG
__webpack_require__.r(__webpack_exports__);
// EXPORTS
__webpack_require__.d(__webpack_exports__, "a", function() { return /* reexport */ a; });
__webpack_require__.d(__webpack_exports__, "b", function() { return /* reexport */ b; });
__webpack_require__.d(__webpack_exports__, "c", function() { return /* reexport */ c; });
// CONCATENATED MODULE: ./node_modules/big-module/es/a.js
var a = 'a';
// CONCATENATED MODULE: ./node_modules/big-module/es/b.js
var b = 'b';
// CONCATENATED MODULE: ./node_modules/big-module/es/c.js
var c = 'c';
// CONCATENATED MODULE: ./node_modules/big-module/es/index.js
/***/ }),
/* 2 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
// ESM COMPAT FLAG
__webpack_require__.r(__webpack_exports__);
// EXPORTS
__webpack_require__.d(__webpack_exports__, "a", function() { return /* reexport */ a; });
__webpack_require__.d(__webpack_exports__, "b", function() { return /* reexport */ b; });
__webpack_require__.d(__webpack_exports__, "c", function() { return /* reexport */ c; });
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/a.js
var a = 'a';
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/b.js
var b = 'b';
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/c.js
var c = 'c';
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/index.js
/***/ })
/******/ ]);
结果是 CDE 失败!
将 modules 的值改回去,并升级big-module-with-flag为0.2.0。CDE 成功,可以打假一波了(?,网上很多文章都是基于webpack3的,过时了)
升级big-module-with-flag为0.5.0, 并更改 src/index.js
import { a as a1, b as b1 } from "big-module";
import { a as a2, b as b2, Apple  } from "big-module-with-flag";
console.log(a1, b1, a2, b2);
const appleModel = new Apple({model: 'IphoneX'}).getModel()
console.log(appleModel)
var Apple = /*#__PURE__*/function () {
  function Apple(_ref) {
    var model = _ref.model;
    _classCallCheck(this, Apple);
    this.className = 'Apple';
    this.model = model;
  }
  _createClass(Apple, [{
    key: "getModel",
    value: function getModel() {
      return this.model;
    }
  }]);
  return Apple;
}();
// CONCATENATED MODULE: ./src/index.js
console.log(a, b, es_a, es_b);
var appleModel = new Apple({
  model: 'IphoneX'
}).getModel();
console.log(appleModel);
DCE 成功!
var _bigModule = __webpack_require__(2);
var _bigModuleWithFlag = __webpack_require__(1);
console.log(_bigModule.a, _bigModule.b, _bigModuleWithFlag.a, _bigModuleWithFlag.b);
var appleModel = new _bigModuleWithFlag.Apple({
  model: 'IphoneX'
}).getModel();
console.log(appleModel);
/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
// ESM COMPAT FLAG
__webpack_require__.r(__webpack_exports__);
// EXPORTS
__webpack_require__.d(__webpack_exports__, "a", function() { return /* reexport */ es_a; });
__webpack_require__.d(__webpack_exports__, "b", function() { return /* reexport */ es_b; });
__webpack_require__.d(__webpack_exports__, "c", function() { return /* reexport */ es_c; });
__webpack_require__.d(__webpack_exports__, "Person", function() { return /* reexport */ Person; });
__webpack_require__.d(__webpack_exports__, "Apple", function() { return /* reexport */ Apple; });
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/a.js
var a = 'a';
/* harmony default export */ var es_a = (a);
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/b.js
var b = 'b';
/* harmony default export */ var es_b = (b);
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/c.js
var c = 'c';
/* harmony default export */ var es_c = (c);
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/Person.js
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }
var Person = /*#__PURE__*/function () {
  function Person(_ref) {
    var name = _ref.name,
        age = _ref.age,
        sex = _ref.sex;
    _classCallCheck(this, Person);
    this.className = 'Person';
    this.name = name;
    this.age = age;
    this.sex = sex;
  }
  _createClass(Person, [{
    key: "getName",
    value: function getName() {
      return this.name;
    }
  }]);
  return Person;
}();
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/Apple.js
function Apple_classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function Apple_defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
function Apple_createClass(Constructor, protoProps, staticProps) { if (protoProps) Apple_defineProperties(Constructor.prototype, protoProps); if (staticProps) Apple_defineProperties(Constructor, staticProps); return Constructor; }
var Apple = /*#__PURE__*/function () {
  function Apple(_ref) {
    var model = _ref.model;
    Apple_classCallCheck(this, Apple);
    this.className = 'Apple';
    this.model = model;
  }
  Apple_createClass(Apple, [{
    key: "getModel",
    value: function getModel() {
      return this.model;
    }
  }]);
  return Apple;
}();
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/index.js
/***/ }),
/* 2 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
// ESM COMPAT FLAG
__webpack_require__.r(__webpack_exports__);
// EXPORTS
__webpack_require__.d(__webpack_exports__, "a", function() { return /* reexport */ a; });
__webpack_require__.d(__webpack_exports__, "b", function() { return /* reexport */ b; });
__webpack_require__.d(__webpack_exports__, "c", function() { return /* reexport */ c; });
// CONCATENATED MODULE: ./node_modules/big-module/es/a.js
var a = 'a';
// CONCATENATED MODULE: ./node_modules/big-module/es/b.js
var b = 'b';
// CONCATENATED MODULE: ./node_modules/big-module/es/c.js
var c = 'c';
// CONCATENATED MODULE: ./node_modules/big-module/es/index.js
// .babelrc
{
  "presets": [["env", { "loose": false }]]
}
总结
webpack 官方号称提速 98%,其最重要的前提就是你的模块引入方式要是ESM,而不能是因为兼容性考虑的UMD实现。
如果你是一个第三方库的维护者,请人性化的按业界规范提供ES版本,同时配置 sideEffects: false.
- Webpack 只有在压缩代码的时候会 tree-shaking, 通常就指是生产环境 
- 代码的 module 引入必须是 import 的引入方式,也就意味着被转换成 ES5 的代码是无法支持 tree-shaking 的。 
满足了文件要求后,简单来说你需要做如下配置操作
- [x] 在 package.json 文件中将 sideEffects 设为 false 
- [x] 将css相关 loader中 sideEffects 设为 true 
- [x] 让@babel/preset-env 不编译 ES6 模块语句 
- [ ] 使用TerserPlugin,js代码压缩插件(webpack 自带) 
参考
webpack 官方文档:https://webpack.docschina.org/guides/tree-shaking/
官方DEMO:https://github.com/webpack/webpack/tree/master/examples/side-effects
webpack 新插件系统如何工作:https://medium.com/webpack/the-new-plugin-system-week-22-23-c24e3b22e95
Tree-Shaking原理:https://juejin.im/post/5a4dc842518825698e7279a9
组件没办法DCE?:https://zhuanlan.zhihu.com/p/32831172


