什么是Tree-Sharking?
直接帖上一篇百度外卖的文章:Tree-Shaking性能优化实践 - 原理篇
如果不想看长篇文章,我简单讲一下:简单说tree-sharking就是在前端在打包时,去掉不需要的代码。
Tree-shaking 较早由 Rich_Harris 的 rollup 实现,后来,webpack2 也增加了tree-shaking 的功能。其实在更早,google closure compiler 也做过类似的事情。
Tree-shaking的本质是消除无用的js代码。无用代码消除在广泛存在于传统的编程语言编译器中,编译器可以判断出某些代码根本不影响输出,然后消除这些代码,这个称之为DCE(dead code elimination)。Tree-shaking 是 DCE 的一种新的实现,Javascript同传统的编程语言不同的是,javascript绝大多数情况需要通过网络进行加载,然后执行,加载的文件大小越小,整体执行时间更短,所以去除无用代码以减少文件体积,对javascript来说更有意义。
Tree-shaking 和传统的 DCE的方法又不太一样,传统的DCE 消灭不可能执行的代码,而Tree-shaking 更关注宇消除没有用到的代码。
Dead Code 一般具有以下几个特征
•代码不会被执行,不可到达
•代码执行的结果不会被用到
•代码只会影响死变量(只写不读)
传统编译型的语言中,都是由编译器将Dead Code从AST(抽象语法树)中删除,那javascript中是由著名的代码压缩优化工具uglify完成删除DCE。
ES6 module 特点:
ES6模块依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析,这就是tree-shaking的基础。
Webpack 4.0 以前,Tree Shaking 对于那些无副作用的模块不会生效。Webpack 4.0 开始,Tree Shaking 对于那些无副作用的模块也会生效了。
消除实验
面向过程编程函数和面向对象编程是javascript最常用的编程模式和代码组织方式,从这两个方面来实验:
函数消除实验中,rollup和webpack都通过,符合预期
类消除实验中,rollup,webpack全军覆没,都没有达到预期
在函数和类消除试验中,google的closure compiler, tree-shaking的结果完美!
Google定义一整套注解规范Annotating JavaScript for the Closure Compiler,想更多了解的,可以去看下官网。侵入式这个就让人很不爽,Google Closure Compiler是java写的,和我们基于node的各种构建库不可能兼容(不过目前好像已经有nodejs版 Closure Compiler),Closure Compiler使用起来也比较麻烦,所以虽然效果很赞,但比较难以应用到项目中,迁移成本较大。
下面摘取了rollup核心贡献者的的一些回答:
关于副作用的讨论,有兴趣的同学可以看看这个:Tree shaking class methods · Issue #349 · rollup/rollupgithub.com
函数的副作用相对较少,顶层函数相对来说更容易分析,加上babel默认都是"use strict"严格模式,减少顶层函数的动态访问的方式,也更容易分析。
副作用是什么?
一个函数会、或者可能会对函数外部变量产生影响的行为。
举个栗子:
function go (url) {
window.location.href = url
}
这个函数修改了全局变量location,甚至还让浏览器发生了跳转,这就是一个有副作用的函数。
如下代码,可能会有副作用。
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,demo
但为什么当我通过webpack打包组件库,再被他人引入时,却没办法消除未使用代码呢?因为我忽略了两件事情:
babel编译 + webpack打包
一切的错误都来自babel,如下代码是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这个方法上。那babel为什么要采用Object.defineProperty
这样的形式呢,用原型链有什么不妥呢?自然是非常的不妥的,因为ES6的一些语法是有其特定的语义的。比如:
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 }]]
}
重新编译后的代码:示例代码,已经解决tree-sharking问题
UglifyJS+webpack打包
用Webpack配合UglifyJS打包文件时,这个Person类的IIFE又被打包进去了。
一条UglifyJS的issue:Class declaration in IIFE considered as side effect
其中的一段代码演示变量赋值的副作用:
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 给删了,其实是不对的。
这个issue中的几点关键信息:
getter
或者setter
,而getter
、setter
是不透明的,有可能会产生副作用。目前uglify可以配置pure_getters: true
来强制认为获取对象属性,是没有副作用的。这样可以通过它删除上述示例中的square
方法。不过由于没有pure_setters
这样的配置,_createClass
方法依旧被认为是有副作用的,无法删除。
最佳实践
目前业界流行的组件库多是将每一个组件或者功能函数,都打包成单独的文件或目录。然后可以像如下的方式引入:
import Button from 'antd/lib/button';
似乎是最完美的变相tree-shaking方案。唯一不足的是,对于组件库开发者来说,需要专门开发一个babel插件;对于使用者来说,需要引入一个babel插件,稍微略增加了开发成本与使用成本。
假如你把所有的资源文件通过webpack打包到一个bundle文件里的话,那这个库文件从此与Tree-shaking无缘。
总之一句话:不好用。
打包工具库、组件库,还是rollup好用,为什么呢?
我们只要通过rollup打出两份文件,一份umd版,一份ES模块版,它们的路径分别设为main
,module
的值。这样就能方便使用者进行tree-shaking。
那么问题又来了,使用者并不是用rollup打包自己的工程化项目的,由于生态不足以及代码拆分等功能限制,一般还是用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的相关说明
插件BabelMinifyWebpackPlugin,它所依赖的babel/minify也集成了uglifyjs。使用此插件便等同于上述使用UglifyJsPlugin + BabelPlugin的效果,如若有此方面需求,建议使用此插件。
总结
webpack 2.0 开始原生支持 ES Module,也就是说不需要 babel 把 ES Module 转换成曾经的 commonjs 模块了,想用上 Tree Shaking,请务必关闭 babel 默认的模块转义:
{
"presets": [
["env", {
"modules": false
}
}]
]
}
另外,Webpack 4.0 开始,Tree Shaking 对于那些无副作用的模块也会生效了。
如果你的一个模块在 package.json
中说明了这个模块没有副作用(也就是说执行其中的代码不会对环境有任何影响,例如只是声明了一些函数和常量):
{
"name": "your-module",
"sideEffects": false
}
那么在引入这个模块,却没有使用它时,webpack 会自动把它 Tree Shaking 丢掉:
import yourModule from 'your-module'
// 下面没有用到 yourModule
在当下阶段,在tree-shaking上能够尽力的事。
loose
模式,这个要根据自身项目判断,如:是否真的要不可枚举class的属性。module
字段。pure_getters: true
,删除一些强制认为不会产生副作用的代码。故而,在当下阶段,依旧没有比较简单好用的方法,便于我们完整的进行tree-shaking。