前端进阶(二十一)Tree-Sharking最佳实践

什么是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 特点:

  • 只能作为模块顶层的语句出现
  • import 的模块名只能是字符串常量
  • import binding 是 immutable的

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-Sharking最佳实践_第1张图片

  • rollup只处理函数和顶层的import/export变量,不能把没用到的类的方法消除掉
  • javascript动态语言的特性使得静态分析比较困难

关于副作用的讨论,有兴趣的同学可以看看这个: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,而gettersetter是不透明的,有可能会产生副作用。
  • uglify没有完善的程序流分析。它可以简单的判断变量后续是否被引用、修改,但是不能判断一个变量完整的修改过程,不知道它是否已经指向了外部变量,所以很多有可能会产生副作用的代码,都只能保守的不删除。
  • rollup有程序流分析的功能,可以更好的判断代码是否真正会产生副作用。

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

最佳实践

如果是使用webpack打包JavaScript库

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

import Button from 'antd/lib/button';

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

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

总之一句话:不好用。

使用rollup打包JavaScript库

打包工具库、组件库,还是rollup好用,为什么呢?

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

我们只要通过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的相关说明

插件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上能够尽力的事。

  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。

你可能感兴趣的:(JavaScript)