什么是在Javascript中迭代对象属性的最快方式?

52
我知道可以像这样迭代对象的属性:

for (var propName in obj) { // do something with obj[propName] }

for (property in object)
{
    // do stuff
}

我也知道在Javascript中迭代数组最快的方法是使用递减的while循环:

var i = myArray.length;
while (i--)
{
    // do stuff fast
}

我想知道是否有类似于递减的 while 循环来迭代对象属性的方法。

编辑:关于与可枚举性有关的答案,我并不需要。


4
这似乎是过早优化的味道... 你确定这是你的代码中需要进行优化的部分吗? - Pat
8
没有一件事情是“需要”优化的。我正在处理一组由1万到2万个对象组成的集合,因此速度越快越好。 - Matt Ball
1
记得检查 hasOwnProperty(property),这样你就不会操作原型的成员(除非你当然想要这样做)。 - Dan Davies Brackett
1
@DDaviesBrackett for/in 循环不会枚举继承的属性/方法。 - Josh Stodola
2
@DDaviesBrackett,@Josh Stodola:for-in循环不会遍历内置属性和方法(特别是在ECMAScript规范中声明为“DontEnum”的属性和方法),但它包括已添加到原型的继承成员,因为脚本添加的成员无法声明为“DontEnum”。请注意,Crockford的示例是一个已添加到String.prototype的方法,而不是String.prototype的内置属性。 - NickFitz
显示剩余3条评论
8个回答

52

更新/简述;

事实证明,这是在幕后使得ajv成为最快的JSON验证器库的方法。

此外,有人将我的想法推向了一个新的高度,并将其用于加速“对对象属性求和”,跨浏览器谱系提高了100倍以上-在这里找到他的jsperf

进入图片描述

粉色条表示他的“预编译求和”方法,它只留下了所有其他方法和操作。

这个技巧是什么?

使用预编译求和方法,他使用了我的代码自动生成了这段代码:

var x = 0;
x += o.a;
x += o.b;
x += o.c;
// ...

这比这个要快得多:

var x = 0;
for (var key in o) {
  x += o[key];
}

...特别是当我们访问属性的顺序(a, b, c)与o隐藏类中的顺序匹配时,性能会更好。

下面是详细解释:

更快的对象属性循环

首先要说的是,for ... in循环是可以的,只有在涉及大量CPU和RAM使用的性能关键代码时才需要考虑这个问题。通常情况下,还有更重要的事情需要花时间处理。但是,如果你是一个性能狂热者,你可能会对这个近乎完美的替代方案感兴趣:

Javascript对象

通常,JS对象有两种用法:

  1. "字典",也称为 "关联数组",是具有不同属性集的通用容器,由字符串键索引。
  2. "常量类型的对象"(其中所谓的隐藏类始终相同)有固定顺序的一组固定属性。是的!虽然标准没有保证任何顺序,但现代VM实现都有一个(隐藏的)顺序来加速各种操作。在我们稍后探讨中始终维护这个顺序非常重要。

使用"常量类型的对象"而不是"字典类型"通常会更快,因为优化程序了解这些对象的结构。如果你想知道如何实现这个,你可能会想看看Vyacheslav Egorov的博客,这篇博客介绍了V8以及其他Javascript运行时是如何处理对象的。Vyacheslav在这篇博客文章中解释了Javascript的对象属性查找实现

循环遍历对象属性

默认的for ... in循环无疑是迭代对象所有属性的好选择。然而,for ... in可能会将您的对象视为具有字符串键的字典,即使它具有隐藏类型。在这种情况下,每次迭代都会有一个字典查找的开销,这通常实现为哈希表查找。在许多情况下,优化程序足够聪明,避免了这个问题,并且性能与属性名称的常量命名相当,但它并不保证设备可用。经常出现的情况是,优化程序无法帮助你,你的循环将比应该慢得多。最糟糕的是,有时候这是不可避免的,特别是当你的循环变得更加复杂时。优化器并不那么聪明(至少现在还不聪明!)。下面的伪代码描述了

for each key in o:                                // key is a string!
    var value = o._hiddenDictionary.lookup(key);  // this is the overhead
    doSomethingWith(key, value);

一个未展开、未优化的for ... in循环,循环遍历一个具有三个特定顺序属性 ['a', 'b', 'c'] 的对象,看起来像这样:

var value = o._hiddenDictionary.lookup('a');
doSomethingWith('a', value);
var value = o._hiddenDictionary.lookup('b');
doSomethingWith('b', value);
var value = o._hiddenDictionary.lookup('c');
doSomethingWith('c', value);

假设你无法优化doSomethingWith,根据阿姆达尔定律,只有在以下条件都成立时才能获得更高的性能:

  1. doSomethingWith比字典查找的开销要快得多。
  2. 你能够真正消除字典查找开销。

我们确实可以使用我所谓的预编译的迭代器来消除这个查找。它是一个专门的函数,用于迭代具有固定类型的所有对象,即具有一组固定顺序属性的类型,并对所有对象执行特定操作。 迭代器通过属性的正确名称显式调用回调(让我们称之为doSomethingWith)。因此,运行时始终可以利用类型的隐藏类,而无需依赖优化器的承诺。以下伪代码描述了如何针对任何具有三种按照给定顺序排列的属性['a', 'b', 'c']的对象使用预编译的迭代器:

doSomethingWith('a', o.a)
doSomethingWith('b', o.b)
doSomethingWith('c', o.c)

没有额外开销。我们不需要查找任何东西。编译器已经可以使用隐藏的类型信息轻松计算出每个属性的确切内存地址,甚至使用最缓存友好的迭代顺序。这也是使用 for ... in 和完美的优化器得到的最快代码。

性能测试

preliminary performance results

这个jsperf 显示预编译的迭代器方法比标准的for ... in 循环要快得多。请注意,加速在很大程度上取决于对象的创建方式和循环的复杂性。由于此测试只有非常简单的循环,因此有时可能观察不到太多加速。但是,在我的一些测试中,我能够看到预编译的迭代器的25倍加速;或者更确切地说, for ... in 循环的显着减速,因为优化器无法摆脱字符串查找。

随着更多测试结果的发布,我们可以对不同的优化器实现得出一些初步结论:

  1. 预编译的迭代器通常表现得更好,即使在非常简单的循环中也是如此。
  2. 在IE中,两种方法显示了最小的差异。感谢微软为编写一个不错的迭代优化器(至少对于这个特定问题)!
  3. 在Firefox中, for ... in 是远远最慢的。迭代优化器在那里表现不佳。

然而,测试具有非常简单的循环体。我仍在寻找一个测试用例,在其中优化器无法实现跨所有(或几乎所有)浏览器的常数索引。任何建议都非常受欢迎!

代码

JSFiddle在此处

以下的 compileIterator 函数为任何类型的(简单)对象预编译迭代器(暂时忽略嵌套属性)。迭代器需要一些额外的信息,表示它应该遍历的所有对象的确切类型。这样的类型信息通常可以表示为字符串属性名称的数组,以确切的顺序, declareType 函数采用该数组来创建一个方便的类型对象。如果您想看到更完整的示例,请参考 jsperf 条目。

//
// Fast object iterators in JavaScript.
//

// ########################################################################
// Type Utilities (define once, then re-use for the life-time of our application)
// ########################################################################

/**
  * Compiles and returns the "pre-compiled iterator" for any type of given properties.
  */
var compileIterator = function(typeProperties) {
  // pre-compile constant iteration over object properties
  var iteratorFunStr = '(function(obj, cb) {\n';
  for (var i = 0; i < typeProperties.length; ++i) {
    // call callback on i'th property, passing key and value
    iteratorFunStr += 'cb(\'' + typeProperties[i] + '\', obj.' + typeProperties[i] + ');\n';
  };
  iteratorFunStr += '})';

  // actually compile and return the function
  return eval(iteratorFunStr);
};

// Construct type-information and iterator for a performance-critical type, from an array of property names
var declareType = function(propertyNamesInOrder) {
  var self = {
    // "type description": listing all properties, in specific order
    propertyNamesInOrder: propertyNamesInOrder,

    // compile iterator function for this specific type
    forEach: compileIterator(propertyNamesInOrder),

    // create new object with given properties of given order, and matching initial values
    construct: function(initialValues) {
      //var o = { _type: self };     // also store type information?
      var o = {};
      propertyNamesInOrder.forEach((name) => o[name] = initialValues[name]);
      return o;
    }
  };
  return self;
};

这是我们如何使用它:

// ########################################################################
// Declare any amount of types (once per application run)
// ########################################################################

var MyType = declareType(['a', 'b', 'c']);


// ########################################################################
// Run-time stuff (we might do these things again and again during run-time)
// ########################################################################

// Object `o` (if not overtly tempered with) will always have the same hidden class, 
// thereby making life for the optimizer easier:
var o = MyType.construct({a: 1, b: 5, c: 123});

// Sum over all properties of `o`
var x = 0;
MyType.forEach(o, function(key, value) { 
  // console.log([key, value]);
  x += value; 
});
console.log(x);

JSFiddle此处


1
我很好奇在非Chrome浏览器中这种推理方式会有什么结果。 - Matt Ball
@MattBall 我更新了我的回答并对测试结果进行了初步分析。看起来不错! - Domi
更新:我最终清理了代码,并添加了一个 JSFiddle,以便更容易地入手 :) - Domi
请记住,虽然这降低了CPU成本,但增加了内存成本。如果编译一个巨大的对象,则 "eval" 函数可能包含大量操作。 - Gershom Maes
1
@GershomMaes 是的,有些微小的内存成本涉及其中。但是关于您的第二个参数,请不要忘记:eval 部分是“离线”计算成本(即在关键代码路径开始之前或第一次使用它时执行),而使用编译函数是唯一(更快)的“在线”计算成本(会影响您的关键路径)。 - Domi
毫无疑问!一旦编译完成,这将使您获得更快的处理速度。内存成本并不一定微不足道 - 它与所涉及对象的大小成比例。 - Gershom Maes

31

1) 有许多不同的方法可以枚举属性:

  • for..in(遍历对象及其原型链中可枚举的属性)
  • Object.keys(obj) 返回直接在对象上找到的可枚举属性数组(不包括原型链)
  • Object.getOwnPropertyNames(obj) 返回直接在对象上找到的所有属性(可枚举或不可枚举)的数组。
  • 如果您正在处理多个具有相同“形状”(一组属性)的对象,则可能有意义“预编译”迭代代码(请参见此处的其他答案)。
  • for..of 不能用于迭代任意对象,但可以与MapSet一起使用,它们都适用于某些用例的普通对象替代品。
  • ...

也许如果您陈述了原始问题,有人可以建议一种优化方式。

2) 我很难相信实际枚举所需的时间超过您在循环主体中执行属性操作的时间。

3) 您没有指定您正在开发的平台。答案可能会因此而异,可用的语言特性也因此而异。例如,在2009年左右的SpiderMonkey(Firefox JS解释器)中,如果您确实需要值而不是键,则可以使用for each(var x in arr)文档)。它比for (var i in arr) { var x = arr[i]; ... }要快。

V8在某个时刻恢复了for..in的性能,并随后修复了该问题。这是关于2017年V8中for..in内部工作的帖子:https://v8project.blogspot.com/2017/03/fast-for-in-in-v8.html

4) 您可能只是没有将其包含在代码片段中,但更快的执行for..in循环的方法是确保您在包含循环的函数内声明了要在循环中使用的变量,即:

//slower
for (property in object) { /* do stuff */ }

//faster
for (var property in object) { /* do stuff */ }

5) 相关于第4点:当我试图优化Firefox扩展时,我曾经注意到将一个紧密循环提取到单独的函数中可以改善其性能(链接)。(显然,并不意味着你应该总是这样做!)


3
应该将变量声明放在for循环外面,这样它就不会每次尝试创建一个新的函数局部变量。 - Eli Grey
1
你为什么这么说?这与我的直觉理解“var”的不同(它在运行时并不是“执行”,而是在函数开始执行之前进行扫描),我在规范中也没有看到任何关于这方面的内容。因此,我认为如果某个引擎确实存在这种情况,那么它应该可以(并且应该)在引擎中修复。 - Nickolay
1
我同意。“没有其他方法可以枚举属性,除了使用for..in”-即使在2009年也不是真的。[预编译拯救(性能)](https://dev59.com/q3I_5IYBdhLWcg3wAd82#25700742)! - Domi
2
for..in循环括号内声明变量与先前或在函数顶部声明它们没有区别。由于JavaScript具有函数作用域,即使在括号内声明变量,循环内的变量也会被提升到函数顶部。 - Vassilis Barzokas
1
@Nickolay 这个问题过于笼统,虽然.keys和getOwnPropertyNames之间有很大的区别,因为其中一个不会遍历非可枚举属性,而另一个会。此外,尽管这是一篇老回答,但这个论坛每天都有人访问它,所以我们有责任保证其正确性。顺便说一下,现在答案已经很完整了。 - Guilherme Ferreira
显示剩余3条评论

3

这很奇怪,它与Object.keys()有什么不同? - Alexander Mills
1
Keys 只获取可枚举的属性,getOwnPropertyNames 获取所有属性(除了像 proto 这样的隐藏属性)。 - Guilherme Ferreira

3

感谢您提供这篇清晰详细的文章链接,正是我所需要的 :) - OXiGEN

2
for/in循环是枚举Javascript对象属性的最佳方式。需要了解的是,它只会循环遍历“可枚举”属性,并且没有特定的顺序。并非所有属性都是可枚举的。通过自己的Javascript代码添加的所有属性/方法都是可枚举的,但是继承的预定义属性/方法(例如toString)通常不是可枚举的。
您可以像这样检查可枚举性...
var o = new Object();
alert(o.propertyIsEnumerable("toString"));

2

在JavaScript 1.7+中显式使用Iterator可能会更快或更慢。当然,这只会迭代一个对象的自有属性。使用ex === StopIteration替换ex instanceof StopIteration可能会使catch语句更快。

var obj = {a:1,b:2,c:3,d:4,e:5,f:6},
   iter = new Iterator(obj, true);

while (true) {
    try {
        doSomethingWithProperty(iter.next());
    } catch (ex if (ex instanceof StopIteration)) {
        break;
    }
}

1
抛出的异常会在循环尾部大大减慢速度。不过,对于非常大的对象/字典,这可能值得进行性能测试... - Domi
值得庆幸的是,V8最近发布了期待已久的try-catch循环优化。这使得代码在try / catch内部和外部同样快。 - Jack G

0

如果您不知道属性的名称,for..in是枚举它们的好方法。如果您知道,最好使用显式解引用。


-4
一个对象的属性在定义上是无序的。无序意味着没有所谓的“前面”,因此也就没有“后面”。

我对顺序毫不在意。当我说“类似于递减while循环”时,我指的是在速度提升方面有类似之处的东西。 - Matt Ball
实际上,对象属性的顺序是已经定义好的——它是添加的顺序。原型链上的属性顺序变得更加复杂。 - olliej
@olliej - 不,ECMAScript规范明确未定义属性的枚举顺序。它是依赖于实现的,可能在不同的对象之间和不同的浏览器中有所变化。某些浏览器中某些对象的观察顺序不能普遍被依赖。 - Tim Down
1
我本来想说它是在ES5中定义的,但实际上在最终的四月草案中仍然未定义(第12.6.4节for-in语句)。 - Nickolay
1
@ollie,@Tim Down:值得注意的是,即使没有添加、删除或修改任何属性,也不能保证对象的属性在两次连续迭代中以相同的顺序返回。我不知道是否有这样讨厌的实现,每次都以不同的随机生成顺序返回东西,但这样的实现实际上符合ECMAScript规范。任何依赖插入顺序作为枚举顺序的代码都是错误的;依赖未指定的行为是一个bug。 - NickFitz
@NickFitz:完全同意。 - Tim Down

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接