初识隐藏类
我们先来看一段代码
const arr = [
{ a: 1, b: 1, c: 1 },
{ a: 2, b: 1, c: 1 },
{ a: 3, b: 1, c: 1 },
{ a: 4, b: 1, c: 1 },
];
const arr1 = [
{ a: 1, b: 1, c: 1 },
{ c: 1, a: 2, b: 1 },
{ b: 1, c: 1, a: 3 },
{ b: 1, a: 4, c: 1 },
];
let iterations = 1e7;
let time = performance.now();
let sum = 0;
while(iterations--){
const p = arr[iterations&3].a;
sum = sum + p;
}
console.log("--->1",performance.now() - time);
iterations = 1e7;
time = performance.now();
while(iterations--){
const p = arr1[iterations&3].a;
sum = sum + p;
}
console.log("--->2",performance.now() - time);
arr 和 arr1 两个数组中的对象的属性是一样的,只是属性的顺序不同,而arr的对象格式一致,但是相同的操作,运行时间却有一部分差距,格式规整的arr进行相关操作的时候运行速度要快一些。
对象的属性顺序为什么会对运行时长有影响呢?我们引出一个js对象的重要概念,叫做隐藏类。
隐藏类是js引擎中用来实现js对象可以动态设置属性的方式,我们暂时理解隐藏类为一个对象的“形状”,形状包括包含的属性,以及属性的顺序(这些属性不包括“非负整数”属性)。
如上图所示,一个js对象的属性值和属性是分开存储的,属性存在隐藏类里,每一个属性对应有一些信息,其中包括offset值,这个值代表对应属性值在对象属性值中的偏移量。如图中obj这个对象的x属性对应的offset是0,那么x属性值就是offset为0位置的值,所以x属性的值是5。
隐藏类不是一个对象所独有的,它像对象的prototype一样,是共享的,多个对象可以拥有相同的隐藏类,如下图:
隐藏类的复用
从js 对象中取对应属性的值是常见和频繁的操作, 如果多个对象的隐藏类相同,那么寻找相同的 属性对应的值,就能复用上次的信息。
有个专业的术语,内嵌缓存(Inline Cache, 后面简称IC), IC是保证 JavaScript 快速运行的关键因素!JavaScript 引擎使用 IC 来记忆对象属性的查找信息,以减少昂贵的查找次数。
上面那段代码中 arr 中的每个对象格式一样,它们具有相同的隐藏类,实现了复用,所以会运行快一些。
隐藏类的更新
动态增加属性 和 删除属性操作 都会更新对象的隐藏类,删除属性会降低属性的查找性能。
对obj动态增加z属性,它的隐藏类会更新为一个新的隐藏类,而不是在原来的基础上进行修改。
对以下几种操作分别执行 1e7次,测量运行时间,比较哪个最慢:
let iterations = 1e7;
while(iterations--){
const p = { x: 1, y: 2, z: 3};
p.x = undefined; // 操作一
p.y = undefined; // 操作二
p.z = undefined; // 操作三
delete p.x; // 操作四
delete p.y; // 操作五
delete p.z; // 操作六
JSON.stringify(p)
}
经过尝试过后,发现设置undefined的操作时间基本都在1000ms左右,执行delete操作的运行时间都比较长。设置undefined不会改变一个对象的隐藏类,而执行delete会。
看起来一个对象的隐藏类会和属性的顺序有关, 包括初始化的属性顺序和动态增加的属性顺序,以及delete操作,那么还有什么会影响一个对象的隐藏类呢?不同的构造函数也会影响对象的隐藏类,看如下代码
let iterations = 1e7;
while(iterations--){
class Point {
constructor(x, y, z){
this.x = x; this.y = y; this.z = z;
}
}
const p = new Point(1, 2, 3);
JSON.stringify(p)
}
上面的代码运行时长接近13秒,你也许认为动态创建类会消耗很大性能,我们在while循环外增加相同的类,同时循环里使用外部的类,内部类声明保留,代码如下
class Point {
constructor(x, y, z){
this.x = x; this.y = y; this.z = z;
}
}
let iterations = 1e7;
while(iterations--){
class PointInner {
constructor(x, y, z){
this.x = x; this.y = y; this.z = z;
}
}
const p = new Point(1, 2, 3);
JSON.stringify(p)
}
发现运行时长降到了4秒多,说明即使有动态创建类,也不是时长变长的主要原因
隐藏类的细节
接下来我们从js引擎内部的一些命令看看隐藏类的细节,首先你需要下一个 v8-debug 的命令工具,可以在电脑上全局安装 jsvu,参考 https://github.com/GoogleChromeLabs/jsvu。
装好d8后就可以使用 d8 --allow-natives-syntax启动
首先我们输入 const a = { x: 1, y: 1 }
然后我们通过一个内部命令看js对象在v8内部的构成,输入 %DebugPrint(a)
其中 - map: 0x34bc0029988d <Map20> [FastProperties], 这个就是对象a的隐藏类,它的后面有一个[FastProperties]的标记,代表是快属性访问模式
下面 - instance descriptors (own) #2: 0x34bc000484d5 <DescriptorArray[2]> 这个就是对象a的所有属性的集合
我们通过一个命令查看一下这个集合的具体内容,输入 %DebugPrintPtr(0x34bc000484d5)
我们看到第八行开始就是对象a的隐藏类所包含的属性,当我们动态给对象a增加属性,重复上面的步骤回看到对应的DescriptorArray会更新
现在我们对a对象执行 delete 操作,delete a.x,同时再次 %DebugPrint(a)
我们看到a的隐藏类变成了 - map: 0x34bc00294f0d <Map12> [DictionaryProperties],隐藏类后面的标记变成了 DictionaryProperties,只有delete操作会使对象的隐藏类变成 DictionaryProperties,属性查找不再是线性查找,而是字典查找,会使查找速度变慢,所以我们不推荐使用 delete 操作符去删除对象的属性
隐藏类的比较
两个对象的隐藏类是否相同,从直观上来看就是两个对象都有相同顺序的相同属性操作,这些操作都最终决定了隐藏类是否相同,例如 const a = { x: 1, y: 1 } 和 const b = {}; b.x = 1; b.y = 1;虽然最终 a和b的属性相同,顺序也相同,但是他俩的对象来源的方式是不同的,最终也会导致隐藏类不同
我们使用 v8内部的命令去查看隐藏类是否相同,%HaveSameMap(a, b)
我们使用这个命令再看一下这个对比,%HaveSameMap({ x: 1 }, { x: 1, 2: 1 }),发现是 true,说明非负整数的属性确实不影响一个对象的隐藏类
小结
- 一个js对象在js引擎内部会有一个隐藏类,这个隐藏类和这个对象的属性的顺序,增加的方式有关,这些属性不包括非负整数属性。
- 使用delete操作符会让一个对象的属性查找变慢,不同的构造函数即使产出的对象的属性及属性的顺序都一样,隐藏类也不一样。
- 多个对象可能有相同的隐藏类,如果隐藏类相同,js引擎会尝试复用隐藏类,复用成功会提高属性的查找速度。
本文参考了以下文章和链接