深入 Boa 引擎的对象 Shapes(隐藏类)机制:从 Root Shape 到属性表分叉与回滚重建

发布时间:2026/9/28 2:54:24
深入 Boa 引擎的对象 Shapes(隐藏类)机制:从 Root Shape 到属性表分叉与回滚重建 编程语言编译器开发工具【免费下载链接】boaBoa is an embeddable Javascript engine written in Rust.项目地址https://gitcode.com/gh_mirrors/bo/boa点击查看免费下载对象 Shapes隐藏类是现代 JavaScript 引擎实现快速属性访问的核心数据结构。本文以 Boa用 Rust 编写的可嵌入式 JavaScript 引擎的官方文档 docs/shapes.md 为主线通过完整的 JavaScript 示例与逐步演进的形状Shape链图解系统讲解 Root Shape、PropertyTable、属性插入 / 删除 / 属性描述符变更等转换transition的底层原理并结合 core/engine/src/object/shape 目录下的真实源码SharedShape、UniqueShape、PropertyTable、Slot、ForwardTransition 等进行源码级印证。读完本文你将能理解 Boa 如何用隐藏类加速对象属性访问、属性表为何会分叉、删除属性时如何回滚重建转换链以及隐藏类与 GC、内联缓存inline cache之间的关系。为什么要研究对象 Shapes在 JavaScript 中对象本质上是动态的可以在运行时任意添加、删除、修改属性这与静态语言中结构固定的对象截然不同。如果每次属性访问都走一遍字符串键 → 哈希查找的路径性能会大打折扣。Shapes也叫隐藏类Hidden Classes的核心思想是把对象的形状有哪些属性、每个属性的描述符、它们在对象存储中的位置从对象本身中剥离出来作为独立的结构Shape共享给所有形状相同的对象。这样一来拥有相同 Shape 的对象可以共享同一份属性描述信息无需各自保存属性访问只需要Shape 中查找属性 → 得到存储槽位索引 → 直接访问对象存储数组接近数组索引访问的速度属性按插入顺序连续排列在对象内部的紧凑存储dense storage中for...in、Object.keys()等按插入顺序遍历的操作也能高效完成。Boa 中 Shapes 的实现位于 core/engine/src/object/shape/mod.rs其中Shape是一个对内部两种变体的统一抽象enum Inner { Unique(UniqueShape), Shared(SharedShape), }即 Shape 分为**共享形状SharedShape与唯一形状UniqueShape**两类后文会详细说明两者的分工。一切从 Root Shape 开始官方文档用实例来解释 Shapes 机制一切始于Root Shape根形状Root Shape 是整个转换链transition chain的起点它带有一个指向PropertyTable的指针。从源码看root_shape.rs 中的RootShape本质上是SharedShape的包装pub struct RootShape { shape: SharedShape, }而SharedShape::root()见 shared_shape/mod.rs创建的根形状具有这些特征pub(crate) fn root() - Self { Self::new(Inner { forward_transitions: ForwardTransition::default(), prototype: None, // 根形状没有原型 property_count: 0, // 属性计数为 0 // Most of the time the root shape initiates with between 1-4 properties. property_table: PropertyTable::with_capacity(4), previous: None, // 根形状没有 previous flags: ShapeFlags::default(), transition_count: 0, }) }注意根形状的PropertyTable初始容量为 4这是基于大多数对象初始就有 14 个属性的经验假设源码注释中明确说明。说明为便于阅读文档约定用S 数字来标注各个形状例如S0、S1。下文沿用这一约定。空对象从根形状转换到带原型的形状现在看一段最简单的 JavaScript 代码let o {};执行后生成的转换链如下这里发生了原型转换prototype transition对象o获得了S1形状。根形状没有原型因此需要转换到一个把Object.prototype作为__proto__的新形状。可以看到S1继承了根形状的PropertyTable——注意这里的继承指的是多个形状共享同一个 PropertyTable 引用而不是复制了一份。源码中对应的是change_prototype_transitionpub(crate) fn change_prototype_transition(self, prototype: JsPrototype) - Self { // 如果之前已经创建过同样的转换直接复用 if let Some(shape) self.forward_transitions().get_prototype(prototype) { ... } let new_inner_shape Inner { forward_transitions: ForwardTransition::default(), prototype: prototype.clone(), property_table: self.property_table().clone(), // 继承属性表 property_count: self.property_count(), previous: Some(self.clone()), transition_count: self.transition_count() 1, flags: ShapeFlags::prototype_transition_from(self.flags()), }; ... }可以看到新形状的property_table直接clone自当前形状PropertyTable内部是RcRefCell...共享引用clone 只是增加引用计数previous指回原形状transition_count加 1。插入属性 x数据属性与槽位索引继续向对象添加属性o.x 100; // 具体值不重要转换链演变为现在对象o拥有了S2形状同样继承了PropertyTable但属性计数变为1并且 PropertyTable 中新增了一条记录x: Slot 0, writable, configurable, enumerable。这里有三个关键信息属性描述符新插入的普通数据属性默认具有writable可写、configurable可配置、enumerable可枚举三个标志Slot 0这是属性在对象自身密集存储dense storage中的索引位置PropertyTable 记录键x被映射到槽位 0并携带属性标志。在源码中这些属性标志就是 slot.rs 中定义的位标志bitflagspub(crate) struct SlotAttributes: u8 { const WRITABLE 0b0000_0001; const ENUMERABLE 0b0000_0010; const CONFIGURABLE 0b0000_0100; const GET 0b0000_1000; const SET 0b0001_0000; // ... inline cache 相关位 }而Slot结构把索引和属性合在一起pub(crate) struct Slot { pub(crate) index: SlotIndex, // u32指向对象存储 pub(crate) attributes: SlotAttributes, }插入属性时PropertyTableInner::insert见 property_table.rs会基于上一个属性的槽位计算新属性的索引这正是因为访问器属性与数据属性占用的存储宽度不同详见后文pub(crate) fn insert(mut self, key: PropertyKey, attributes: SlotAttributes) { let slot Slot::from_previous(self.keys.last().map(|x| x.1), attributes); let index self.keys.len() as u32; self.keys.push((key.clone(), slot)); let value self.map.insert(key, (index, slot)); debug_assert!(value.is_none()); }结合对象o本身来看完整结构如下对象o的存储storage第 0 个元素就是值100与 Shape 中x: Slot 0的记录一一对应。属性值存在对象自己的存储数组里而属性名、描述符、槽位索引存在共享的 Shape/PropertyTable 里——这就是隐藏类隐藏属性的方式对象只保存值结构信息全部上提共享。插入访问器属性 ygetter/setter 占用两个槽位接下来用Object.defineProperty给o添加一个 getter/setter 属性y// getter/setter 的具体实现并不重要 Object.defineProperty(o, y, { enumerable: true, configurable: true, get: function () { return this.x; }, set: function (value) { this.x value; }, });转换链继续延伸这次的转换链变成了S0 → S1 → S2 → S3其中S3是插入y的形状。PropertyTable 中新增记录y: Slot 1, has_get, has_set, configurable, enumerable——它带有has_get和has_set标志。对应的源码是 slot.rs 中的宽度计算逻辑/// Get the width of the slot. pub(crate) fn width(self) - u32 { // accessor take 2 positions in the storage to accommodate for the get and set fields. 1 u32::from(self.is_accessor_descriptor()) }槽位的宽度是可变的数据属性占 1 个存储位置访问器属性占 2 个存储位置分别存放get函数和set函数。这正是文档强调的Slots are varying in length, two for accessor properties and one for data properties, the index points to the first value in the object storage.可以看到对象o的存储中新增了两个元素Element 1是 get 函数、Element 2是 set 函数而y的 Slot 索引1指向的是第一个值get 函数的位置。相邻槽位索引的递推逻辑在Slot::from_previous中实现pub(crate) fn from_previous(previous_slot: OptionSelf, new_attributes: SlotAttributes) - Self { let Some(previous_slot) previous_slot else { return Self { index: 0, attributes: new_attributes }; }; Self { index: previous_slot.index previous_slot.width(), // 上一个槽位索引 上一个槽位宽度 attributes: new_attributes, } }属性查找用属性计数做边界判定文档提出了一个很关键的问题如果一个对象只有S2形状我们试图访问属性yShape 如何知道它到底有没有y这个属性答案就藏在形状上的属性计数property count里。S2的属性计数是1。PropertyTable 中所有属性都存储在一个保留插入顺序且可索引的映射中源码上是Vec(PropertyKey, Slot)保序 FxHashMapPropertyKey, (u32, Slot)加速查找的双结构见 property_table.rs。查找时如果某属性在 PropertyTable 中的索引大于等于当前形状的属性计数就说明该属性不属于这个形状pub(crate) fn lookup(self, key: PropertyKey) - OptionSlot { let property_count self.property_count(); if property_count 0 { return None; } let property_table_inner self.property_table().inner().borrow(); // 检查我们是否在访问属于另一个形状的属性 if let Some((property_table_index, slot)) property_table_inner.map.get(key) *property_table_index self.property_count() { return Some(*slot); } None }对应到文档的描述When we do a lookup on the property table, if the index of the property is greater than the property count (1), then it does not belong to the shape.这就是为什么y索引 1在S2属性计数 1上查找会失败、而在S3属性计数 2上能查到的原因——属性计数同时充当了形状拥有边界。形状复用新对象共享同一个 Shape现在再创建一个带属性x的新对象let o2 { x: 200 };由于o2的构造过程与o添加x时完全一致同样的原型、同样的插入属性x、同样的属性描述符o2会直接复用已有的S2形状而不会创建新的形状。为什么形状知道可以复用S1再转到S2文档明确指出图中省略了真实的指针结构——每个形状都持有指向已经发生过的前向转换的指针这些指针是弱引用weak pointers因此不会阻止未使用的形状被垃圾回收。为了让图示更清晰避免太多箭头这些前向转换指针在图中被省略了。源码中对应的是 shared_shape/mod.rs 的insert_property_transitionpub(crate) fn insert_property_transition(self, key: TransitionKey) - Self { // 检查我们是否已经创建过同样的转换如果是就直接复用 if let Some(shape) self.forward_transitions().get_property(key) { if let Some(inner) shape.upgrade() { return Self { inner }; } self.forward_transitions().prune_property_transitions(); } ... }前向转换表的具体实现在 forward_transition.rs它以FxHashMapTransitionKey, WeakGcSharedShapeInner保存键属性名属性描述符→ 转换后的形状的弱引用映射。TransitionKey在 shared_shape/mod.rs 中定义pub(crate) struct TransitionKey { pub(crate) property_key: PropertyKey, pub(crate) attributes: SlotAttributes, }也就是说转换的指纹是属性键 属性描述符的组合——只有两者都完全一致时才能复用转换结果。这正是{x: 100}和{x: 200}能共享S2的原因值不同不影响形状而{x: 1}与{x: 1, y: 2}无法共享属性集合不同。前向转换的弱引用与定期清理ForwardTransition内部使用WeakGc保存转换目标未使用的形状可被 GC 回收。同时它维护一个insertion_count_since_prune计数器每插入 256 次就清理一次已被 GC 回收的弱引用u8::MAX溢出归零触发见 forward_transition.rspub(super) fn insert_property(self, key: TransitionKey, value: GcSharedShapeInner) { let mut this self.inner.borrow_mut(); let properties this.properties.get_or_insert_with(Box::default); if properties.get_and_increment_count() u8::MAX { properties.map.retain(|_, v| v.is_upgradable()); } properties.map.insert(key, WeakGc::new(value)); }在 shared_shape/tests.rs 中test_prune_property_on_counter_limit用 255 次连续插入验证了这个清理机制插入第 256 次后被 GC 回收的过渡目标会被清理映射规模从 255 骤降到 1计数器复位。属性表分叉不同插入顺序产生新分支现在给o2定义一个属性z而不是yo2.z 300;转换树发生如下变化现在o2获得了S4形状。注意图中PropertyTable 发生了分叉forko2从S2只有x出发插入z在位置1放入的是数据属性z而S2原有的 PropertyTable 里位置1已经被访问器属性y占据。两个形状的属性描述符集合不再兼容因此不能再共享同一个 PropertyTable必须深拷贝一份——这就是分叉。源码中对应的是PropertyTable::add_property_deep_clone_if_needed见 property_table.rspub(crate) fn add_property_deep_clone_if_needed( self, key: PropertyKey, attributes: SlotAttributes, property_count: u32, ) - Self { { let mut inner self.inner.borrow_mut(); // 属性表容量刚好匹配且不含该键直接原地插入共享 if (property_count as usize) inner.keys.len() !inner.map.contains_key(key) { inner.insert(key, attributes); return self.clone(); } } // 属性已存在或属性不一致需要深拷贝属性表 let this self.deep_clone(property_count); { let mut inner this.inner.borrow_mut(); inner.insert(key, attributes); } this }两个分支从S2开始分道扬镳S2 → S3插入y共享原表S2 → S4插入z深拷贝出新表。这也是隐藏类优化中典型的形状树分叉现象——属性插入顺序不同、属性描述符不同的对象会各自长出独立的分支同构的对象越多、分支越少复用率越高。从S4的构造也可以看到transition_count的语义S4的transition_count是 3S0→S1→S2→S4共 3 次转换但它的property_count是 2。删除属性回滚到基准点并重建转换链最后考虑从对象o删除属性xdelete o.x;注意图中省略了o2及其形状因为删除o的属性不应影响o2的形状树。删除的算法步骤文档原述在链上找到当初插入该属性的节点取其**父节点base**作为重建的起点记住该属性插入之后发生的所有转换从 base 出发逐个重新应用这些转换跳过被删除的属性返回重建链的最后一个形状作为对象的新形状。源码中对应的是remove_property_transition与rollback_before见 shared_shape/mod.rspub(crate) fn remove_property_transition(self, key: PropertyKey) - Self { let (mut base, prototype, transitions) self.rollback_before(key); // 如果有原型转换先应用原型转换 if let Some(prototype) prototype { base base.change_prototype_transition(prototype); } // 重新应用此前的属性转换从最旧到最新 for (property_key, attributes) in transitions.into_iter().rev() { let transition TransitionKey { property_key, attributes }; base base.insert_property_transition(transition); } base }rollback_before从当前形状沿previous指针向上回溯跳过原型转换、找到插入目标属性的节点返回其父形状作为base同时收集沿途所有属性转换用IndexMap保序且同一属性只保留最新属性描述符以尽量构造更小的树。回溯过程的源码注释给出了一个直观的例子// INSERT(x) INSERT(y) INSERT(z) // { } ------------ { x } ------------ { x, y } ------------ { x, y, z } // // 对 y 调用 rollback // // INSERT(x) INSERT(y) INSERT(z) // { } ------------ { x } ------------ { x, y } ------------ { x, y, z } // ^ // \--- base要重放的转换数组INSERT(z)原型None由于o删除x后剩下的y是访问器属性占据两个存储位置重建后y的槽位从Slot 1前移为Slot 0对象的存储也随之前移Element 0、Element 1都是函数。这也解释了为什么删除中间属性时属性表的深拷贝与槽位重排是必须的。值得注意删除链尾属性最后一个插入的属性时只需回退到previous即可而删除中间属性时则需要走完整的回滚 重建路径。删除过程同样会通过前向转换表尽力复用已有的中间形状避免无谓的新建。SharedShape 与 UniqueShape 的分工前文提到Shape内部有两种变体它们的取舍值得展开SharedShape共享形状被多个对象共享是隐藏类优化的主力。它通过不可变链 前向转换表实现复用任何属性变更插入、删除、改描述符、改原型都产生新形状并把旧形状作为previous旧形状保持不变从而不影响仍引用它的其他对象。UniqueShape唯一形状只属于单个对象的形状典型场景是**内建对象builtin objects**等天生唯一的对象。见 unique_shape.rs/// Represents a [Shape] that is not shared with any other object. /// /// This is useful for objects that are inherently unique like, /// the builtin object.UniqueShape允许原地修改属性表直接可变但修改会导致当前形状失效、返回一个新形状以使指向旧形状的内联缓存inline caches失效——源码中多处注释都强调了这一点。还有一个关键的兜底机制共享形状的转换深度上限。在 mod.rs 中/// The max transition count of a [SharedShape] from the root node, /// before the shape will be converted into a [UniqueShape] const TRANSITION_COUNT_MAX: u16 1024;insert_property_transition、change_attributes_transition、remove_property_transition、change_prototype_transition四个转换入口都会检查一旦从根节点开始的转换次数达到 1024就把 SharedShape 降级为 UniqueShapeto_unique()避免形状链无限增长、前向转换表无限膨胀。这个设计在Shape层面统一处理是隐藏类机制在极端动态对象面前的自我保护。Shapes 的落地对象属性映射与 ObjectTemplateShapes 最终要服务于对象的实际属性读写。在 Boa 中对象通过 property_map.rs 持有 Shape 与存储storagePropertyTable中每个Slot的index直接对应存储数组的下标。此外template.rs 提供了ObjectTemplate用于从预定义的 SharedShape 批量构造同构对象/// Represent a template of an objects properties and prototype. /// This is used to construct as many objects as needed from a predefined [SharedShape]. pub(crate) struct ObjectTemplate { shape: SharedShape, }它暴露了链式 APIproperty(key, attributes)添加数据属性、accessor(key, get, set, attributes)添加访问器属性、set_prototype(prototype)设置原型最后create(data, storage)一次性产出对象。这相当于把形状转换预编译为模板运行时直接按模板批量创建对象并填充存储是内建对象初始化等高频路径的优化手段。小结回顾整个 Shapes 机制可以提炼出几条核心设计主线形状与值分离对象存储只保存属性值属性名、描述符、槽位索引全部上提至共享的 Shape 与 PropertyTable单向转换链 前向转换表属性变更产生新形状previous指针串成链前向转换表以弱引用缓存已创建的转换支持同构对象复用形状同时配合 256 次插入一次的清理机制控制内存可变宽度槽位数据属性占 1 个槽位、访问器属性占 2 个槽位Slot::from_previous保证相邻属性的索引计算正确属性计数即边界属性查找用属性计数判断属性是否属于当前形状避免误读共享 PropertyTable 中属于其他形状的槽位分叉与深拷贝插入顺序或属性描述符不兼容时深拷贝 PropertyTable形成形状树分支删除 回滚重建找到插入节点、取其父形状为基准、重放后续转换最终得到新链尾形状共享与唯一两级设计SharedShape 面向多对象复用UniqueShape 面向内建等唯一对象转换深度超过 1024 时共享形状自动降级为唯一形状以自保。这套机制与 V8 等主流引擎的隐藏类思想一脉相承而 Boa 的 Rust 实现把弱引用前向转换、深拷贝分叉、回滚重建等细节落到了类型系统与 GC 之上。想深入源码的读者可以从 shape/mod.rs 的Shape枚举入手依次阅读 shared_shape/mod.rs、property_table.rs、slot.rs、forward_transition.rs 与 unique_shape.rs配合 shared_shape/tests.rs 中的转换清理测试即可完整掌握 Boa 对象系统的这一核心基石。赞分享编程语言编译器开发工具【免费下载链接】boaBoa is an embeddable Javascript engine written in Rust.项目地址https://gitcode.com/gh_mirrors/bo/boa点击查看免费下载相关推荐深入 V8 隐藏类Map与内联缓存IC动态语言高性能属性访问的引擎原理深入 V8 隐藏类Map与内联缓存IC动态语言高性能属性访问的引擎原理 JavaScript 是动态语言对象结构可以随时增删改但 V8 依然能在语言运行时编译器JIT编译解释器内存管理终极指南深入解析WAMR引用类型的实现机制与高效应用终极指南深入解析WAMR引用类型的实现机制与高效应用 WebAssembly Micro Runtime WAMR 作为轻量级WebAssembly运行时其语言运行时嵌入式物联网V8 堆中 JavaScript 对象与 Map隐藏类的表示原理详解V8 堆中 JavaScript 对象与 Map隐藏类的表示原理详解 导读 本文基于 V8 仓库 docs/heap/objects and maps.md语言运行时编译器JIT编译解释器内存管理上一篇深入理解Jocko Broker核心组件设计与实现原理详解下一篇Fluent UI无障碍ARIA属性参考常用ARIA属性指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询