译 JavaScript 引擎基础:原型优化
Category(分类): JavaScript Status: 已更新
本文保留 2018 年原译文关于 Shapes、Inline Caches 和原型属性访问的主要内容,并把当时的引擎实现与今天的通用结论分开。文中的
Shape、HiddenClass、Map、Structure、ValidityCell都是引擎实现术语,不是 ECMAScript 可以直接调用的 API。
原文作者:Benedikt Meurer、Mathias Bynens 原译文作者:hijiangtao
原文:JavaScript engine fundamentals: optimizing prototypes 上一篇译文:JavaScript 引擎基础:Shapes 和 Inline Caches
0. 先区分规范与实现
ECMAScript 规定了对象属性、原型链和类的可观察行为,但没有规定:
- 引擎必须使用解释器还是 JIT;
- 必须有多少个编译层;
- Shape 是否叫 HiddenClass、Map 或 Structure;
- Inline Cache、ValidityCell 或去优化必须采用什么实现;
- 某种引擎一定比另一种引擎快。
因此,本文的性能部分应理解为帮助建立实现模型的历史资料,不能把某个版本 V8 的内部结构当成跨浏览器、跨版本的固定事实。
一、优化层级与执行效率的取舍
现代 JavaScript 引擎通常在“尽快启动代码”和“让热点代码运行得更快”之间做取舍。解释器启动成本低、生成代码快;基线或优化编译器需要更多编译时间和内存,但可能生成运行效率更高的机器码。
1. V8 的历史与现代分层
原文写作时主要介绍了 V8 的 Ignition 解释器和 TurboFan 优化编译器,并把它们描述为简单的两层模型:
V8 的现代管线已经更加分层,常见的概念模型是:
JavaScript 源码
↓
Ignition:解释执行字节码并收集反馈
↓
Sparkplug:快速基线编译
↓
Maglev:中层优化编译
↓
TurboFan:更深入的优化编译
并不是每个函数都会依次经过所有层级。引擎会根据代码是否变热、反馈信息、代码大小、内存和编译成本等因素作决定,还可能因为假设失效而去优化(deoptimize),回到较低层级或解释执行。
原文中“Ignition 是所有引擎中最快的解释器”属于没有版本、平台和基准条件的绝对排名,应删除。更准确的说法是:解释器通常有利于快速启动,优化编译器通常有利于热点代码的长期运行性能,但具体结果必须以目标引擎和真实工作负载测试为准。
2. SpiderMonkey、Chakra 和 JavaScriptCore
原译文还介绍了 SpiderMonkey、Chakra 和 JavaScriptCore 的分层:
这些内容有重要的历史价值,但不能直接当成 2026 年所有版本的当前架构:
- Chakra/ChakraCore 主要是旧 EdgeHTML 时代的引擎;现代 Microsoft Edge 基于 Chromium/V8;
- SpiderMonkey 的实现持续演进,旧资料中的 IonMonkey 术语不能覆盖今天的完整管线;
- JavaScriptCore 的 LLInt、Baseline JIT、DFG、FTL 等层级和调度策略也会随版本变化;
- 不同宿主、平台和编译选项可能导致同一引擎采用不同策略。
引擎的共同目标仍然是:先尽快运行,再利用类型反馈和执行频率优化热点代码,同时控制编译延迟、内存使用和去优化成本。
3. 字节码和机器码的内存取舍
原文用一个简单的加法函数说明,优化后的机器码可能比解释器字节码占用更多内存:
function add(x, y) {
return x + y
}
add(1, 2)
解释器可以使用紧凑的字节码,执行时由解释器逐条处理;优化编译器则可能生成更多机器指令,并附带保护检查、去优化入口和元数据。优化不是“越多越好”:
- 冷代码不值得花大量时间编译;
- 编译过程本身会消耗 CPU 和内存;
- 过于激进的假设失效后可能反复去优化;
- 代码缓存和机器码占用也会影响整体性能。
二、Shape、Hidden Class 与属性访问
1. 什么是 Shape
JavaScript 对象可以动态添加、删除属性,属性的值和属性描述符也可以变化。为了更快地表示常见对象,引擎通常会把:
- 对象的结构信息:有哪些属性、属性布局、描述符、原型等;
- 对象实际保存的属性值;
分开处理。不同引擎的术语不同:V8 常用 Hidden Class/Map,SpiderMonkey 常用 Shape,JavaScriptCore 常用 Structure。本文统一用 Shape 表示这类概念。
function Point(x, y) {
this.x = x
this.y = y
}
const first = new Point(1, 2)
const second = new Point(3, 4)
如果实例以相同顺序初始化相同属性,通常更容易共享结构信息:
但不要把“同样的属性”理解得过于绝对。属性创建顺序、删除属性、改变属性描述符、改变原型以及特定类型的内部表示,都可能影响引擎的布局或优化策略。修改一个已有数据属性的值,通常不等于改变 Shape;但它仍可能影响类型反馈或原型查找依赖。
2. class 与原型
原文用 class 和构造函数表示同一组方法:
class Bar {
constructor(x) {
this.x = x
}
getX() {
return this.x
}
}
const foo = new Bar(true)
简单地近似改写为:
function BarLegacy(x) {
this.x = x
}
BarLegacy.prototype.getX = function getX() {
return this.x
}
这个例子说明了类方法通常放在原型上、实例字段通常放在实例自身,但 class 不只是无条件的语法替换。类还具有严格模式、不能直接调用 class 构造器、方法默认不可枚举、extends/super、私有字段、类字段和静态初始化块等规范语义。上面的函数版本只用于说明原型共享机制。
如果创建另一个 Bar 实例,两个实例通常可以共享相同的结构和 Bar.prototype:
const another = new Bar(false)
Object.getPrototypeOf(foo) === Bar.prototype // true
Object.getPrototypeOf(another) === Bar.prototype // true
3. 属性读取与方法调用
下面的调用可以从理解层面拆成“先读取方法,再以 foo 作为接收者调用”:
const value = foo.getX()
// 近似理解为:
const getX = foo.getX
const valueAgain = getX.call(foo)
这不是说引擎一定真的生成这两行代码,而是为了说明 this 来自方法调用表达式的接收者。属性读取会先查对象自身,再沿原型链向上查找:
4. 原型链是可变的
JavaScript 允许改变对象的原型,因此同一个属性访问点的结果可能发生变化:
const item = new Bar(true)
item.getX() // true
Object.setPrototypeOf(item, null)
// item.getX()
// TypeError: item.getX is not a function
Object.setPrototypeOf() 是标准 API,但对已经运行的热点对象修改原型可能让引擎放弃或重建相关优化;这不是语义错误,而是应谨慎使用的动态特性。创建对象时确定原型通常更容易让引擎优化。
5. DOM 原型链只是示例,不要依赖固定层数
原文使用锚元素的 getAttribute() 说明 DOM 原型链可能比自定义类更长:
const anchor = document.createElement('a')
const title = anchor.getAttribute('title')
具体方法位于哪一层、原型链有多少层,会因浏览器和版本而变化,不能把“6 个原型”“7 次检查”作为固定规则。对属性读取而言,抽象模型仍然是:
- 检查接收者自身是否有属性;
- 沿
[[Prototype]]查找; - 找到数据属性或访问器后按规范读取;
- 到达
null仍未找到则返回undefined。
三、Inline Cache 与原型依赖
1. Inline Cache 的直觉
当同一个代码位置反复读取相似结构的对象时,引擎可以记录过去的反馈,并在下一次访问时先检查少量条件:
- 这个接收者是否具有预期结构;
- 原型链是否仍满足查找假设;
- 目标属性的布局或值是否仍可直接使用。
命中时可以减少重复的通用查找;不命中时回退到更通用的路径,并更新反馈。常见术语包括:
- 单态(monomorphic):一个访问点主要看到一种结构;
- 多态(polymorphic):看到少量结构;
- 超多态(megamorphic):结构种类很多,专门化收益降低。
这些都是实现层面的概念,不是开发者可以强制设置的开关。
2. 原型保护与 ValidityCell
原译文以 2018 年 V8 为例,介绍 ValidityCell:当相关原型或祖先原型改变时,某个依赖可能失效,Inline Cache 下次命中时就不能继续使用旧假设。
这个模型可以帮助理解“为什么原型改变可能造成去优化”,但 ValidityCell 不是 ECMAScript 概念,也不应假设今天所有 V8 版本都以完全相同的内部结构实现。
在概念上,第一次访问可能记录:
- 接收者结构;
- 找到属性的原型;
- 属性在该结构中的布局信息;
- 能证明原型链仍未改变的依赖。
之后的访问只需验证这些假设:
如果假设失效,引擎可以使相关缓存失效、回退到通用查找或重新编译。并不是所有缓存都会失效,实际影响取决于哪些优化代码依赖这条原型链。
3. 修改原型为什么通常不推荐
下面的代码修改了所有普通对象常见的祖先原型:
Object.prototype.newMethod = function newMethod() {
return 'example'
}
// 依赖 Object.prototype 的属性查找可能需要重新检查或去优化。
const result = ({ }).newMethod()
delete Object.prototype.newMethod
不推荐运行时修改内建原型,原因不只是性能:
- 可能让依赖该原型链的优化代码失效;
- 可能与其他库、框架或未来标准属性发生命名冲突;
- 可能改变第三方代码对属性存在性的判断;
- 可能带来安全和调试问题。
如果需要兼容旧环境,优先使用特性检测、命名空间函数或模块内工具;确实需要 polyfill 时,应在应用代码运行前按标准定义,并遵循兼容性条件。
四、实际编码建议
1. 在构造函数中稳定初始化字段
class User {
constructor(id, name) {
this.id = id
this.name = name
this.role = undefined
}
}
统一初始化顺序有助于产生相似对象结构,但不要为了追求某个 Shape 而牺牲可读性。现代引擎会持续优化许多自然写法,最终应以真实性能数据为准。
2. 不要在热点路径频繁改变原型
// 更推荐:创建时确定原型
const user = Object.create(userPrototype)
// 谨慎:对象已经被大量使用后再修改原型
Object.setPrototypeOf(user, anotherPrototype)
如果需要不同类型的对象,优先使用明确的类、工厂或 Object.create() 在创建阶段设置原型。
3. 不要把引擎优化建议当成语言规则
以下说法都不应作为绝对结论:
- “所有对象都必须按同一顺序创建属性”;
- “修改一个属性值一定会改变 Shape”;
- “修改一个原型会让所有 Inline Cache 失效”;
- “某个引擎一定比其他引擎快”;
- “只要使用 class 就一定比对象字面量快”。
这些说法脱离引擎版本、运行平台、数据规模和实际工作负载后都可能失真。
五、总结
- JavaScript 引擎常用分层执行、类型反馈和 Inline Cache 来平衡启动时间与热点性能;
- Shape/Hidden Class 等结构信息帮助引擎描述对象布局,但名称和策略属于实现细节;
- 原型属性读取需要同时考虑对象自身、原型链、访问器、Proxy 和动态修改;
class方法通常利用原型共享,但 class 还有严格模式、私有元素等额外语义;- 原型或内建对象的运行时修改可能使相关优化失效,并带来兼容性和安全风险;
- 不要凭历史文章或微基准过度手动“迎合引擎”,先保持清晰代码,再用目标环境的真实数据验证。