你的Tree-Shaking并没什么卵用

23,850 阅读17分钟

本文将探讨tree-shaking在当下(webpack@3, babel@6 以下)的现状,以及研究为什么tree-shaking依旧举步维艰的原因,最终总结当下能提高tree-shaking效果的一些手段。

Tree-Shaking这个名词,很多前端coder已经耳熟能详了,它代表的大意就是删除没用到的代码。这样的功能对于构建大型应用时是非常好的,因为日常开发经常需要引用各种库。但大多时候仅仅使用了这些库的某些部分,并非需要全部,此时Tree-Shaking如果能帮助我们删除掉没有使用的代码,将会大大缩减打包后的代码量。

Tree-Shaking在前端界由rollup首先提出并实现,后续webpack在2.x版本也借助于UglifyJS实现了。自那以后,在各类讨论优化打包的文章中,都能看到Tree-Shaking的身影。

许多开发者看到就很开心,以为自己引用的elementUI、antd 等库终于可以删掉一大半了。然而理想是丰满的,现实是骨干的。升级之后,项目的压缩包并没有什么明显变化。

我也遇到了这样的问题,前段时间,需要开发个组件库。我非常纳闷我开发的组件库在打包后,为什么引用者通过ES6引用,最终依旧会把组件库中没有使用过的组件引入进来。

下面跟大家分享下,我在Tree-Shaking上的摸索历程。

Tree-Shaking的原理

这里我不多冗余阐述,直接贴百度外卖前端的一篇文章:Tree-Shaking性能优化实践 - 原理篇

如果懒得看文章,可以看下如下总结:

  1. ES6的模块引入是静态分析的,故而可以在编译时正确判断到底加载了什么代码。
  2. 分析程序流,判断哪些变量未被使用、引用,进而删除此代码。

很好,原理非常完美,那为什么我们的代码又删不掉呢?

先说原因:都是副作用的锅!

副作用

了解过函数式编程的同学对副作用这词肯定不陌生。它大致可以理解成:一个函数会、或者可能会对函数外部变量产生影响的行为。

举个例子,比如这个函数:

function go (url) {
  window.location.href = url
}

这个函数修改了全局变量location,甚至还让浏览器发生了跳转,这就是一个有副作用的函数。

现在我们了解了副作用了,但是细想来,我写的组件库也没有什么副作用啊,我每一个组件都是一个类,简化一下,如下所示:

// componetns.js
export class Person {
  constructor ({ name, age, sex }) {
    this.className = 'Person'
    this.name = name
    this.age = age
    this.sex = sex
  }
  getName () {
    return this.name
  }
}
export class Apple {
  constructor ({ model }) {
    this.className = 'Apple'
    this.model = model
  }
  getModel () {
    return this.model
  }
}
// main.js
import { Apple } from './components'

const appleModel = new Apple({
  model: 'IphoneX'
}).getModel()

console.log(appleModel)

用rollup在线repl尝试了下tree-shaking,也确实删掉了Person,传送门

可是为什么当我通过webpack打包组件库,再被他人引入时,却没办法消除未使用代码呢?

因为我忽略了两件事情:babel编译 + webpack打包

成也Babel,败也Babel

Babel不用我多解释了,它能把ES6/ES7的代码转化成指定浏览器能支持的代码。正是由于它,我们前端开发者才能有今天这样美好的开发环境,能够不用考虑浏览器兼容性地、畅快淋漓地使用最新的JavaScript语言特性。

然而也是由于它的编译,一些我们原本看似没有副作用的代码,便转化为了(可能)有副作用的。

比如我如上的示例,如果我们用babel先编译一下,再贴到rollup的repl,那么结果如下:传送门

如果懒得点开链接,可以看下Person类被babel编译后的结果:

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

var _createClass = function() {
  function defineProperties(target, props) {
    for (var i = 0; i < props.length; i++) {
      var descriptor = props[i];
      descriptor.enumerable = descriptor.enumerable || !1, descriptor.configurable = !0,
      "value" in descriptor && (descriptor.writable = !0), Object.defineProperty(target, descriptor.key, descriptor);
    }
  }
  return function(Constructor, protoProps, staticProps) {
    return protoProps && defineProperties(Constructor.prototype, protoProps), staticProps && defineProperties(Constructor, staticProps),
    Constructor;
  };
}()

var Person = 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;
}();

我们的Person类被封装成了一个IIFE(立即执行函数),然后返回一个构造函数。那它怎么就产生副作用了呢?问题就出现在_createClass这个方法上,你只要在上一个rollup的repl链接中,将Person的IIFE中的_createClass调用删了,Person类就会被移除了。至于_createClass为什么会产生副作用,我们先放一边。因为大家可能会产生另外一个疑问:Babel为什么要这样去声明构造函数的?

假如是我的话,我可能会这样去编译:

var Person = function () {
  function Person() {

  }
  Person.prototype.getName = function () { return this.name };
  return Person;
}();

因为我们以前就是这么写“类”的,那babel为什么要采用Object.defineProperty这样的形式呢,用原型链有什么不妥呢?自然是非常的不妥的,因为ES6的一些语法是有其特定的语义的。比如:

  1. 类内部声明的方法,是不可枚举的,而通过原型链声明的方法是可以枚举的。这里可以参考下阮老师介绍Class 的基本语法
  2. for...of的循环是通过遍历器(Iterator)迭代的,循环数组时并非是i++,然后通过下标寻值。这里依旧可以看下阮老师关于遍历器与for...of的介绍,以及一篇babel关于for...of编译的说明transform-es2015-for-of

所以,babel为了符合ES6真正的语义,编译类时采取了Object.defineProperty来定义原型方法,于是导致了后续这些一系列问题。

眼尖的同学可能在我上述第二点中发的链接transform-es2015-for-of中看到,babel其实是有一个loose模式的,直译的话叫做宽松模式。它是做什么用的呢?它会不严格遵循ES6的语义,而采取更符合我们平常编写代码时的习惯去编译代码。比如上述的Person类的属性方法将会编译成直接在原型链上声明方法。

这个模式具体的babel配置如下:

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

同样的,我放个在线repl示例方便大家直接查看效果:loose-mode

咦,如果我们真的不关心类方法能否被枚举,开启了loose模式,这样是不是就没有副作用产生,就能完美tree-shaking类了呢?

我们开启了loose模式,使用rollup打包,发现还真是如此!传送门

不够屌的UglifyJS

然而不要开心的太早,当我们用Webpack配合UglifyJS打包文件时,这个Person类的IIFE又被打包进去了? What???

为了彻底搞明白这个问题,我搜到一条UglifyJS的issue:Class declaration in IIFE considered as side effect,仔细看了好久。对此有兴趣、并且英语还ok的同学,可以快速去了解这条issue,还是挺有意思的。我大致阐述下这条issue下都说了些啥。

issue楼主-blacksonic 好奇为什么UglifyJS不能消除未引用的类。

UglifyJS贡献者-kzc说,uglify不进行程序流分析,所以不能排除有可能有副作用的代码。

楼主:我的代码没什么副作用啊。要不你们来个配置项,设置后,可以认为它是没有副作用的,然后放心的删了它们吧。

贡献者:我们没有程序流分析,我们干不了这事儿,实在想删除他们,出门左转 rollup 吼吧,他们屌,做了程序流分析,能判断到底有没有副作用。

楼主:迁移rollup成本有点高啊。我觉得加个配置不难啊,比如这样这样,巴拉巴拉。

贡献者:欢迎提PR。

楼主:别嘛,你们项目上千行代码,我咋提PR啊。我的代码也没啥副作用啊,您能详细的说明下么?

贡献者:变量赋值就是有可能产生副作用的!我举个例子:

var V8Engine = (function () {
  function V8Engine () {}
  V8Engine.prototype.toString = function () { return 'V8' }
  return V8Engine
}())
var V6Engine = (function () {
  function V6Engine () {}
  V6Engine.prototype = V8Engine.prototype // <---- side effect
  V6Engine.prototype.toString = function () { return 'V6' }
  return V6Engine
}())
console.log(new V8Engine().toString())

贡献者:V6Engine虽然没有被使用,但是它修改了V8Engine原型链上的属性,这就产生副作用了。你看rollup(楼主特意注明截至当时)目前就是这样的策略,直接把V6Engine 给删了,其实是不对的。

楼主以及一些路人甲乙丙丁,纷纷提出自己的建议与方案。最终定下,可以在代码上通过/*@__PURE__*/这样的注释声明此函数无副作用。

这个issue信息量比较大,也挺有意思,其中那位uglify贡献者kzc,当时提出rollup存在的问题后还给rollup提了issue,rollup认为问题不大不紧急,这位贡献者还顺手给rollup提了个PR,解决了问题。。。

我再从这个issue中总结下几点关键信息:

  1. 函数的参数若是引用类型,对于它属性的操作,都是有可能会产生副作用的。因为首先它是引用类型,对它属性的任何修改其实都是改变了函数外部的数据。其次获取或修改它的属性,会触发getter或者setter,而gettersetter是不透明的,有可能会产生副作用。
  2. uglify没有完善的程序流分析。它可以简单的判断变量后续是否被引用、修改,但是不能判断一个变量完整的修改过程,不知道它是否已经指向了外部变量,所以很多有可能会产生副作用的代码,都只能保守的不删除。
  3. rollup有程序流分析的功能,可以更好的判断代码是否真正会产生副作用。

有的同学可能会想,连获取对象的属性也会产生副作用导致不能删除代码,这也太过分了吧!事实还真是如此,我再贴个示例演示一下:传送门

代码如下:

// maths.js
export function square ( x ) {
	return x.a
}
square({ a: 123 })

export function cube ( x ) {
	return x * x * x;
}
//main.js
import { cube } from './maths.js';
console.log( cube( 5 ) ); // 125

打包结果如下:

function square ( x ) {
  return x.a
}
square({ a: 123 });

function cube ( x ) {
	return x * x * x;
}
console.log( cube( 5 ) ); // 125

而如果将square方法中的return x.a 改为 return x,则最终打包的结果则不会出现square方法。当然啦,如果不在maths.js文件中执行这个square方法,自然也是不会在打包文件中出现它的。

所以我们现在理解了,当时babel编译成的_createClass方法为什么会有副作用。现在再回头一看,它简直浑身上下都是副作用。

查看uglify的具体配置,我们可以知道,目前uglify可以配置pure_getters: true来强制认为获取对象属性,是没有副作用的。这样可以通过它删除上述示例中的square方法。不过由于没有pure_setters这样的配置,_createClass方法依旧被认为是有副作用的,无法删除。

那到底该怎么办?

聪明的同学肯定会想,既然babel编译导致我们产生了副作用代码,那我们先进行tree-shaking打包,最后再编译bundle文件不就好了嘛。这确实是一个方案,然而可惜的是:这在处理项目自身资源代码时是可行的,处理外部依赖npm包就不行了。因为人家为了让工具包具有通用性、兼容性,大多是经过babel编译的。而最占容量的地方往往就是这些外部依赖包。

那先从根源上讨论,假如我们现在要开发一个组件库提供给别人用,该怎么做?

如果是使用webpack打包JavaScript库

先贴下webpack将项目打包为JS库的文档。可以看到webpack有多种导出模式,一般大家都会选择最具通用性的umd方式,但是webpack却没支持导出ES模块的模式。

所以,假如你把所有的资源文件通过webpack打包到一个bundle文件里的话,那这个库文件从此与Tree-shaking无缘。

那怎么办呢?也不是没有办法。目前业界流行的组件库多是将每一个组件或者功能函数,都打包成单独的文件或目录。然后可以像如下的方式引入:

import clone from 'lodash/clone'

import Button from 'antd/lib/button';

但是这样呢也比较麻烦,而且不能同时引入多个组件。所以这些比较流行的组件库大哥如antd,element专门开发了babel插件,使得用户能以import { Button, Message } form 'antd'这样的方式去按需加载。本质上就是通过插件将上一句的代码又转化成如下:

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

这样似乎是最完美的变相tree-shaking方案。唯一不足的是,对于组件库开发者来说,需要专门开发一个babel插件;对于使用者来说,需要引入一个babel插件,稍微略增加了开发成本与使用成本。

除此之外,其实还有一个比较前沿的方法。是rollup的一个提案,在package.json中增加一个key:module,如下所示:

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

这样,当开发者以es6模块的方式去加载npm包时,会以module的值为入口文件,这样就能够同时兼容多种引入方式,(rollup以及webpack2+都已支持)。但是webpack不支持导出为es6模块,所以webpack还是要拜拜。我们得上rollup!

(有人会好奇,那干脆把未打包前的资源入口文件暴露到module,让使用者自己去编译打包好了,那它就能用未编译版的npm包进行tree-shaking了。这样确实也不是不可以。但是,很多工程化项目的babel编译配置,为了提高编译速度,其实是会忽略掉node_modules内的文件的。所以为了保证这些同学的使用,我们还是应该要暴露出一份编译过的ES6 Module。)

使用rollup打包JavaScript库

吃了那么多亏后,我们终于明白,打包工具库、组件库,还是rollup好用,为什么呢?

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

我们只要通过rollup打出两份文件,一份umd版,一份ES模块版,它们的路径分别设为mainmodule的值。这样就能方便使用者进行tree-shaking。

那么问题又来了,使用者并不是用rollup打包自己的工程化项目的,由于生态不足以及代码拆分等功能限制,一般还是用webpack做工程化打包。

使用webpack打包工程化项目

之前也提到了,我们可以先进行tree-shaking,再进行编译,减少编译带来的副作用,从而增加tree-shaking的效果。那么具体应该怎么做呢?

首先我们需要去掉babel-loader,然后webpack打包结束后,再执行babel编译文件。但是由于webpack项目常有多入口文件或者代码拆分等需求,我们又需要写一个配置文件,对应执行babel,这又略显麻烦。所以我们可以使用webpack的plugin,让这个环节依旧跑在webpack的打包流程中,就像uglifyjs-webpack-plugin一样,不再是以loader的形式对单个资源文件进行操作,而是在打包最后的环节进行编译。这里可能需要大家了解下webpack的plugin机制

关于uglifyjs-webpack-plugin,这里有一个小细节,webpack默认会带一个低版本的,可以直接用webpack.optimize.UglifyJsPlugin别名去使用。具体可以看webpack的相关说明

webpack =< v3.0.0 currently contains v0.4.6 of this plugin under webpack.optimize.UglifyJsPlugin as an alias. For usage of the latest version (v1.0.0), please follow the instructions below. Aliasing v1.0.0 as webpack.optimize.UglifyJsPlugin is scheduled for webpack v4.0.0

而这个低版本的uglifyjs-webpack-plugin使用的依赖uglifyjs也是低版本的,它没有uglifyES6代码的能力,故而如果我们有这样的需求,需要在工程中重新npm install uglifyjs-webpack-plugin -D,安装最新版本的uglifyjs-webpack-plugin,重新引入它并使用。

这样之后,我们再使用webpack的babel插件进行编译代码。

问题又来了,这样的需求比较少,因此webpack和babel官方都没有这样的插件,只有一个第三方开发者开发了一个插件babel-webpack-plugin。可惜的是这位作者已经近一年没有维护这个插件了,并且存在着一个问题,此插件不会用项目根目录下的.babelrc文件进行babel编译。有人对此提了issue,却也没有任何回应。

那么又没有办法,就我来写一个新的插件吧----webpack-babel-plugin,有了它之后我们就能让webpack在最后打包文件之前进行babel编译代码了,具体如何安装使用可以点开项目查看。注意这个配置需要在uglifyjs-webpack-plugin之后,像这样:

plugins: [
  new UglifyJsPlugin(),
  new BabelPlugin()
]

但是这样呢,有一个毛病,由于babel在最后阶段去编译比较大的文件,耗时比较长,所以建议区分下开发模式与生产模式。另外还有个更大的问题,webpack本身采用的编译器acorn不支持对象的扩展运算符(...)以及某些还未正式成为ES标准的特性,所以。。。。。

所以如果特性用的非常超前,还是需要babel-loader,但是babel-loader要做专门的配置,把还在es stage阶段的代码编译成ES2017的代码,以便于webpack本身做处理。

感谢掘金热心网友的提示,还有一个插件BabelMinifyWebpackPlugin,它所依赖的babel/minify也集成了uglifyjs。使用此插件便等同于上述使用UglifyJsPlugin + BabelPlugin的效果,如若有此方面需求,建议使用此插件。

总结

上面讲了这么多,我最后再总结下,在当下阶段,在tree-shaking上能够尽力的事。

  1. 尽量不写带有副作用的代码。诸如编写了立即执行函数,在函数里又使用了外部变量等。
  2. 如果对ES6语义特性要求不是特别严格,可以开启babel的loose模式,这个要根据自身项目判断,如:是否真的要不可枚举class的属性。
  3. 如果是开发JavaScript库,请使用rollup。并且提供ES6 module的版本,入口文件地址设置到package.json的module字段。
  4. 如果JavaScript库开发中,难以避免的产生各种副作用代码,可以将功能函数或者组件,打包成单独的文件或目录,以便于用户可以通过目录去加载。如有条件,也可为自己的库开发单独的webpack-loader,便于用户按需加载。
  5. 如果是工程项目开发,对于依赖的组件,只能看组件提供者是否有对应上述3、4点的优化。对于自身的代码,除1、2两点外,对于项目有极致要求的话,可以先进行打包,最终再进行编译。
  6. 如果对项目非常有把握,可以通过uglify的一些编译配置,如:pure_getters: true,删除一些强制认为不会产生副作用的代码。

故而,在当下阶段,依旧没有比较简单好用的方法,便于我们完整的进行tree-shaking。所以说,想做好一件事真难啊。不仅需要靠个人的努力,还需要考虑到历史的进程。

PS: 此文中涉及到的代码,我也传到了github,可以点击阅读原文下载查看。

--阅读原文

@丁香园F2E @相学长

--转载请先经过本人授权。