一、从一个老生常谈的问题说起
咱们写 JavaScript 的,都知道它是一门动态语言。最直观的体现就是,对象(Object)特别灵活,你可以随时给它加个属性,或者删掉一个属性,玩得不亦乐乎。
let myObject = {};
myObject.name = '卡尔'; // 加个 name
myObject.age = 30; // 加个 age
delete myObject.age; // 哎,我又不要 age 了,你来打我呀
但你有没有想过一个问题:这么灵活的背后,代价是什么?为什么很多性能优化的文章都告诉我们,尽量不要动态修改对象的结构?
相比之下,像 C++ 或 Java 这样的静态语言,一个对象的“形状”(有哪些属性,每个属性是什么类型)在编译的时候就定死了。访问一个属性,比如 obj.x,编译器直接把它翻译成“访问内存地址 obj 往后偏移 N 个字节的位置”,这速度快得飞起。
那 JavaScript 呢?引擎在拿到 obj.x 这行代码的时候,它不知道 obj 到底长啥样。它得先去查一下:“嘿,obj,你有没有一个叫 x 的属性啊?” 这个过程,就像查字典一样,专业点叫“动态查找”,性能上肯定是有开销的。
如果每次访问属性都这么查一次,那性能肯定好不到哪儿去。所以,V8 引擎(Chrome 和 Node.js 用的就是它)就想了个办法,在动态和性能之间找到了一个绝妙的平衡点。这个办法,就是我们今天的主角——隐藏类(Hidden Classes),官方现在更喜欢叫它 Maps。
二、V8 的骚操作:给对象偷偷“分类”
V8 的工程师们有个很聪明的假设:虽然 JavaScript 语法上很动态,但我们开发者写代码,其实是很有规律的。我们通常会创建很多“形状”相同的对象。
比如,我们可能会写一个构造函数:
function Point(x, y) {
this.x = x;
this.y = y;
}
let p1 = new Point(1, 2);
let p2 = new Point(3, 4);
p1 和 p2 这两个对象,虽然值不一样,但它们的“形状”是一样的:都有一个 x 属性,和一个 y 属性,而且添加的顺序也是一样的。
V8 就利用了这一点。它会在背后,偷偷地为这些形状相同的对象创建一个“隐藏类”。这个隐藏类描述了对象的结构,比如有哪些属性,以及每个属性在内存中的偏移量是多少。
当 V8 发现 p1 和 p2 的形状一样时,就会让它们共享同一个隐藏类。这样一来,当 V8 的优化编译器(TurboFan)在处理一段代码时,如果它能确定一个对象的隐藏类,那访问这个对象的属性时,就可以像静态语言一样,直接通过内存偏移来读取,省去了动态查找的开销。性能一下子就上来了!
三、它是怎么玩的呢?一个“连连看”游戏
隐藏类的创建和转换,就像一个“连连看”游戏。我们来看一个简单的例子:
-
创建一个空对象
let obj = {};V8 会创建一个初始的隐藏类,我们叫它
C0。C0表示这是一个没有任何属性的空对象。 -
添加第一个属性
obj.x = 10;V8 会创建一个新的隐藏类
C1,它描述了“一个拥有属性x的对象”。同时,V8 会在C0和C1之间建立一个“转换”(Transition):从C0状态的对象,如果添加了属性x,就转换到C1状态。现在,obj这个对象的隐藏类就变成了C1。 -
添加第二个属性
obj.y = 20;V8 又会创建一个新的隐藏类
C2,描述“一个拥有属性x和y的对象”。同样,在C1和C2之间建立一个转换:从C1状态,如果添加了属性y,就转换到C2状态。obj的隐藏类就变成了C2。
这个过程就像一条转换链:C0 -> C1 -> C2。
重点来了:如果我们再创建一个新对象,并且以完全相同的顺序添加属性:
let obj2 = {};
obj2.x = 100;
obj2.y = 200;
V8 会发现,obj2 的属性添加过程,和 obj 走的是完全一样的“转换路径”。所以,V8 不会再创建新的隐藏类,而是直接让 obj2 复用 obj 的那套隐藏类(C0, C1, C2)。最终,obj 和 obj2 会共享同一个隐藏类 C2。
因为它们共享同一个隐藏类,V8 就知道,它们的 y 属性都在同一个内存偏移位置。当 JIT 编译器优化代码时,就可以生成非常高效的机器码来访问这些属性。
四、需要注意的几个点(别让 V8 的努力白费了)
理解了隐藏类的工作原理,我们就能明白为什么有些写法会影响性能了。
1. 属性的顺序很重要
看下面这两行代码:
let obj1 = { x: 1, y: 2 };
let obj2 = { y: 2, x: 1 };
虽然 obj1 和 obj2 最终的属性是一样的,但因为它们属性的添加顺序不同,V8 会为它们创建两条完全不同的隐藏类转换路径。它们最终的隐藏类也是不一样的。这就导致 V8 无法对它们进行统一的优化。
结论: 在创建对象时,尽量保证属性的初始化顺序一致。
2. 动态添加/删除属性是大忌
如果你在一个函数里,反复地给一个对象动态添加属性,会发生什么?
function update(obj) {
obj.z = 3; // 动态添加属性
return obj.x + obj.y + obj.z;
}
let o1 = { x: 1, y: 2 };
let o2 = { x: 1, y: 2 };
update(o1);
update(o2);
每次调用 update(o1) 时,o1 的隐藏类都会从 {x, y} 的状态转换到 {x, y, z} 的状态。这会创建新的隐藏类,并且可能导致之前 JIT 编译器做的优化失效(这个过程叫“去优化”或“Deoptimization”),因为对象的“形状”变了。
结论: 尽量在构造函数或对象字面量里一次性把所有属性都定义好,避免在后面再动态地去修改对象的结构。
3. “字典模式”——V8 的放弃治疗
如果你对一个对象的操作过于“狂野”,比如用 delete 删除属性,或者添加非常多的属性,V8 最终会“放弃治疗”。它会把这个对象的模式切换到“字典模式”(Dictionary Mode)。
在字典模式下,对象的属性就真的存一个类似哈希表(或字典)的数据结构里了。访问属性就变回了慢速的动态查找。虽然灵活,但性能就差远了。
五、总结一下
好了,今天就聊到这儿。我们来简单总结一下:
- JavaScript 对象的动态性给性能优化带来了挑战。
- V8 引擎通过“隐藏类”这个机制,为形状相同的对象创建共享的结构信息,从而实现快速的属性访问。
- 隐藏类的创建和转换依赖于属性的添加顺序。
- 为了帮助 V8 更好地优化我们的代码,我们应该:
- 在构造函数或对象字面量中初始化所有成员。
- 始终保证属性的初始化顺序一致。
- 尽量避免在对象创建后动态添加或删除属性。
理解了隐藏类的概念,你就能更深刻地理解为什么某些 JavaScript 的“最佳实践”是那样建议的。它不是玄学,背后都是有科学道理的。