原文:Hidden performance implications of Object.defineProperty() 链接:https://humanwhocodes.com/blog/2015/11/performance-implication-object-defineproperty/ 翻译:Robin

最近我一直在做一个项目 Espree,基于 Acorn 的 ESLint 的解析器。其中,我碰到了一个关于 Object.defineProperty() 的有趣的性能问题。似乎任何调用 Object.defineProperty() 都会在 V8(包括Node.js 和 Chrome)上对性能带来负面影响。调查揭露了一些有趣的结果。

问题

当我第一次对 ESLint 进行性能测试时我注意到一个问题,当使用基于 Acorn 的 Espree 时多了 500 毫秒。使用 Espree 最新的版本(v2.2.5),ESLint 的性能测试总在 2500 毫秒内完成(你可以克隆ESLint仓库并运行 npm run perf)。当切换到基于 Acorn 的 Espree时,开销超过了 3000 毫秒。500 毫秒的开销很大,无疑会影响到 ESLint 的用户,因此我决定找出原因所在。

调查

使用 ESLint 的 profiling 命令(npm run profile)在 Chrome 的 profiler中运行 ESLint。然后,一些东西立刻吸引了我。

Object.defineProperty()的隐藏性能影响 - 图1

如你所见,esprimaFinishNode() 函数占运行时间超过了 33%。该函数使用 Acorn 生成的 AST 节点(就像Esprima 的AST节点)。我花了一分钟发现函数中一些不适当的操作调用了 Object.defineProperty()。

Acorn 在 AST 节点上增加了 start 和 end 属性来追踪位置。这些属性无法去除,因为 Acorn 在内部使用来决定其他节点。绕开去除的解决方法,Espree 使用 Object.defineProperty() 把属性定义为不可枚举,像这样:

  1. Object.defineProperty(node, "start", { enumerable: false });
  2. Object.defineProperty(node, "end", { enumerable: false });

通过定义为不可枚举属性,JSON序列化输出可以满足Esprima并且不影响for-in 循环语句。不幸的是,这恰好是造成性能问题的因素。我注释了这两行,运行 profile 后变得有些不同:

Object.defineProperty()的隐藏性能影响 - 图2

结果,esprimaFinishNode() 不再是最耗时的,占比少于 5%。不同的地方在于两次 Object.defineProperty() 调用。

再进一步

我很想设置 start 和 end 为不可枚举,因此我在 esprimaFinishNode() 中使用 Object.defineProperty() 方法尝试了几种替代方案。

首先,我使用 Object.defineProperties() 方法代替多次调用 Object.defineProperty()。我的想法是可能每次调用 Object.defineProperty() 都会招致性能损耗,因而使用单次调用取代两次调用以消除损耗。这没有带来任何改变,因此我总结这里性能损耗的原因不在于 Object.defineProperty() 调用的次数,而是事实上该函数是否被调用。

我想起了我之前看过的关于V8优化的内容,我想性能损耗可能是改变已定义的对象模型的结果。可能改变内容的属性便足以能够改变V8中的对象模型,这会造成一些优化路径的改变。我觉得理论上是这样。

第一个测试的源代码看起来是这样的:

  1. // Slowest: ~3000ms
  2. var node = new Node();
  3. Object.defineProperty(node, "start", { enumerable: false });
  4. Object.defineProperty(node, "end", { enumerable: false });

就像之前提到的那样,ESLint性能测试中可能需要大约3000毫秒。我做的第一件事就是把Object.defineProperty()移到Node构造函数中(用来创建新的AST节点)。我认为应该在构造器中定义模型更好,以避免在生成后改变模型带来的性能损耗。第二段代码如下:

  1. // A bit faster: ~2800ms
  2. function Node() {
  3. this.start = 0;
  4. this.end = 0;
  5. Object.defineProperty(node, "start", { enumerable: false });
  6. Object.defineProperty(node, "end", { enumerable: false });
  7. }

这的确带来了性能的提升,耗时从3000毫秒来到了2800毫秒。仍然比2500毫秒差,但是思路是对的。

接下来,我怀疑如果创建一些属性并定义为可枚举的应该比使用Object.defineProperty() 创建属性并定义为可枚举的慢。因此,有了下面的代码:

  1. // Faster: ~2650ms
  2. function Node() {
  3. Object.defineProperties(this, {
  4. start: { enumerable: false, value: pos, writable: true, configurable: true },
  5. end: { enumerable: false, value: pos, writable: true, configurable: true }
  6. });
  7. }

该版本进一步降低了性能测试耗时,大约在2650毫秒。降低到2500毫秒最简单的方式是什么?仅需要把属性设置为可枚举:

  1. // Fastest: ~2500ms
  2. function Node() {
  3. this.start = 0;
  4. this.end = 0;
  5. }

是的,不使用 Object.defineProperty() 是最好的方式。

思考

最令我吃惊的是基本上没有真正有效的方式将属性设置为不可枚举,尤其是直接对this直接增加一个新的属性。如果你必须使用Object.defineProperty(),最好是考虑在构造器里面而不是外面。然而,考虑到性能问题,似乎最好是避免使用Object.defineProperty()。

很幸运我进行了ESLint的性能测试,测试是在相当大的Javascript文件上进行的,这能够减少问题的数量。我不确定一个的孤立的benchmark能否证明对于ESlint来说这是否是一个问题。

参考