Javascript对象的属性有顺序么?

265 阅读10分钟

前言

我在整理一些基本的排序算法时,偶然发现对象好像也能排序。实现如下

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 of ownKeys, do

  1. 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 of O such that P is an array index, in ascending numeric index order, do 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 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 Add P as the last element of keys.

5. Return keys

翻译师上线:

    1. 升序插入数组索引
    1. 按属性插入对象的时间顺序升序插入非数组索引的字符串形式的属性(也就是肉眼上属性的出现顺序)
    1. 按属性插入对象的时间属性升序插入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.

翻译师上线:

整数索引是字符串值形式的属性,它是规范的数字字符串,其数值为正整数的 +02^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.getOwnPropertyNamesObject.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 )

  1. 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 of keys, do

  1. a. If [Type] (nextKey) is Symbol and type is symbol or [Type] (nextKey) is String and type is string, then

    1. i. Append nextKey as the last element of nameList.

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: 小孩子才做选择,我全都要!

qdy.webp

属性的顺序由 OwnPropertyKeys 决定,顺序是:

  1. 正整数数组索引
  2. 字符串属性
  3. 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 需要对每个原型级别的键进行排序,首先是元素的整数索引,然后是属性的字符串。 但是,这不适用于代理模式下的排序。

JSReceiverJSObjectJSProxy 的父类, 而 JSObject 描述了 V8 实际堆分配的 JavaScript 对象,JSProxy 对应的是 Proxy。这段注释解释的属性的排序方式:

  1. 整数索引来自 element
  2. 字符串来自 properties

这里的 elementproperties 是指 JSObject 中的两个属性,在v8的博客【9】【10】都会介绍这两个概念,这里简单的做个描述。

jsobject.png

上图简单展示了一个基本的 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 的表现一致。在浏览器环境下打印出下面的顺序:

{
  00
  11
  1.11.1
  1.7976931348623157e+3081.7976931348623157e+308
  22
  42949672944294967294
  42949672954294967295
  90071992547409919007199254740991
  -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)
  1122
  2211
  c"c"
  d"d"
  Symbol(one): Symbol(one)
}

发现规律大致如下:

  1. 正整数从小到大,使用规格化科学记数法表示的数则通过尾数比较
  2. 负数从大到小
  3. 字符串属性,有点像按位比较ascii,从小到大
  4. Symbol 按Symbol里的属性按上面三个规则排列
  5. 不可枚举元素按上面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…