前言
以下的🌰你觉得会输出什么?
const obj1 = {
100: '一百',
2: '二',
7: '七'
}
const obj2 = {
a: '一百',
b: '二',
c: '七'
}
const obj3 = {
1.3: '一百',
'-1': '二',
2: '七'
}
console.log('obj1 key',Object.keys(obj1))
console.log('obj2 key',Object.keys(obj2))
console.log('obj3 key',Object.keys(obj3))
Object.keys() 是按照什么顺序返回值的?
先说结论:
Object.keys在内部会根据属性名key的类型进行不同的排序逻辑。分三种情况:
- 如果属性名的类型是
Number,那么Object.keys返回值是按照key从小到大排序
- 如果属性名的类型是
String,那么Object.keys返回值是按照属性被创建的时间升序排序。
- 如果属性名的类型是
Symbol,那么逻辑同String相同
object.keys被调用的背后发生了什么
调用链路分析
首先查看MDN上的说法:
Object.keys() 方法会返回一个由一个给定对象的自身可枚举属性组成的数组,数组中属性名的排列顺序和正常循环遍历该对象时返回的顺序一致 。
额、也没具体说是什么顺序,点击MDN的API链接查看规范定义
规范中是这样定义的:
-
调用
ToObject(O)将结果赋值给变量obj -
调用
EnumerableOwnPropertyNames(obj, "key")将结果赋值给变量nameList -
调用
CreateArrayFromList(nameList)得到最终的结果
对象属性列表是通过
EnumerableOwnPropertyNames 获取的,OwnPropertyKeys 最终返回的 OrdinaryOwnPropertyKeys
关于key的排序就在
OrdinaryOwnPropertyKeys,这里总结一下:
-
创建一个空的列表用于存放 keys
-
将所有合法的数组索引按升序的顺序存入
- 合法的数组索引值的是正整数,负数和浮点数当字符串处理
-
将所有字符串类型索引按属性创建时间以升序的顺序存入
-
将所有
Symbol类型索引按属性创建时间以升序的顺序存入 -
返回 keys
举个例子
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'] = ''
// 1,2,-1,1.1,c,b,a,d
console.log(Object.keys(testObj))
V8对对象属性的处理
在 V8 的官方博客上有一篇文章《Fast properties in V8》(中译版),非常详细地向我们解释了 V8 内部如何处理 JavaScript 的对象属性,本节主要参考这里:
在chrome中查看内存快照
在控制台运行一段程序,点击memory-小圆点获取快照
V8中对象的结构
对象主要由三个部分组成:隐藏类hidden class、Property和Element
其中,隐藏类用于描述对象的结构。Property 和 Element 用于存放对象的属性,它们的区别主要体现在键名能否被索引。
Property与Element
// 可索引属性会被存储到 Elements 指针指向的区域
const indexObj = {1:'a',1:'b'}
// 命名属性会被存储到 Properties 指针指向的区域
const nameObj = {'name':'banggan','age':22}
事实上,这是为了满足 ECMA 规范 要求所进行的设计。按照规范中的描述,可索引的属性应该按照索引值大小升序排列,而命名属性根据创建的顺序升序排列。
举个例子🌰
var a = { 1: "a", 2: "b", "first": 1, 3: "c", "second": 2 }
var b = { "second": 2, 1: "a", 3: "c", 2: "b", "first": 1 }
console.log(a)
// { 1: "a", 2: "b", 3: "c", first: 1, second: 2 }
console.log(b)
// { 1: "a", 2: "b", 3: "c", second: 2, first: 1 }
从上面的例子可以看出:
- 索引的属性按照索引值大小升序排列,而命名属性根据创建的顺序升序排列。
- 无论是可索引属性还是命名属性先声明,在控制台中总是以相同的顺序出现
举个例子使用快照看看
function Foo1 () {}
var a1 = new Foo1()
var b1 = new Foo1()
a1.name = '我是aaa'
a1.text = 'aaa text'
b1.name = '我是bbb'
b1.text = 'bbb text'
a1[1] = 'aaa的索引属性1'
a1[2] = 'aaa的索引属性2'
a和b都有name和text命名属性,但a还有两个可索引属性,在快照中可以看出可索引属性是存在elements中,而a和b有相同的结构
命名属性的不同存储方式
V8 中命名属性有三种的不同存储:对象内属性(in-object)、快属性(fast)和慢属性(slow)。
对象内属性
可以注意到每次访问一个属性的时候,都需要经过一个间接层才能访问,这无疑降低了访问效率,为了解决这个问题,V8 又引入了一个叫做对象内属性,顾名思义,它会将某些属性直接存放在对象的第一层里,它的访问是最快的,如下图所示:
但要注意,对象内属性只存放常规属性,排序属性依旧不变。而且需要常规属性的数量小于某个数量的时候才会直接存放对象内属性, 这个数量取决于对象初始化时的大小。
快属性需要额外多一次 properties 的寻址时间,之后便是与对象内属性一致的线性查找。
慢属性
除了对象内属性、快属性以外,还有一个慢属性。
为什么会有慢属性呢?快属性虽然访问很快,但是如果要从对象中添加或删除大量属性,则可能会产生大量时间和内存开销来维护隐藏类,所以在属性过多或者反复添加、删除属性时会将常规属性的存储方式从线性结构变成字典,也就是降低到慢属性,而由于慢属性的信息不会再存放在隐藏类中,所以它的访问会比快属性要慢,但是可以高效地添加和删除属性。
举个🌰
function Foo2() {}
var a2 = new Foo2()
var b2 = new Foo2()
var c2 = new Foo2()
for (var i = 0; i < 10; i ++) {
a2[new Array(i+'a).join('a')] = 'aaa'
}
for (var i = 0; i < 12; i ++) {
b2[new Array(i+2).join('b')] = 'bbb'
}
for (var i = 0; i < 30; i ++) {
c2[new Array(i+2).join('c')] = 'ccc'
}
a2、b2 和 c2 分别拥有 10 个,12 个和 30 个属性:当对象内属性放满之后,会以快属性的方式,在 properties 下按创建顺序存放。相较于对象内属性,快属性需要额外多一次 properties 的寻址时间,之后便是与对象内属性一致的线性查找。和 b (快属性)相比,properties 中的索引变成了毫无规律的数,意味着这个对象已经变成了哈希存取结构了。
隐藏类
由于 JavaScript 在运行时是可以修改对象属性的,所以在查询的时候会比较慢,每次访问一个属性的时候都需要经过多一层的访问,而像 C++ 这类静态语言在声明对象之前需要定义这个对象的结构(形状),经过编译后每个对象的形状都是固定的,所以在访问的时候由于知道了属性的偏移量,自然就会比较快。
V8 采用的思路就是将这种机制应用在 JavaScript 对象中,所以引入了隐藏类的机制,可以简单的理解隐藏类就是描述这个对象的形状、包括每个属性对应的位置,这样查询的时候就会快很多
关于隐藏类, 补充几点:
- 对象的第一个字段指向它的隐藏类。
- 如果两个对象的形状是完全相同的,会共用同一个隐藏类。
- 当对象添加、删除属性的时候,会创建一个新的对应的隐藏类,并重新指向它。
- V8 有一个转换树的机制来创建隐藏类,不过本文不赘述,有兴趣可以看这里。
神奇的delete操作
function Foo3() {}
var a3 = new Foo3()
var b3 = new Foo3()
for (var i = 1; i < 8; i ++) {
a3[new Array(i+1).join('a')] = 'aaa'
b3[new Array(i+1).join('b')] = 'bbb'
}
delete a3.a
a3 和 b3 本身都是对象内属性。从快照可以看到,删除了 a3.a 后,a3 变成了慢属性,退回哈希存储。
但是,如果我们按照添加属性的顺序逆向删除属性,情况会有所不同。
现在我们逆序删除b3.bbbbbbb。可以发现,b3没有退回哈希存储。这也就是为什么在实际开发中不建议大家使用delete删除对象的某个属性,因为容易改变对象在V8的存储方式,导致性能下降。
结论
- 属性分为命名属性和可索引属性,命名属性存放在
Properties中,可索引属性存放在Elements中。 - 命名属性有三种不同的存储方式:对象内属性、快属性和慢属性,前两者通过线性查找进行访问,慢属性通过哈希存储的方式进行访问。
- 总是以相同的顺序初始化对象成员,能充分利用相同的隐藏类,进而提高性能。
- 增加或删除可索引属性,不会引起隐藏类的变化,稀疏的可索引属性会退化为哈希存储。
- delete 操作可能会改变对象的结构,导致引擎将对象的存储方式降级为哈希表存储的方式,不利于 V8 的优化,应尽可能避免使用(当沿着属性添加的反方向删除属性时,对象不会退化为哈希存储)。