前言
我在整理一些基本的排序算法时,偶然发现对象好像也能排序。实现如下
const array = [1,6,3,4,2,5,7]
function ObjkeysSort(arr = []) {
return Object.keys(
arr.reduce((pre, cur) => {
pre[cur] = 1;
return pre;
}, {})
).map(Number);
}
当然,仅在不能太大的正整数下,且没有重复的数字的情况下有效。一时激起千万好奇心,对象的属性是否排列有序?
获取对象属性的API
首先,获取对象上的 keys数组 的 API 有一下几个:
Object.keys
Object.getOwnPropertyNames
Object.getOwnPropertySymbols
Reflect.ownKeys
在下面的内容中会介绍这些 api 能获取到对象上的那些属性,以及他们是否有序,以什么样的顺序排列? 我们就以下面的对象为例(为什么设计了这样的一个对象,相信看完通篇文章你会恍然大悟,运行环境window10 & 谷歌浏览器 & node16)。
const ONE = Symbol('one');
const obj = {
[Symbol(0)]: Symbol(0),
[Symbol(1.1)]: Symbol(1.1),
[Symbol(4294967295)]: Symbol(4294967295),
[Symbol(4294967294)]: Symbol(4294967294),
[Symbol('B')]: Symbol('B'),
[Symbol('b')]: Symbol('b'),
[Symbol('A')]: Symbol('A'),
[Symbol('b2')]: Symbol('b2'),
[Symbol('b1')]: Symbol('b1'),
'-4294967294': -4294967294,
'-4294967295': -4294967295,
0: 0,
1: 1,
2: 2,
1.1: 1.1,
4294967295: 4294967295,
4294967294: 4294967294,
[Number.MAX_VALUE]: Number.MAX_VALUE,
[Number.MAX_SAFE_INTEGER]: Number.MAX_SAFE_INTEGER,
B: 'B',
b: 'b',
A: 'A',
b2: 'b2',
b1: 'b1',
[ONE]: ONE,
22: 11,
11: 22,
d: 'd',
c: 'c'
};
// 定义不可枚举属性
Object.defineProperties(obj, {
22: {
enumerable: false
},
11: {
enumerable: false
},
d: {
enumerable: false
},
c: {
enumerable: false
},
[ONE]: {
enumerable: false
}
});
Object.keys
我们先来说说 Object.keys
, 看看他在 MDN 的描述【1】
Object.keys
返回一个所有元素为字符串的数组,其元素来自于从给定的object
上面可直接枚举的属性。这些属性的顺序与手动遍历该对象属性时的一致(使用for in)。
通过描述我们只能看出keys只可以遍历出可枚举的属性,但是没有说明属性的顺序,那是有序还是无序?我们先来看看 Object.keys
的输出:
// 手动换行数组元素,便于阅读
keys = [
'0', '1', '2', '4294967294', '-4294967294', '-4294967295',
'1.1', '4294967295', '1.7976931348623157e+308', '9007199254740991',
'B', 'b', 'A', 'b2', 'b1'
]
观察好像有一些规律:
- 正整数从小到大,但是到
4294967295
就不灵了 - 其他属性好像和在对象中的排列顺序一样
Symbol
属性"离家出走"了- 不可遍历元素当然看不见了
那这些规律是否正确?引胡适先生的一句话:
大胆假设,小心求证
现在我们来验证验证,最标准的地方莫过于我们的ECMA规范【2】了。 在规范里生成数组的是步骤2
1. Let
obj
be ? [ToObject] (O
).2. Let
nameList
be ? EnumerableOwnPropertyNames(obj
, key).3. Return [CreateArrayFromList] (
nameList
).
这里会调用另一个函数 EnumerableOwnPropertyNames
, 继续点击查看该函数的具体内容
1. Let
ownKeys
be ?O
[OwnPropertyKeys]2. Let
properties
be a new empty [List]3. For each element
key
ofownKeys
, do
- a. If [Type] (
key
) is String, then
1. i. Let `desc` be ? `O`.[[GetOwnProperty]](`key`).
1. ii. If `desc` is not undefined and `desc`.[[Enumerable]] is true, then // ...省略 根据Object.keys还是values还是entry 取不同的数据
4. Return
properties
.
简单来说就是先用 OwnPropertyKeys
获取数组,然后剔除不可遍历元素,最后返回。 里面还有函数调用,继续查看 OwnPropertyKeys
【3】 的逻辑
1. Let
keys
be a new empty List.2. For each own property key
P
ofO
such thatP
is an array index, in ascending numeric index order, do AddP
as the last element ofkeys
.3. For each own property key
P
ofO
such that Type(P
) is String andP
is not an array index, in ascending chronological order of property creation, do AddP
as the last element ofkeys
.4. For each own property key
P
ofO
such that Type(P
) is Symbol, in ascending chronological order of property creation, do AddP
as the last element ofkeys
.5. Return
keys
翻译师上线:
-
- 升序插入数组索引
-
- 按属性插入对象的时间顺序升序插入非数组索引的字符串形式的属性(也就是肉眼上属性的出现顺序)
-
- 按属性插入对象的时间属性升序插入Symbol
这时候问题来了,为什么同样是正整数的的属性 4294967295
没在 4294967294
之后,而是按步骤3的方式出现?
答案就在 integer-index
的定义【4】中:
An integer index is a String-valued property key that is a canonical numeric String (see 7.1.16) and whose numeric value is either +0 or a positive integer ≤ 2^53-1. An array index is an integer index whose numeric value
i
is in the range +0 ≤i
< 2^32-1.
翻译师上线:
整数索引是字符串值形式的属性,它是规范的数字字符串,其数值为正整数的 +0
到 2^53-1
(最大安全数)。数组索引是一种整数索引,其数值 i 在 +0 ≤ i < 2^32-1
(32位无符号整数的最大值) 的范围内。
规范中说明了 4294967295
已经超出了范围,所以不能排在前面了。解决了这个问题,那么,对象中的Symbol小团队又是什么原因?
来到Symbol的MDN文档【5】看看
Symbols 在 [
for...in
] 迭代中不可枚举。另外,[Object.getOwnPropertyNames()
] 不会返回 symbol 对象的属性,但是你能使用 [Object.getOwnPropertySymbols()
] 得到它们。
Object.keys
数组中属性名的排列顺序和正常循环遍历该对象时,也就是 for in
返回的顺序一致。所以数组中也不会包含Symbol
。 个人理解是对象的 Symbol
属性更多的用于对象的内部行为,比如 Symbol.iterator
,而不是用来声明对象的属性。
GetOwnPropertyNames和GetOwnPropertySymbols
上面我们在Symbol的文档中出现过Object.getOwnPropertyNames
和Object.getOwnPropertySymbols
,那么,他们的输出会是怎么样的?请看结果:
// 手动换行数组元素,便于阅读
getOwnPropertyNames = [
'0', '1', '2', '11', '22', '4294967294', '-4294967294',
'-4294967295', '1.1', '4294967295', '1.7976931348623157e+308', '9007199254740991',
'B', 'b', 'A', 'b2', 'b1', 'd', 'c'
]
getOwnPropertySymbols = [
Symbol(0), Symbol(1.1), Symbol(4294967295),
Symbol(4294967294), Symbol(B), Symbol(b),
Symbol(A), Symbol(b2), Symbol(b1), Symbol(one)
]
Object.getOwnPropertyNames
相比 Object.keys
好像多了几个属性,这几个属性就是我们定义的不可枚举的元素。而 Object.getOwnPropertySymbols
只获取的对象的 Symbol
,排列规则还是和Object.keys
中一样。Too! ECMA【6】走起
Object.getOwnPropertyNames (
O
)1. Return CreateArrayFromList (?[
GetOwnPropertyKeys
] (O
, string))
Object.getOwnPropertySymbols (
O
)
- Return [CreateArrayFromList] (?[
GetOwnPropertyKeys
] (O
, symbol)).
两个方法调用了同一个函数,区别是第二个入参,继续查看 GetOwnPropertyKeys
1. Let
obj
be ? [ToObject] (O
).2. Let
keys
be ?obj
.[OwnPropertyKeys].3. Let
nameList
be a new empty [List].4. For each element
nextKey
ofkeys
, do
a. If [Type] (
nextKey
) is Symbol andtype
is symbol or [Type] (nextKey
) is String andtype
is string, then
- i. Append
nextKey
as the last element ofnameList
.5. Return
nameList
.
兄弟姐妹们,在步骤2看到了熟悉的东西 OwnPropertykeys
。说明获取的是和 Object.keys
起初获取的数组方法时一样的。 在上面的步骤4中 Object.getOwnPropertyNames
从该数组筛选了字符串形式的属性,而Object.getOwnPropertySymbols
则是 Symbol
形式的属性。
Reflect.ownKeys
除了上面的API, Reflect.ownKeys
也能获取对象的属性,他又有什么样的输出?
// 手动换行数组元素,便于阅读
ownkeys = [
'0', '1', '2', '11', '22', '4294967294', '-4294967294',
'-4294967295', '1.1', '4294967295', '1.7976931348623157e+308', '9007199254740991',
'B', 'b', 'A', 'b2', 'b1', 'd', 'c',
Symbol(0), Symbol(1.1), Symbol(4294967295), Symbol(4294967294),
Symbol(B), Symbol(b), Symbol(A), Symbol(b2), Symbol(b1), Symbol(one)
]
好家伙!一个不拉。 老样子ECMA走起【7】
1. If [Type] (
target
) is not Object, throw a TypeError exception.2. Let
keys
be ?target
.[[OwnPropertyKeys
]].3. Return [CreateArrayFromList] (
keys
).
还是熟悉的食材,熟悉的味道。 直接拿 OwnPropertyKeys
的结果构建数组了,所以,所有的属性都有了。
api总结
回顾上面的api, 我们总结下他们的“表现”:
Object.keys
: 光明正大,“只拿看得见”的属性Object.getOwnPropertyNames
: 咦,不要Symbol
!Object.getOwnPropertySymbols
: 专一,我只要Symbol
!Reflect.ownKeys
: 小孩子才做选择,我全都要!
属性的顺序由 OwnPropertyKeys
决定,顺序是:
- 正整数数组索引
- 字符串属性
- Symbol属性
这里你也可以在控制台运行以上和以下的代码加深印象
[].length = Math.pow(2,32)
为什么数组索引会排在前面
前面我们介绍到对象的属性有一定的顺序,那么,为什么会这样?
最终,在v8的代码里找到了这么一段注释(翻了两天了,c++代码看的太累,看不太懂)。
This is a helper class for JSReceiver::GetKeys which collects and sorts keys.
GetKeys needs to sort keys per prototype level, first showing the integer
indices from elements then the strings from the properties. However, this
does not apply to proxies which are in full control of how the keys are
sorted.
翻译师上线:
这是一个 JSReceiver::GetKeys
的辅助类,用于收集和排序(对象的)键。GetKeys
需要对每个原型级别的键进行排序,首先是元素的整数索引,然后是属性的字符串。 但是,这不适用于代理模式下的排序。
JSReceiver
是 JSObject
和 JSProxy
的父类, 而 JSObject
描述了 V8 实际堆分配的 JavaScript
对象,JSProxy
对应的是 Proxy
。这段注释解释的属性的排序方式:
- 整数索引来自
element
- 字符串来自
properties
这里的 element
和 properties
是指 JSObject
中的两个属性,在v8的博客【9】【10】都会介绍这两个概念,这里简单的做个描述。
上图简单展示了一个基本的 JavaScript
对象在内存中是什么样的。元素和属性被保存在两个独立的数据结构中,以对象 {a: "a", b: "b", 1: 1, 2:2}
为例。
- 数组索引属性(array-indexed properties),常叫元素(elements) 。 1和值"1", 2和值"2"存放在这里
- 没有任何整数索引作属性名的属性称为命名属性,即properties。 a和值"a", b和值"b"存放在这里
元素又可按元素类型,稀疏性,快元素,字典元素等区分。属性也有内属性,快慢属性之分。这些都在v8的博客中有介绍,英文版的,精品。这里不多做讲解。
再看注释的最后一句话, 运行下面的代码,只能得到 ['1', '2']。 因为用户对Object.ownkeys的可控性,所以v8中代理对象使用的和通常对象不是同一种算法。
const proxyObj = new Proxy(obj, {
ownKeys(target) {
return ['1', '2', '3'];
}
});
结尾
我们知道了对象的属性是有一定的属性的,也简单的了解的一个简单的对象在 v8 内存中的存储形式,++知识点。
在浏览器环境调试上述的代码过程中发现直接通过 console.log
打印出的对象的属性排列方式和上述并不同,而在node环境下则和Object.getOwnPropertyNames
的表现一致。在浏览器环境下打印出下面的顺序:
{
0: 0
1: 1
1.1: 1.1
1.7976931348623157e+308: 1.7976931348623157e+308
2: 2
4294967294: 4294967294
4294967295: 4294967295
9007199254740991: 9007199254740991
-4294967294: -4294967294
-4294967295: -4294967295
A: "A"
B: "B"
b: "b"
b1: "b1"
b2: "b2"
Symbol(0): Symbol(0)
Symbol(1): Symbol(1)
Symbol(4294967294): Symbol(4294967294)
Symbol(4294967295): Symbol(4294967295)
Symbol(A): Symbol(A)
Symbol(B): Symbol(B)
Symbol(b1): Symbol(b1)
Symbol(b2): Symbol(b2)
Symbol(b): Symbol(b)
11: 22
22: 11
c: "c"
d: "d"
Symbol(one): Symbol(one)
}
发现规律大致如下:
- 正整数从小到大,使用规格化科学记数法表示的数则通过尾数比较
- 负数从大到小
- 字符串属性,有点像按位比较ascii,从小到大
- Symbol 按Symbol里的属性按上面三个规则排列
- 不可枚举元素按上面4个规则排列
可惜的是在 MDN 上写着console.log
不属于任何公开的规范。 目前还在 while(true)
查找原因中,这个问题也留给广大的掘友么。
参考文献
【1】 Object.keys MDN描述 developer.mozilla.org/zh-CN/docs/…
【2】 Object.keys ECMA tc39.es/ecma262/#se…
【3】 OwnPropertyKeys ECMA tc39.es/ecma262/#se…
【4】integer index ECMA定义 tc39.es/ecma262/#se…
【5】Symbol与for-in developer.mozilla.org/zh-CN/docs/…
【6】Object.getOwnPropertyNames ECMA tc39.es/ecma262/mul…
【7】Reflect.keys ECMA tc39.es/ecma262/#se…
【8】V8 注释 github.com/v8/v8/blob/…
【9】element和properties v8.dev/blog/fast-p…
【10】for in v8.dev/blog/fast-f…