Ladybird LibWeb 包装器架构:Wrappable、WrapperWorld 与 C++ 到 JavaScript 的三层包装模型
本文基于 Ladybird 浏览器项目中的 WrapperArchitecture.md,系统讲解 LibWeb 如何把内部 C++ 实现对象反射给 JavaScript 的三层包装模型:Web::Bindings::Wrappable 实现基类、WebIDL 生成器产出的 PlatformObject 包装类,以及按"可观测世界"划分的 WrapperWorld 缓存。读完本文,你将掌握 LibWeb 的包装器身份规则(同一实现对象在每个 WrapperWorld 中至多一个包装器)、Realm 选择契约、包装器保持(preservation)机制与 GC 活性模型,并了解编写包装器/GC 相关测试时必须遵守的硬性规则。
三层包装模型
LibWeb 向 JavaScript 暴露内部 C++ 实现对象采用三层结构:
Web::Bindings::Wrappable是"可被反射进 JavaScript"的 C++ 实现对象基类;- WebIDL 生成器在
Web::Bindings中产出PlatformObject派生的包装类,包装器持有实现对象引用并实现所有面向 Web 的对象行为(属性、方法、原型链); Web::Bindings::WrapperWorld为一个可观测世界(observable world)拥有包装器身份,"包装一个实现对象"就是在调用方的WrapperWorld中查缓存或新建包装器。
这个模型保证包装器身份以 (实现对象, WrapperWorld) 二元组为键:同一个实现对象可以在不同 realm 或隔离世界中各自被观察,但同一世界内看到的 JS 对象恒等(=== 比较稳定)。
从源码看,三层的核心定义分别在:
- 实现侧基类 Wrappable:派生自
JS::Cell,提供interface_name()/implements_interface()接口名判别、fast_is<T>()/fast_as<T>()类型检查,以及extract_an_origin()、supported_property_names()等供包装器转发的虚钩子。头文件中还有几行关键的编译期断言(Wrappable.h 第 238-241 行),静态保证Wrappable*永远不能隐式转成JS::Value——C++ 实现对象绝不能绕过包装器直接漏给 JS; - 世界缓存 WrapperWorld:核心成员是
GC::WeakHashMap<Wrappable, PlatformObject> m_wrappers弱映射缓存与GC::WeakHashSet<GCAllocatedWrappable> m_preserved_wrappables弱集合。源码注释(WrapperWorld.h 第 58-61 行)明确:主世界缓存单元是 agent 本地、可跨同 agent 多个 realm 的;非主世界缓存单元则是 realm 本地——一个跨多 realm 的逻辑隔离世界必须为每个 realm 分配一个WrapperWorld单元。缓存两端都是弱引用,缓存本身不能保住实现对象或包装器; - 包装函数族 wrap()/create_wrapper_for_wrappable:
wrap(WrapperWorld&, JS::Realm& preferred_realm, GC::Ref<Wrappable>)是唯一入口,第二个参数是"首选 realm"(preferred realm),只影响新包装器的分配位置,不影响身份键。
Wrappable 中还有一个内联弱指针快路径 m_main_world_wrapper(Wrappable.h 第 181 行,GC::Weak<PlatformObject> 类型),但权威缓存始终是 WrapperWorld 的映射——因为同一实现对象可能在同一 agent 内经不同 realm 可见而不分裂身份。若主世界包装器缓存命中来自另一个主世界单元的包装器,会被视为不变量违例:跨 agent 的对象图从不直接共享。头文件中的尺寸断言 static_assert(sizeof(Wrappable) == 24)(Wrappable.h 第 184 行)说明这个弱指针的布局开销是被刻意压缩的。
World 模型:主世界与非主世界
主世界。 每个 HTML agent 拥有一个主世界 WrapperWorld。对 window 而言,这意味着按"同-origin window agent"(similar-origin window agent)划分单元,而不是按 Page 划分:同 agent 的辅助页面(例如同源的 window.open())可以直接观察彼此的 DOM 对象,因此必须共享主世界包装器身份。Worker agent 天然获得自己独立的主世界单元。文档同时指出现实中的限制:同一 WebContent 进程内不同 origin 的 window 当前共享同一个 SimilarOriginWindowAgent,因而也共享该缓存单元;真正的 agent-cluster 隔离是未来工作(包括 PageClient.cpp 中标注的进程内 COOP/noopener 弹窗场景)。
非主世界。 非主世界是 realm 本地的包装器缓存单元。当前具体类型是 Internal 和 Extension(对应 WrapperWorld 构造参数 WrapperWorldType,见 WrapperWorld.h 第 31-33 行),各自拥有独立包装器缓存,且不得占用主世界槽位。两个关键约束:
- 可能持有已保持(preserved)包装器的非主世界,在 embedder 丢弃它之前必须显式调用
detach()。析构函数会无条件校验这一点,唯一例外是CollectEverything收集期间——那时无法安全遍历弱 wrappable 引用并改动保持列表(该语义直接写在 WrapperWorld.h 第 34-39 行 的注释里); - 对一个已 detach 的世界执行包装、保持或缓存插入,同样会被无条件
VERIFY拒绝,且 release 构建也生效。
主世界单元是 agent 生命周期对象,不允许 detach(WrapperWorld.h 第 44-46 行 的 detach() 注释)。
Realm 规则:每个 IDL 成员只做一次 realm 决策
生成的绑定对每个 IDL 成员做一次 realm 决策,规则是:
| IDL 成员类型 | realm 决策 |
|---|---|
| 实例成员(默认) | 使用经过校验的 receiver realm |
| 静态成员、构造器、命名空间成员 | 使用调用方/当前 realm |
标注 [NeedsCallerRealm] 的实例成员 |
唯一的例外出口:其可观察分配 realm 是调用方 realm |
详细的策略文档(包括异常包装与当前各类 opt-out 分类)位于 REALM_POLICY.md,该文件在生成器源码树 Meta/Generators/libweb_bindings/ 内,与 realms.py、operations.py、attributes.py 等生成逻辑放在一起。
WindowProxy 有一条特殊的 receiver 规则:对主世界 proxy,Bindings::this_value_realm() 解析到活动 [[Window]] 的 realm;对非主世界 proxy,则停留在 proxy 自身的 realm。这正是 Wrappable.h 第 65 行 声明的 this_value_realm(fallback_realm, this_value) 辅助函数的用途。
面向 DOM 开发者的 Realm 契约
文档给实现代码定了一条非常简洁的边界:除非算法明确是绑定相邻(binding-adjacent)或 JS 对象具现(materialization)代码,否则实现代码不应提及 realm。绑定层负责为异常、返回值、回调和普通包装器创建选择可观察 realm。
日常工作中的 realm 决策只落在两处:
- 非
Node的可包装对象可能先于被包装就从多个 global 被观察——此时应覆写relevant_global_impl(),返回所属Window、Document的 window 或 worker 全局作用域实现对象。源码中该钩子的默认实现是返回nullptr的虚函数(Wrappable.h 第 151 行),覆写后对象才能从"所属全局实现"派生出稳定的包装器摆放 realm; - 为后续兑现而铸造
Promise——使用面向相关对象或 settings object 的 promise 发放(vending)辅助函数。
其余一切——异常、返回值、事件、回调——在普通实现代码里都是 realm-free 的。现有实现侧的 realm 引用属于过渡性质,除非服务于:规范选定的 JS 对象具现、流/块转换、结构化序列化、模块/脚本管道或回调调用。Bindings/、HTML/Scripting/ 和 WebIDL/ 之外新增的 realm 引用应被视为架构变更,必须在 lint allowlist 中给出理由。
保持(Preservation)与包装器状态
触发保持的状态。 包装器上出现任何非默认 JavaScript 状态,就保持(preserve)这个确切的包装器,包括:
- 普通、accessor 与 symbol expando(扩展属性);
- 通过 inline-cache 路径创建的 expando;
- 子类构造状态(subclass construction state);
- 自定义 prototype;
- 非默认的 extensibility/frozen/sealed 状态。
底层机制。 C++ 侧的 inline-cache 保持触发器是 JS::Object::Flag::RequiresSlowAddOwnProperty(定义在 Object.h 第 449 行,1 << 8),平台包装器会设置这个标志,使快速 expando 写入改走"保持型" add-own-property 路径(LibWeb 侧的入口是 PlatformObject.h 第 146 行 的 ordinary_define_own_property_and_preserve_wrapper_if_needed)。
append-only 语义。 保持对包装器是刻意只增不减的:删除一个 expando 不会解除保持。测试删除行为时应断言"被删属性在 GC 后仍是删除状态",而不是断言"包装器变得可回收"。
测试辅助函数。 检查保持包装器行为应使用 include.js 第 69-82 行 提供的 withCollectedWrapper(makeAndMark, reacquire, verify):
function withCollectedWrapper(makeAndMark, reacquire, verify) {
let wasPreserved = false;
(() => {
const wrapper = makeAndMark();
wasPreserved = internals.wrapperIsPreserved(wrapper);
})();
if (!wasPreserved) {
throw new Error("Expected wrapper to be preserved before GC");
}
internals.gc();
verify(reacquire());
}
它先在 IIFE 里创建对象并校验 internals.wrapperIsPreserved()(本地引用随即被丢弃),然后 internals.gc(),再通过实现对象重新获取(re-acquire)包装器并交给 verify 断言——这正好对应下文"测试硬性规则"的第 1 条。
保持状态在实现侧的存放位置也可以从源码得到印证:GCAllocatedWrappable 用两个位标志 MainWorldWrapperIsPreserved 与 HasNonMainWorldPreservedWrappers(Wrappable.h 第 208-211 行)记录保持状态,Wrappable::visit_edges 对已保持包装器走强引用边。
活性(Liveness)模型
普通的包装器/实现对象边及其强度为:
| 边 | 强度 | 载体 |
|---|---|---|
| 包装器 → 实现对象 | 强引用 | 生成的包装器成员 |
| 实现对象 → 缓存包装器 | 弱引用 | WrapperWorld 映射 + 内联主世界代表指针 |
| 实现对象 → 已保持包装器 | 强引用 | 经 Wrappable 的 visit_edges 访问 |
待处理活动(pending activity)是显式的:定时器、XHR、WebSocket、ResizeObserver 这类对象在状态迁移时获取和释放 GC::ActivityRoot(对应 LibGC/ActivityRoot.h),而不是依赖"静默的 GC 虚谓词"判断存活。
最终化(finalization)顺序上,LibGC 在弱指针重新被视为存活之前先清空包装器缓存条目:依赖弱包装器缓存的代码依赖这个顺序——包装器最终化先删除缓存条目,随后弱引用才会清除。WrapperWorld 声明了 OVERRIDES_FINALIZE = true 并重写 finalize()(WrapperWorld.h 第 29 行与第 54 行),承担这条清理职责。
Window 与 Location 的跨源描述符
跨源(cross-origin)PropertyDescriptor 映射存放在按世界划分的包装器上,而不是实现对象上;WindowWrapper 和 LocationWrapper 都遵循这一模式。由此带来两点设计收益:
- 描述符缓存的可达性是"普通包装器可达性":缓存的描述符值与访问器经包装器追踪(trace),但它们不是根(root)——当没有任何循环外引用到达时,包装器/realm 循环整体仍可被收集。这消除了对"世界序列号"(world serials)或在 detach 时清理实现对象自有映射的需要;
- 每个世界的包装器拥有自己的描述符映射。为某个世界缓存的描述符不可能与另一个世界的描述符混叠,因为它存储在另一个包装器对象里。
已接受的风险(Accepted Risks)
文档明确列出四项被接受而非修复的行为,理解它们有助于判断 LibWeb 包装器语义的边界:
- 被收养节点(adopted nodes)可能继续使用原主世界 realm 的包装器——当该包装器已被保持时。接受原因是:清除这条边会使包装器身份依赖导航/bfcache 时序;
- 状态回到默认后保持可能过度持有包装器(例如删除 expando 之后)。append-only 保持简单、确定性强,避免了"所有包装器状态都是默认"这种脆弱检测器;
- 没有
relevant_global_impl()覆写的非Node实现回退到调用方首选 realm。若其未保持的包装器死亡,同一世界内新分配的替代包装器可能落在不同 realm——但 wrapper-world 身份不变;有覆写的实现则从所属全局实现派生稳定摆放; - 跨源描述符缓存键使用 settings object 的裸地址。该键只在一个存活包装器拥有的描述符映射内部使用,条目不会在包装器死亡后被复用——裸地址只是同生命周期缓存查找的身份令牌,不是拥有引用。
未来的实现对象表示:绑定层的窄接口
文档对绑定层提出一条硬约束:不得假设实现对象永久是 GC 分配的。长期预期状态是异构的——部分实现保留 GC 单元,部分变成 C++ 引用计数对象,部分变成 Rust 支撑的句柄。
新增包装器相关工作必须收在窄绑定接口之后:
wrap();impl_from<T>()/wrappable_impl();preserve_wrapper();WrapperWorld缓存;- 生成的根包装器的实现对象成员。
禁止在接口之外增加临时性的实现指针管道或新的 GC 专属活性旁路。未来迁移点集中在:生成的实现句柄、弱的实现→包装器缓存、已保持包装器根,以及当前的 ActivityRoot 模型。
仓库源码已经体现了这个方向的过渡设计:Wrappable 之下专门拆出了 GCAllocatedWrappable 基类,源码注释写明"把 GC 所有权与保持标志隔离在单独的基类里,可以让各个类独立迁移到其它所有权模型"(Wrappable.h 第 186-188 行)。
包装器与 GC 测试的硬性规则
文档最后五条规则源于一个真实教训:早期包装器工作产出了约 10 个"不可能失败"的回归测试,还有 2 个提交的期望值文件把"它本应捕捉的 bug"编码成了期望输出。因此任何触及包装器身份、保持、realm 或 GC 的测试必须遵守:
- 丢弃再获取(Drop and re-acquire)。 在
internals.gc()之间被活动局部变量持有的包装器永远无法被收集,测试因此断言不了任何东西。正确姿势:在 IIFE 内创建/修改(或把变量置 null)→gc()→ 经实现对象(getElementById、集合访问等)重新获取 → 对重新获取的对象断言。优先使用 include.js 中的withCollectedWrapper()。只有当测试目的恰恰是"持有的引用钉住身份"时才可持有引用——并在注释中说明。 Function的参数签名是 (params..., body)。Function("x => f(x)")会把字符串当作零参函数的函数体。跨 realm 调用应写成frame.contentWindow.Function("arg", "return arg.item(0)"),并验证结果不是undefined。- 提交期望值文件前逐字阅读。 如果期望输出含
false、undefined或未变化的"before"值,那么要么测试记录了一个失败,要么是有意的负断言——后者必须在测试中写明。 - 证明测试能失败。 每个守护某个修复的回归测试,本地回滚修复、观察测试失败、再恢复,并把失败文本记录到变更说明中。有修复/无修复都通过的测试是"覆盖固定点"(coverage pin),必须如此标注。
- 验收标准未达成时不得标记工作完成——应记录显式偏差。多个测试失败共享同一签名时,先诊断共同原因,再写逐点修复;绿色门禁只是必要条件,绝不是目标。
小结
LibWeb 的包装器架构可以用三句话概括:实现对象继承 Wrappable 获得"可反射"资格;WebIDL 生成的 PlatformObject 包装器承载全部 JS 行为并以 (实现对象, WrapperWorld) 为身份键;WrapperWorld 以弱引用缓存维系每个可观测世界的身份一致性,配合显式的保持标志、ActivityRoot 活性管理和 finalize 顺序约束,实现"跨 realm 可见、跨世界隔离、GC 可回收"。对开发者而言,日常实现代码保持 realm-free,包装器相关工作收在 wrap()/impl_from()/preserve_wrapper() 的窄接口内,测试则必须按"丢弃再获取"范式编写——这三条纪律是这套架构长期演进(包括未来实现对象异构化)的前提。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00