5 分钟彻底理解 Object.keys

前几天一个朋友问了我一个问题:为什么 Object.keys 的返回值会自动排序?例子是这样的:

const obj = {
  100: '一百',
  2: '二',
  7: '七',
};
Object.keys(obj); // ["2", "7", "100"]

而下面这例子又不自动排序了?

const obj = {
  c: 'c',
  a: 'a',
  b: 'b',
};
Object.keys(obj); // ["c", "a", "b"]

当朋友问我这个问题时,一时间我也回答不出个所以然。故此去查了查 ECMA262 规范 (opens new window),再加上后来看了看这方面的文章,明白了为什么会发生这么诡异的事情。

故此写下这篇文章详细介绍,当 Object.keys 被调用时内部都发生了什么。

答案

对于上面那个问题先给出结论,Object.keys 在内部会根据属性名 key 的类型进行不同的排序逻辑。分三种情况:

  1. 如果属性名的类型是 Number,那么 Object.keys 返回值是按照 key 从小到大排序。
  2. 如果属性名的类型是 String,那么 Object.keys 返回值是按照属性被创建的时间升序排序。
  3. 如果属性名的类型是 Symbol,那么逻辑同 String 相同。

这就解释了上面的问题。接下来我们详细介绍 Object.keys 被调用时,背后发生了什么。

当 Object.keys 被调用时背后发生了什么

Object.keys 函数使用参数 O 调用时,会执行以下步骤:

第一步:将参数转换成 Object 类型的对象。

第二步:通过转换后的对象获得属性列表 properties

注意:属性列表 properties 为 List 类型(List 类型 (opens new window)ECMAScript 规范类型 (opens new window)

第三步:将 List 类型的属性列表 properties 转换为 Array 得到最终的结果。

规范中是这样定义的:

  1. 调用 ToObject(O) 将结果赋值给变量 obj
  2. 调用 EnumerableOwnPropertyNames(obj, "key") 将结果赋值给变量 nameList
  3. 调用 CreateArrayFromList(nameList) 得到最终的结果

将参数转换成 Object

ToObject 操作根据下表将参数 O 转换为 Object 类型的值:

参数类型 结果
Undefined 抛出 TypeError
Null 抛出 TypeError
Boolean 返回一个新的 Boolean 对象
Number 返回一个新的 Number 对象
String 返回一个新的 String 对象
Symbol 返回一个新的 Symbol 对象
Object 直接将 Object 返回

因为 Object.keys 内部有 ToObject 操作,所以 Object.keys 其实还可以接收其他类型的参数。上表详细描述了不同类型的参数将如何转换成 Object 类型。

我们可以简单写几个例子试一试:

先试试 null 会不会报错:

Object.keys(null)的演示图片

如图所示,果然报错了。接下来我们试试数字的效果:

Object.keys(123)的演示图片

如图所示,返回空数组。为什么会返回空数组?

new Number(123)的演示图片

上图所示,返回的对象没有任何可提取的属性,所以返回空数组也是正常的。

然后我们再试一下 String 的效果:

Object.keys('我是Berwin')的演示图片

通过上图我们会发现返回了一些字符串类型的数字。这是因为 String 对象有可提取的属性:

new String('我是Berwin')的演示图片

因为 String 对象有可提取的属性,所以将 String 对象的属性名都提取出来变成了列表返回出去了。

获得属性列表

EnumerableOwnPropertyNames(obj, "key"),获取属性列表的过程有很多细节,其中比较重要的是调用对象的内部方法 OwnPropertyKeys 获得对象的 ownKeys

注意:这时的 ownKeys 类型是 List 类型,只用于内部实现。

然后声明变量 properties,类型也是 List 类型,并循环 ownKeys 将每个元素添加到 properties 列表中。

最终将 properties 返回。

您可能会感觉到奇怪,ownKeys 已经是结果了为什么还要循环一遍将列表中的元素放到 properties 中。

这是因为 EnumerableOwnPropertyNames 操作不只是给 Object.keys 这一个 API 用,它内部还有一些其他操作,只是 Object.keys 这个 API 没有使用到,所以看起来这一步很多余。

所以针对 Object.keys 这个 API 来说,获取属性列表中最重要的是调用了内部方法 OwnPropertyKeys 得到 ownKeys

其实也正是内部方法 OwnPropertyKeys 决定了属性的顺序。

关于 OwnPropertyKeys 方法 ECMA-262 (opens new window) 中是这样描述的:

O 的内部方法 OwnPropertyKeys 被调用时,执行以下步骤(其实就一步):

  1. Return ! OrdinaryOwnPropertyKeys(O)

OrdinaryOwnPropertyKeys 是这样规定的:

  1. 声明变量 keys 值为一个空列表(List 类型)。
  2. 把每个 Number 类型的属性,按数值大小升序排序,并依次添加到 keys 中。
  3. 把每个 String 类型的属性,按创建时间升序排序,并依次添加到 keys 中。
  4. 把每个 Symbol 类型的属性,按创建时间升序排序,并依次添加到 keys 中。
  5. keys 返回(return keys)。

上面这个规则不光规定了不同类型的返回顺序,还规定了如果对象的属性类型是数字,字符与 Symbol 混合的,那么返回顺序永远是数字在前,然后是字符串,最后是 Symbol。

举个例子:

Object.keys({
  5: '5',
  a: 'a',
  1: '1',
  c: 'c',
  3: '3',
  b: 'b',
});
// ["1", "3", "5", "a", "c", "b"]

属性的顺序规则中虽然规定了 Symbol 的顺序,但其实 Object.keys 最终会将 Symbol 类型的属性过滤出去。(原因是顺序规则不只是给 Object.keys 一个 API 使用,它是一个通用的规则)

将 List 类型转换为 Array 得到最终结果

对于 CreateArrayFromList(elements),现在我们已经得到了一个对象的属性列表,最后一步是将 List 类型的属性列表转换成 Array 类型。

将 List 类型的属性列表转换成 Array 类型非常简单:

  1. 先声明一个变量 array,值是一个空数组;
  2. 循环属性列表,将每个元素添加到 array 中;
  3. array 返回。

该顺序规则还适用于其他 API

上面介绍的排序规则同样适用于下列 API:

  • Object.entries
  • Object.values
  • for...in 循环
  • Object.getOwnPropertyNames
  • Reflect.ownKeys

注意:以上 API 除了 Reflect.ownKeys 之外,其他 API 均会将 Symbol 类型的属性过滤掉。