什么是 “Tree-shaking”
Tree-shaking,也被称为“live code inclusion”,是消除项目中并未实际使用到的代码的过程。它是一种消除无效代码的方式,但在优化输出内容大小方面可能比其他方法有效得多。这个名字源于模块内容(而非模块图)的抽象语法树(abstract syntax tree, AST)。Tree-shaking 算法首先标记所有相关语句,然后“摇动语法树”以删除所有无效代码。其思想类似于标记-清除垃圾收集算法。
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 不能在运行时修改),我们在打包时就可以确定哪些代码是我们需要的。
webpack
webpack 本身在打包时只能标记未使用的代码而不移除,而识别代码未使用标记并完成 tree-shaking 的 其实是 UglifyJS、BabelMinify、terser 这类压缩代码的工具。简单来说,就是压缩工具读取 webpack 打包结果,在压缩之前移除 bundle 中未使用的代码。
不同插件实现差别可以查考这篇文章: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 不会把这部分代码删除。
常见副作用代码
// 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 运行,所以我们通过打包工具,编译成浏览器能支持的语法,在语法转换的过程中就会引入具有副作用的代码。
将副作用代码的例子修改一下,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 编译后,输出以下代码,再把代码分别放在 rollup 和 terser 看,均没有被 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 模块导出
而 rollup 有以下这些优点
- 支持导出ES模块的包
- 支持程序流分析,能更加正确的判断项目本身的代码是否有副作用
因此对于一些组件库、工具库,会更倾向于使用 rollup 进行打包。
我们只需要通过 rollup 打出两份文件,一份 umd 版本,一份 ES 模块版,他们的路径分别设置为 main,module 的值,这样就方便使用者进行 Tree-shaking。
奇思妙想
我们回来再来讨论这个猜想:先进行 Tree-shaking ,再打包编译
结语
- Tree-shanking 依赖于ES6 import 和 export 静态分析能力,可以在编译阶段将不会被执行,执行的结果不会被用到,代码只会影响死变量(只写不读)的代码移除。
- 不同的打包工具实现 Tree-shaking 的方式都不一样
- 除了副作用的代码和打包工具的 sideEffect配置,在编译的过程中可能会产生有副作用的代码。比如将代码由 ES6 编译成 ES5
- 尽量少写会产生副作用的代码,但不是不需要副作用,只是在需要时限制它们
- 想要更好的 Tree-shaking 效果,尽量使用最新版本的编译器