关于 Tree-shaking

1,061 阅读13分钟

什么是 “Tree-shaking”

Tree-shaking,也被称为“live code inclusion”,是消除项目中并未实际使用到的代码的过程。它是一种消除无效代码的方式,但在优化输出内容大小方面可能比其他方法有效得多。这个名字源于模块内容(而非模块图)的抽象语法树(abstract syntax tree, AST)。Tree-shaking 算法首先标记所有相关语句,然后“摇动语法树”以删除所有无效代码。其思想类似于标记-清除垃圾收集算法。

tree-shaking.jpeg

Tree-shaking 的原理

无用代码在我们的代码中其实十分常见,消除无用代码也就拥有了自己的专业术语 - dead code elimination(DCE)。 Tree-shaking 是 DCE 的一种新的实现,和传统的 DCE 的方法又不太一样,传统的 DCE 消灭不可能执行的代码,而 Tree-shaking 更关注消除没有用到的代码。

DCE

  • 代码不会被执行,不可到达
  • 代码执行的结果不会被用到
  • 代码只会影响死变量,只写不读

如下所示,定义了 add 和 sub 方法,但在 main.js 中只用到到 add 方法

// utils.js
export function add(a, b) {
    const c =  1 +  1; // 无用变量
    return a + b;
}

export function sub(a, b) { // 无用方法
    return a - b;
}

const d =  1 +  1; // 无用变量

// main.js
import { add } from './utils.js';

console.log(add(1,1));

sub 方法属于不会被执行的代码,常量声明 c、d 变量属于死变量,这部分代码不会被打包输出。

// output.js
function add(a, b) {
    return a + b;
}

console.log(add(1,1));

传统编译型的语言都是由编译器将 Dead Code 从 AST (抽象语法树)中删除。 Tree-shaking 更关注于消除那些引用了但并没有被使用的模块,这种消除原理依赖于 ES6 的模块特性,这也意味着被转换正 ES5 的代码是不能 Tree Shaking 的。

总的来说就是

  • 利用 ES Module 可以进行静态分析的特点来检测模块内容的导出、导入以及被使用的情况,保留 Live Code
  • 消除不会被执行和没有副作用(Side Effect) 的 Dead Code,即 DCE 过程

Tree-shaking 的实现

打包工具中的 Tree-shaking, 较早时候由 Rich_Harris 在 rollup 提出并实现。

后面从 Webpack2 开始, Webpack 借助 UglifyJS、BabelMinify、terser 等压缩工具 也实现了 Tree-shaking 功能。

rollup

rollup 是在编译打包过程中分析程序流,得益于 ES6 静态模块(exports 和 imports 不能在运行时修改),我们在打包时就可以确定哪些代码是我们需要的。

Tree-shaking-机制-rollup.png

webpack

webpack 本身在打包时只能标记未使用的代码而不移除,而识别代码未使用标记并完成 tree-shaking 的 其实是 UglifyJS、BabelMinify、terser 这类压缩代码的工具。简单来说,就是压缩工具读取 webpack 打包结果,在压缩之前移除 bundle 中未使用的代码。

Tree-shaking-机制-webpack.png

不同插件实现差别可以查考这篇文章:Webpack 实现 Tree shaking 的前世今生 总的来说,webpack 对代码进行标记,主要是对 import & export 语句标记为 3 类:

  • 所有 import 标记为 /* harmony import */
  • 所有被使用过的 export 标记为/* harmony export ([type]) */,其中 [type] 和 webpack 内部有关,可能是 binding, immutable 等等
  • 没被使用过的 export 标记为/* unused harmony export [FuncName] */,其中 [FuncName] 为 export 的方法名称

副作用

在计算机科学中,如果操作、函数或表达式在其本地环境之外修改某些状态变量值,则称其具有副作用。

Tree-Shaking 失效主要原因是函数存在副作用,当我们调用某个函数时,该函数除了返回值之外,还产生附加的影响,例如修改了可变数据结构或变量,修改参数,使用I/O,引发异常或中止错误,则将产生副作用。

当代码存在副作用时,Tree-shaking 不会把这部分代码删除。

常见副作用代码

I/O 调用

// utils.js
export function add(a, b) {
    return a + b;
}

export function sub(a, b) { // 无用方法
    console.log('aaa'); // 副作用代码
    return a - b;
}

const c = sub(1,1); // 死变量

// main.js
import { add } from './utils.js';
console.log(add(1,1));
// output.js
function add(a, b) {
    return a + b;
}

function sub(a, b) { // 无用方法
    console.log('aaa'); // 副作用代码
    return a - b;
}

sub(1,1); // 死变量

console.log(add(1,1));

操作引用变量

虽然变量没有被任何地方使用到,但是函数内操作了引用对象。如果把变量删除,可能会导致引用对象的值无法被正确设置。也即是说,删除有副作用的代码可能导致应用程序出现 bug 甚至 crash。

BOM、DOM 操作,抛出异常,操作函数范围之外的检索值等也可以理解为该类似的情况

// utils.js
export function add(a, b) {
    return a + b;
}

// 读写引用参数
function paramsObj(obj) {// 无外部引用 
   const c = obj.a;
   return c;
}
const c = paramsObj({});

// 读写全局作用域引用变量
outsideObj ={}
function readOutsideObj() { // 无外部引用
  const d = outsideObj.a;
  return d;
}
const d = readOutsideObj();

// 读写内部 new 的对象
function createObject() {  // 无外部引用
   const obj = new Object();
   const e = obj.a;
   return e;
}
const e = createObject();

// main.js
import { add } from './utils.js';
console.log(add(1,1));
// output.js
function add(a, b) {
    return a + b;
}

// 读写引用参数
function paramsObj(obj) {// 无外部引用 
   obj.a;
}
paramsObj({});

// 读写全局作用域引用变量
outsideObj ={};
function readOutsideObj() { // 无外部引用
  outsideObj.a;
}
readOutsideObj();

// 读写内部 new 的对象
function createObject() {  // 无外部引用
   const obj = new Object();
   obj.a;
}
createObject();

console.log(add(1,1));

磁盘检索

对于磁盘检索的方法,类似 Math、Date 等,都会产生副作用。但是在 rollup 和 webpack 的 tree-shaking 表现却不一样,rollup 能正确的识别出死变量 r,但 webpack 却不行,依旧把方法打包输出。

这里是由于 rollup 与 webpack 实现 tree-shaking 的方式不一样,对副作用代码的判定也不一样,而 rollup 有相对完善的程序流分析,因此可以更好地判断代码是否有副作用。

// utils.js
export function add(a, b) {
	return a + b;
}

function getNum() {
    // return Math.random();
    return Date.now(); 
}
const r = getNum();

// main.js
import { add } from './utils.js';
console.log(add(1,1));
// output-rollup.js
function add(a, b) {
    return a + b;
}
console.log(add(1,1));


// output-webpack.js
(() => {
	'use strict';
	Date.now(), console.log(2);
})();

sideEffects 配置

除了以上副作用代码不会被 shake 掉,开发过程中,可能会存在避免代码被 Shake 的情况,例如,引入 polyfill 和 css 代码。 一般编译器也会提供 sideEffects 相关配置,来告诉编译器不要去 shake 代码。

webpack 项目中,在一个模块的 package.json 中设置 sideEffects: false(默认值),就代表该模块不包含任何副作用,如果它没有被任何地方使用到,打包工具就会跳过对它的副作用分析。

// package.json
"sideEffects": [
  "**/*.css",
  "./esnext/index.js",
]

在 webpack 中配置 sideEffects: true 之后,webpack 在分析依赖时就会去识别 package.json 中的副作用标记 (sideEffects),以跳过那些未被使用且不包含副作用的导出模块。

// webpack.config.js
module.exports = {
  optimization: {
    sideEffects: true,
  },
};

注意,这里有两个 sideEffects 配置,一个是在 webpack config 中,一个是在 package.json 中。webpack 中的 sideEffects 表示是否要开启识别 package.json 中 sideEffects 标记的功能,而 package.json 中的 sideEffects 是为了告知打包工具该模块是否包含副作用或者包含哪些副作用

除了可以配置 sideEffects 外,在 webpack 中可以在函数调用之前增加一行注释 /*#__PURE__*/,通过这行注释,可以告诉 terser:这个函数调用是没有副作用的,请放心地删除它吧!相当于人工标记方法为纯函数。

const r = /*#__PURE__*/ getNum();

sideEffects 作用于模块层面,而 /*#__PURE__*/ 注释作用于代码语句层面

Tree-shaking 失效了?

通过了解 Tree-shaking 的机制后,编写的代码已经遵循 es6 标准,且没有副作用,使用的打包工具支持 Tree-shaking ,但是实际打包后,发现依旧引入无用代码。

其实依旧还是 副作用的锅

即便避免了上述的副作用代码的问题,但是在编译阶段依可能会引入副作用的代码。由于并不是所有浏览器都能支持 es6 运行,所以我们通过打包工具,编译成浏览器能支持的语法,在语法转换的过程中就会引入具有副作用的代码。

export default

将副作用代码的例子修改一下,add,sub 方法通过 export.default 导出,其余代码不变,再次打包。

// utils.js
function add(a, b) {
    return a + b;
}

function sub(a, b) { // 无用方法
    return a - b;
}

export default { add, sub }

// main.js
import utils from './utils.js';
console.log(utils.add(1,1));

这次,sub 和 add 都被加入到了打包后的文件中,Tree-Shaking 失败了。

// output.js
function add(a, b) {
    return a + b;
}

function sub(a, b) { // 无用方法
    return a - b;
}

var utils = { add, sub };

console.log(utils.add(1,1));

export default 打包后会作为一个对象整体。编译器会分析顶层对象的使用情况,并不会分析对象中的属性,所以 export default 要么就是整体引入,要么就是整体删除。

所以一般是不推荐使用 export default {}

es5 编译

为了能够使用浏览器和 Node.js 尚不支持的最新 JavaScript 特性,许多开发人员会在项目中使用 Babel。 然而在编译过程中,一些我们原本看似没有副作用的代码,便转化为了(可能)有副作用。

举个例子,使用 es6 的语法,声明 Greet 类

class Greet {
  greeting() {
    return "hello";
  }
}

经过 babel 编译后,输出以下代码,再把代码分别放在 rollupterser 看,均没有被 shake 掉

function _typeof(obj) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (obj) { return typeof obj; } : function (obj) { return obj && "function" == typeof Symbol && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }, _typeof(obj); }
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, _toPropertyKey(descriptor.key), descriptor); } }
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
function _toPropertyKey(arg) { var key = _toPrimitive(arg, "string"); return _typeof(key) === "symbol" ? key : String(key); }
function _toPrimitive(input, hint) { if (_typeof(input) !== "object" || input === null) return input; var prim = input[Symbol.toPrimitive]; if (prim !== undefined) { var res = prim.call(input, hint || "default"); if (_typeof(res) !== "object") return res; throw new TypeError("@@toPrimitive must return a primitive value."); } return (hint === "string" ? String : Number)(input); }
var Greet = function () {
  function Greet() {
    _classCallCheck(this, Greet);
  }
  _createClass(Greet, [{
    key: "greeting",
    value: function greeting() {
      return "hello";
    }
  }]);
  return Greet;
}();

Greet 类被封装成了一个IIFE(立即执行函数),然后返回一个构造函数。那它怎么就产生副作用了呢?问题就出现在_createClass这个方法上,将 Greet 的IIFE中的_createClass调用删了,Greet 类就会被移除了。

在 Babel 严格模式下,会遵循 es6 的语义去编译代码。就拿 Class 来说,你可以把它看成是 es5 构造函数的一个语法糖。可是它却比普通的构造函数多了许多限制,例如必须使用 new 关键字来调用、类内部的方法不可枚举等等,通过调用 Object.defineProperty 方法修改了传入的参数 target,这样就产生了副作用。

为避免这个问题 babel 提供 loose 模式,直译的话叫做宽松模式,它会不严格遵循ES6的语义,而采取更符合我们平常编写代码时的习惯去编译代码。

// .babelrc
{
  "presets": [["env", { "loose": true }]]
}

比如上述的 Greet 类的属性方法将会编译成直接在原型链上声明方法。(code)

var Greet = function () {
  function Greet() {}
  var _proto = Greet.prototype;
  _proto.greeting = function greeting() {
    return "hello";
  };
  return Greet;
}();

除了提供宽松模式外,Babel7 会给每个 class 都默认加上 /*#__PURE__*/注释 的方式来优化,所以在 teser 时,不用再担心 Class 不能被删除的问题。

var Greet = /*#__PURE__*/function () {
  function Greet() {}
  var _proto = Greet.prototype;
  _proto.greeting = function greeting() {
    return "hello";
  };
  return Greet;
}();

class 的编译问题,只是其中一个 es6 编译成 es5 的问题,再使用 javascript 新语法且编译成 es5 的时,就会有可能出现引入副作用代码的情况。具体看编译器是怎么处理转译,编译器的不同的版本、配置等,都会一定程度上影响最终的结果。

小结:

  • 使用支持 Tree-shaking 的打包工具,rollup、webpack 等,尽量使用最新版本的编译器
  • 使用 es 模式开发代码
  • 尽量少写产生副作用的代码

如何更好的 Tree-shaking

了解上述的问题之后,避免副作用就能更好的实现 Tree-shaking ,有人肯定会想到,既然 webpack 打包 babel 编译导致我们产生了副作用的代码,那我们可以先进行 Tree-shaking ,再打包编译
理论可行!但是仅限于处理项目本身的代码,对于外部依赖的 npm 包不行,因为大部分 npm 包更具有通用性,兼容性,或者大多都经过 babel 编译,而最占地方的往往就是这些外部依赖包。

但是我们还是可以通过以下这些方式来优化

按需引入

除了通过打包工具自行进行 Tree-shakink,我们还可以按需引入的方式来达成 “Tree-shaking”

import clone from 'lodash/clone'
import Button from 'antd/es/button';

像 antd、element 这样流行的组件库,专门开发了 babel 插件,使用户能以 imort {Button, Message} from 'antd 这样的方式去按需加载。本质上就是通过插件将上述代码转化成以下代码:

import Button from 'antd/es/buttonn';
import Message from 'antd/lib/button';

这样确实能解决全量引入一个库的问题。唯一不足的是,对于组件开发者来说,需要专门开发一个 babel 插件,对于使用者来说,需要额外引入一个 babel 插件,稍微增加开发成本与使用成本。

引入有 ES 模块入口的外部依赖

ES 模块逐渐成为标准和被更多的浏览器支持,很多外部依赖都逐渐支持 ES 模块的入口,在package.json 中增加一个key:module (由 rollup 提议)

// package.json
{
  "name": "my-package",
  "main": "dist/my-package.umd.js",
  "module": "dist/my-package.esm.js"
}

rollup 默认使用 ES 模块引入外部模块,webpack 可以配置优先以 ES 模块的方式去加载外部模块,设置 mainfields module 的值作为入口文件。

// webpack.config.js
module.exports = {
  resolve: {
    mainFields: [ 'module', 'main' ]
  }
}

使用 rollup 打包 javascript 库

webpack 目前还不完全支持 ES 模块导出 image.png

而 rollup 有以下这些优点

  • 支持导出ES模块的包
  • 支持程序流分析,能更加正确的判断项目本身的代码是否有副作用

因此对于一些组件库、工具库,会更倾向于使用 rollup 进行打包。

我们只需要通过 rollup 打出两份文件,一份 umd 版本,一份 ES 模块版,他们的路径分别设置为 main,module 的值,这样就方便使用者进行 Tree-shaking。

奇思妙想

我们回来再来讨论这个猜想:先进行 Tree-shaking ,再打包编译

结语

  • Tree-shanking 依赖于ES6 import 和 export 静态分析能力,可以在编译阶段将不会被执行,执行的结果不会被用到,代码只会影响死变量(只写不读)的代码移除。
  • 不同的打包工具实现 Tree-shaking 的方式都不一样
  • 除了副作用的代码和打包工具的 sideEffect配置,在编译的过程中可能会产生有副作用的代码。比如将代码由 ES6 编译成 ES5
  • 尽量少写会产生副作用的代码,但不是不需要副作用,只是在需要时限制它们
  • 想要更好的 Tree-shaking 效果,尽量使用最新版本的编译器

参考