你的Tree-Shaking并没什么卵用

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

Tree-Shaking这个名词,不少前端coder已经耳熟能详了,它表明的大意就是删除没用到的代码。这样的功能对于构建大型应用时是很是好的,由于平常开发常常须要引用各类库。但大多时候仅仅使用了这些库的某些部分,并不是须要所有,此时Tree-Shaking若是能帮助咱们删除掉没有使用的代码,将会大大缩减打包后的代码量。javascript

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

许多开发者看到就很开心,觉得本身引用的elementUI、antd 等库终于能够删掉一大半了。然而理想是丰满的,现实是骨干的。升级以后,项目的压缩包并无什么明显变化。java

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

下面跟你们分享下,我在Tree-Shaking上的摸索历程。webpack

Tree-Shaking的原理

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

若是懒得看文章,能够看下以下总结:es6

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

很好,原理很是完美,那为何咱们的代码又删不掉呢?github

先说缘由:都是反作用的锅!web

反作用

了解过函数式编程的同窗对反作用这词确定不陌生。它大体能够理解成:一个函数会、或者可能会对函数外部变量产生影响的行为。npm

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

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语言特性。

然而也是因为它的编译,一些咱们本来看似没有反作用的代码,便转化为了(可能)有反作用的。

若是懒得点开连接,能够看下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. 若是是工程项目开发,对于依赖的组件,只能看组件提供者是否有对应上述三、4点的优化。对于自身的代码,除一、2两点外,对于项目有极致要求的话,能够先进行打包,最终再进行编译。
  6. 若是对项目很是有把握,能够经过uglify的一些编译配置,如:pure_getters: true,删除一些强制认为不会产生反作用的代码。

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

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

--阅读原文

@丁香园F2E @相学长

--转载请先通过本人受权。

相关文章
相关标签/搜索