Object.keys() 的返回顺序是怎样的?但是很多回答都只流于表面,甚至还有这样片面的回答:数字排前面,字符串排后面。
我们先来看看在 MDN 上关于 Object.keys() 的描述:
Object.keys() 方法会返回一个由一个给定对象的自身可枚举属性组成的数组,数组中属性名的排列顺序和正常循环遍历该对象时返回的顺序一致 。
emmm… 并没有直接告诉我们输出顺序是什么,不过我们可以看看上面的 Polyfill 是怎么写的:
if (!Object.keys) {
Object.keys = (function () {
var hasOwnProperty = Object.prototype.hasOwnProperty,
hasDontEnumBug = !{ toString: null }.propertyIsEnumerable("toString"),
dontEnums = ["toString", "toLocaleString", "valueOf","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","constructor",],
dontEnumsLength = dontEnums.length;
return function (obj) {
if ((typeof obj !== "object" && typeof obj !== "function") ||obj === null)
throw new TypeError("Object.keys called on non-object");
var result = [];
for (var prop in obj) {
if (hasOwnProperty.call(obj, prop)) result.push(prop);
}
if (hasDontEnumBug) {
for (var i = 0; i < dontEnumsLength; i++) {
if (hasOwnProperty.call(obj, dontEnums[i]))
result.push(dontEnums[i]);
}
}
return result;
};
})();
}
其实就是利用 for...in 来进行遍历,接下来我们可以再看看关于 for…in 的文档,然而里面也没有告诉我们顺序是怎样的。
既然 MDN 上没有,那我们可以直接看 ECMAScript 规范,通常 MDN 上都会附上关于这个 API 的规范链接,我们直接点开最新(Living Standard)的那个,下面是关于 Object.keys 的规范定义:
When the keys function is called with argument O, the following steps are taken:
Let obj be ? ToObject(O).
Let nameList be ? EnumerableOwnPropertyNames(obj, key).
Return CreateArrayFromList(nameList).
对象属性列表是通过 EnumerableOwnPropertyNames 获取的,这是它的规范定义:
The abstract operation EnumerableOwnPropertyNames takes arguments O (an Object) and kind (key, value, or key+value). It performs the following steps when called:
1、Let ownKeys be ? O.[OwnPropertyKeys].
2、Let properties be a new empty List.
3、For each element key of ownKeys, do
a. If Type(key) is String, then
1、Let desc be ? O.[GetOwnProperty].
2、If desc is not undefined and desc.[[Enumerable]] is true, theni. Assert: kind is key+value.
ii. Let entry be ! CreateArrayFromList(« key, value »).
iii. Append entry to properties.
a. If kind is key, append key to properties.
b. Else,
1、Let value be ? Get(O, key).
2、If kind is value, append value to properties.
3、Else4、Return properties.
敲黑板!这里有个细节,请同学们多留意,后面会考。
我们接着探索,OwnPropertyKeys 最终返回的 OrdinaryOwnPropertyKeys:
The [[OwnPropertyKeys]] internal method of an ordinary object O takes no arguments. It performs the following steps when called:
Return ! OrdinaryOwnPropertyKeys(O).
重头戏来了,关于 keys 如何排序就在 OrdinaryOwnPropertyKeys 的定义中:
The abstract operation OrdinaryOwnPropertyKeys takes argument O (an Object). It performs the following steps when called:
1、Let keys be a new empty List.
2、For each own property key P of O such that P is an array index, in ascending numeric index order, do
a. Add P as the last element of keys.
3、For each own property key P of O such that Type(P) is String and P is not an array index, in ascending chronological order of property creation, do
a. Add P as the last element of keys.
4、For each own property key P of O such that Type(P) is Symbol, in ascending chronological order of property creation, do
a. Add P as the last element of keys.
5、Return keys.
到这里,我们已经知道我们想要的答案,这里总结一下:
-
创建一个空的列表用于存放 keys
-
将所有合法的数组索引按升序的顺序存入
-
将所有字符串类型索引按属性创建时间以升序的顺序存入
-
将所有 Symbol 类型索引按属性创建时间以升序的顺序存入
-
返回 keys
这里顺便也纠正一个普遍的误区:有些回答说将所有属性为数字类型的 key 从小到大排序,其实不然,还必须要符合「合法的数组索引」,也即只有正整数才行,负数或者浮点数,一律当做字符串处理。
PS:严格来说对象属性没有数字类型的,无论是数字还是字符串,都会被当做字符串来处理。
我们结合上面的规范,来思考一下下面这段代码会输出什么:
const testObj = {}
testObj[-1] = ''
testObj[1] = ''
testObj[1.1] = ''
testObj['2'] = ''
testObj['c'] = ''
testObj['b'] = ''
testObj['a'] = ''
testObj[Symbol(1)] = ''
testObj[Symbol('a')] = ''
testObj[Symbol('b')] = ''
testObj['d'] = ''
console.log(Object.keys(testObj))
请认真思考后,在这里核对你的答案是否正确:
['1', '2', '-1', '1.1', 'c', 'b', 'a', 'd']
是否与你想象的一致?你可能会奇怪为什么没有 Symbol 类型。
因为在 EnumerableOwnPropertyNames 的规范中规定了返回值只应包含字符串属性(上面说了数字其实也是字符串)。
所以 Symbol 属性是不会被返回的,可以看 MDN 上关于 Object.getOwnPropertyNames() 的描述。
如果要返回 Symbol 属性可以用 Object.getOwnPropertySymbols()。
看完 ECMAScript 的规范定义,相信你不会再搞错 Object.keys() 的输出顺序了。但是你好奇 V8 是如何处理对象属性的吗,下一节我们就来讲讲。
V8 是如何处理对象属性的
在 V8 的官方博客上有一篇文章《Fast properties in V8》,非常详细地向我们解释了 V8 内部如何处理 JavaScript 的对象属性,强烈推荐阅读。
另外再推荐一下极客时间上的课程《图解 Google V8》。
本节内容主要参考这两个地方,下面我们来总结一下。
首先,V8 为了提高对象属性的访问效率,将属性分为两种类型:
-
排序属性(elements) ,就是符合数组索引类型的属性(也就是正整数)。
-
常规属性(properties) ,就是字符串类型的属性(也包括负数、浮点数)。
所有的排序属性都会存放在一个线性结构中,线性结构的特点就是支持通过索引随机访问,所以能加快访问速度,对于存放在线性结构的属性都称为快属性。
常规属性也会存放在另一个线性结构中,可以看下面这张图帮助理解:
但是常规属性还需要做一些额外的处理,这里我们要先介绍一下什么是隐藏类。
由于 JavaScript 在运行时是可以修改对象属性的,所以在查询的时候会比较慢,可以看回上面那张图,每次访问一个属性的时候都需要经过多一层的访问,而像 C++ 这类静态语言在声明对象之前需要定义这个对象的结构(形状),经过编译后每个对象的形状都是固定的,所以在访问的时候由于知道了属性的偏移量,自然就会比较快。
V8 采用的思路就是将这种机制应用在 JavaScript 对象中,所以引入了隐藏类的机制,你可以简单的理解隐藏类就是描述这个对象的形状、包括每个属性对应的位置,这样查询的时候就会快很多。
关于隐藏类还有几点要补充:
-
对象的第一个字段指向它的隐藏类。
-
如果两个对象的形状是完全相同的,会共用同一个隐藏类。
-
当对象添加、删除属性的时候,会创建一个新的对应的隐藏类,并重新指向它。
-
V8 有一个转换树的机制来创建隐藏类,不过本文不赘述,有兴趣可以看这里。
解释完隐藏类,我们再回头来讲讲常规属性,通过上面那张图我们很容易发现一个问题,那就是每次访问一个属性的时候,都需要经过一个间接层才能访问,这无疑降低了访问效率,为了解决这个问题,V8 又引入了一个叫做对象内属性,顾名思义,它会将某些属性直接存放在对象的第一层里,它的访问是最快的,如下图所示:
但要注意,对象内属性只存放常规属性,排序属性依旧不变。而且需要常规属性的数量小于某个数量的时候才会直接存放对象内属性,那这个数量是多少呢?
答案是取决于对象初始化时的大小。
PS:有些文章说是少于 10 个属性时才会存放对象内属性,别被误导了。
除了对象内属性、快属性以外,还有一个慢属性。
为什么会有慢属性呢?快属性虽然访问很快,但是如果要从对象中添加或删除大量属性,则可能会产生大量时间和内存开销来维护隐藏类,所以在属性过多或者反复添加、删除属性时会将常规属性的存储方式从线性结构变成字典,也就是降低到慢属性,而由于慢属性的信息不会再存放在隐藏类中,所以它的访问会比快属性要慢,但是可以高效地添加和删除属性。可以通过下图帮助理解:
但是当我看到这段代码的时候:
function toFastProperties(obj) {
/*jshint -W027*/
function f() {}
f.prototype = obj;
ASSERT("%HasFastProperties", true, obj);
return f;
eval(obj);
}
关于这段代码是如何能让 V8 使用对象快属性的可以看这篇文章:开启 V8 对象属性的“fast”模式。
另外也可以看一下这段代码:to-fast-properties/index.js。
写在最后
当在开发时遇到一个简单的错误,通常可以很快地利用搜索引擎解决问题,但如果只是面向 Google 编程,可能在技术上很难会有进步,所以我们不光要能解决问题,还要理解这个产生问题的背后的原因到底是什么,也就是知其然更知其所以然。
真的非常建议每个 JavaScript 开发者都应该去了解一些关于 V8 或其它 JavaScript 引擎的知识,无论你是通过什么途径,这样能保证我们在编写 JavaScript 代码时出现问题可以更加地得心应手。
最后,本文篇幅有限,部分细节难免会有遗漏,非常建议有兴趣深入了解的同学可以延伸阅读下面的列表。
感谢阅读。
延伸阅读
-
Fast properties in V8
-
《图解 Google V8》 —— 极客时间
-
How is data stored in V8 JS engine memory?
-
V8 中的快慢属性与快慢数组
-
开启 V8 对象属性的“fast”模式
-
ECMAScript® 2015 Language Specification
-
Does JavaScript guarantee object property order? —— stackoverflow
关于本文
作者:@4Ark
原文:原文