JS中继承后的对象慢了100倍揭秘

790 阅读6分钟

一、背景

事情的起因是因为有位同事发现了我们的代码有性能问题,简单来说就是发现一个对象被new了100万次,执行时间达到了2s,直觉告诉我们这里肯定有问题,这个时间理论上是不应该有的,于是便开始了解密之旅。

涉案代码简化一下其实很简单,这里贴出来:

class Parent {
  a;
  constructor(a = 0) {
    this.a = a;
  }
}

class Child extends Parent {
  b;
  constructor(a = 0, b = 0) {
    super(a);
    this.b = b;
  }
}

为了测试这段代码的性能,我们写了一个简单的benchmark函数:

function bench(count) {
  (() => {
    let start = Date.now();
    for (let i = 0; i < count; i += 1) {
      new Child(i, i, i);
    }
    console.log(Date.now() - start);
  })();
}

最后执行:

bench(100 * 10000)

在我们的项目里面,这段代码执行时间需要2s左右

二、定位

我们都知道,现在大部分项目都会使用babel来支持低端浏览器,对于这个性能问题同事发现两个现象:

  1. 如果Child不继承Parent,那性能没有问题,这个benchmark大概时间在30ms左右
  2. 如果自己写一个简单的继承,那性能也没有问题,这个benchmark大概时间也在30ms左右
function ext(C, P) {
  C.prototype = Object.create(P.prototype);
  C.prototype.constructor = C;
}

function Parent(a) {
  this.a = a;
}
Parent.prototype.xx = function() {};

function Child(a, b) {
  Parent.call(this, a);
  this.b = b;
}
ext(Child, Parent);

简单版的继承大概就类似如上代码,那问题很明显了,出在babel上面,难道babel转译出来的代码有性能问题?

使用babel在线编译平台,我们看看这段代码最后是怎样的

"use strict";

function _inheritsLoose(subClass, superClass) { subClass.prototype = Object.create(superClass.prototype); subClass.prototype.constructor = subClass; _setPrototypeOf(subClass, superClass); }

function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }

var Base = function Base(a) {
  if (a === void 0) {
    a = 0;
  }

  this.a = a;
};

var Child = /*#__PURE__*/function (_Base) {
  _inheritsLoose(Child, _Base);

  function Child(a, b) {
    var _this;

    if (a === void 0) {
      a = 0;
    }

    if (b === void 0) {
      b = 0;
    }

    _this = _Base.call(this, a) || this;
    _this.b = b;
    return _this;
  }

  return Child;
}(Base);

简单看一下,也没有什么异常的地方,于是我们只好求助万能的google,很快就找到了一篇文章: stackoverflow.com/questions/4…

And it is also faster than babel's implementation! I have created something similar what babel does to extend a class and tested it in jsPerf. My top 5 test run result was very disappointing with Object.setPrototypeOf: 19% slower, 20 % slower and three times 21% slower.

可以看到,里面有人提出babel实现的继承比较慢,主要是因为Object.setPrototypeOf,甚至还搜到了一些文章说是Object.setPrototypeOf性能杀手

image.png

到这里似乎问题就解决了,我们直接验证一下是不是Object.setPrototypeOf导致的就好了,甚至心里有点确信应该就是这个导致的。

三、没那么简单

很快,同事就来反馈,Object.setPrototypeOf似乎对性能没有影响,我们使用编译后的代码运行了一下,时间差不多是在35ms,基本上没啥差距,看来问题没有那么简单。

现在我们大概知道这个问题应该不是一个通用的问题,大概率是和我们自己的项目构建有关系。那我们只能在我们自己项目中构造最小demo,其实一开始就应该在自己项目中构造最小demo来定位问题的,但是因为项目过于复杂,而且有sourcemap,怕麻烦没有直接去看编译后的问题。但是既然问题已经到了这一步,只能去用performance去看自己的代码了。

performance大法的确好用,我们很快就发现性能瓶颈的确是在new对象上,但是似乎我们项目构建的代码和babel在线构建的不一样。

"use strict";

function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); }

function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); }

function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }

function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }

function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } else if (call !== void 0) { throw new TypeError("Derived constructors may only return object or undefined"); } return _assertThisInitialized(self); }

function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; }

function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } }

function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }

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

var Base = function Base() {
  var a = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;

  _classCallCheck(this, Base);

  this.a = a;
};

var Child = /*#__PURE__*/function (_Base) {
  _inherits(Child, _Base);

  var _super = _createSuper(Child);

  function Child() {
    var _this;

    var a = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
    var b = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;

    _classCallCheck(this, Child);

    _this = _super.call(this, a);
    _this.b = b;
    return _this;
  }

  return Child;
}(Base);

这一段代码如果运行100万次,时间的确需要2s,感兴趣的读者可以直接复制代码运行试试,并且performance会显示_createSuperInternal这个函数是最耗时的,那这个函数是怎么来的,我们简化一下代码看看

function _createSuper(Derived) { 
    var hasNativeReflectConstruct = _isNativeReflectConstruct();
    return function _createSuperInternal() {
        var Super = _getPrototypeOf(Derived), result;
        if (hasNativeReflectConstruct) {
            var NewTarget = _getPrototypeOf(this).constructor;
            result = Reflect.construct(Super, arguments, NewTarget);
        } else {
            result = Super.apply(this, arguments);
        }
        return _possibleConstructorReturn(this, result);
     };
  }

var Child = /*#__PURE__*/function (_Base) {
  _inherits(Child, _Base);

  var _super = _createSuper(Child);

  function Child() {
    // ...
    _this = _super.call(this, a);
    _this.b = b;
    return _this;
  }

  return Child;
}(Base);

可以看到,_createSuperInternal其实就是在调用父类的构造函数,对比一下一开始性能没问题的版本:

var Child = /*#__PURE__*/function (_Base) {
  _inheritsLoose(Child, _Base);

  function Child(a, b) {
    var _this;
    // 差异在这里
    _this = _Base.call(this, a) || this;
    return _this;
  }

  return Child;
}(Base);

区别就在于继承父类的时候,有问题的版本是用_super.call,没问题的版本是用_Base.call,继续跟踪一下就会发现,性能的根本在这行代码:

result = Reflect.construct(Super, arguments, NewTarget);

关于Reflect可以直接看MDN上面的规范。 现在问题找到了,那么接下来怎么解决呢:

一种解决方案是我们不用继承,自然不会有这些问题,但是这只是在逃避问题。

另外一种解决方案是深入babel,看看到底是谁导致了构建结果的差异。

很快我们就发现,如果把babel-preset配置项里面的loose配置项改为false,那最终就会使用createSuper版本的继承,但是我们自己的项目构建检查了一遍,loose配置项值是true的。那肯定还有别的东西影响了,最大可能就是插件。我们项目构建比较复杂,用了很多工具,不知道用了多少插件,排查起来有点没有头绪。

这时只能使用笨办法:二分法了。我们在最终编译使用的配置文件地方打日志,首先将所有用到的插件全部打印出来,再通过一遍遍注释相关插件再编译,最终我们锁定了疑犯:@babel/plugin-transform-classes

打开插件官网,我们就发现:

When extending a native class (e.g., class extends Array {}), the super class needs to be wrapped. This is needed to workaround two problems:

  • Babel transpiles classes using SuperClass.apply(/* ... */), but native classes aren't callable and thus throw in this case.
  • Some built-in functions (like Array) always return a new object. Instead of returning it, Babel should treat it as the new this.

这完美符合我们的现状,翻到插件的配置项:

Consider migrating to the top level assumptions which offers granular control over various loose mode deductions Babel has applied.

好家伙,这就是我们配置的babel的loose属性为true,这里为什么没生效,原来他自己需要单独配置,说是在考虑用babel用的配置。

接下来的事情就比较简单了,找到谁用了这个插件,最终发现其实不是我们自己的构建代码使用了这个插件,是storybook使用了,并且不支持配置loose属性。那只能使用storybook的babel配置能力强行移除这个插件或者加上loose属性来解决问题。

四、总结

至此,问题所有都搞清楚了,因为使用了storybook,而它使用了@babel/plugin-transform-classes插件并且loose为false,最终构建的出来的代码是使用了createSuper包裹,底层再使用Reflect来new对象,从而导致性能慢了有100倍。

但是其实线上是没有这个问题的,因为线上没有使用storybook。

重要的不是问题本身,而是解决问题的过程,这个问题的解决也给我们带来了一些反思:

  1. 遇到性能问题最好的方式还是立即使用performance来定位问题,毕竟是在问题现场定位
  2. 二分法虽然有时候效率不高,但是其实挺万能的。一直觉得怎么定位bug其实是有学问的,结合经验进行合理的猜测、尝试,再配合常见的定位方式,往往能有不错的效果
  3. 前端还是需要有更广阔的视野,像这个问题,如果你对构建没有一些了解的话,查起来的确会一头雾水

最后,希望这个问题对大家有帮助,大家可以检查一下自己线上项目是否有类似问题~