聊一聊面试中经常被问到的Tree Shaking

时间:2022-07-24
本文章向大家介绍聊一聊面试中经常被问到的Tree Shaking,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

天下武功,唯快不破!最新版的 antd 以及 vue 都对 Tree Shaking 提供了支持。我们内部的组件在支持这部分功能时,也专门梳理了相关的特性。这是四月份写的文章了,长时间不用就会忘,复习一下!

JS 文件绝大多数需要通过网络进行加载,然后执行。DCE(dead code elimination)可以使得加载文件的大小更小,整体执行时间更短。tree shaking 就是通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如 importexport

原理

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 <lulu27753@163.com> (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