首页
/ Ladybird LibWeb 包装器架构:Wrappable、WrapperWorld 与 C++ 到 JavaScript 的三层包装模型

Ladybird LibWeb 包装器架构:Wrappable、WrapperWorld 与 C++ 到 JavaScript 的三层包装模型

2026-09-04 09:12:12作者:冯梦姬Eddie

本文基于 Ladybird 浏览器项目中的 WrapperArchitecture.md,系统讲解 LibWeb 如何把内部 C++ 实现对象反射给 JavaScript 的三层包装模型:Web::Bindings::Wrappable 实现基类、WebIDL 生成器产出的 PlatformObject 包装类,以及按"可观测世界"划分的 WrapperWorld 缓存。读完本文,你将掌握 LibWeb 的包装器身份规则(同一实现对象在每个 WrapperWorld 中至多一个包装器)、Realm 选择契约、包装器保持(preservation)机制与 GC 活性模型,并了解编写包装器/GC 相关测试时必须遵守的硬性规则。

三层包装模型

LibWeb 向 JavaScript 暴露内部 C++ 实现对象采用三层结构:

  1. Web::Bindings::Wrappable 是"可被反射进 JavaScript"的 C++ 实现对象基类;
  2. WebIDL 生成器在 Web::Bindings 中产出 PlatformObject 派生的包装类,包装器持有实现对象引用并实现所有面向 Web 的对象行为(属性、方法、原型链);
  3. 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_wrappablewrap(WrapperWorld&, JS::Realm& preferred_realm, GC::Ref<Wrappable>) 是唯一入口,第二个参数是"首选 realm"(preferred realm),只影响新包装器的分配位置,不影响身份键。

Wrappable 中还有一个内联弱指针快路径 m_main_world_wrapperWrappable.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 本地的包装器缓存单元。当前具体类型是 InternalExtension(对应 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.pyoperations.pyattributes.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 决策只落在两处:

  1. Node 的可包装对象可能先于被包装就从多个 global 被观察——此时应覆写 relevant_global_impl(),返回所属 WindowDocument 的 window 或 worker 全局作用域实现对象。源码中该钩子的默认实现是返回 nullptr 的虚函数(Wrappable.h 第 151 行),覆写后对象才能从"所属全局实现"派生出稳定的包装器摆放 realm;
  2. 为后续兑现而铸造 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 用两个位标志 MainWorldWrapperIsPreservedHasNonMainWorldPreservedWrappersWrappable.h 第 208-211 行)记录保持状态,Wrappable::visit_edges 对已保持包装器走强引用边。

活性(Liveness)模型

普通的包装器/实现对象边及其强度为:

强度 载体
包装器 → 实现对象 强引用 生成的包装器成员
实现对象 → 缓存包装器 弱引用 WrapperWorld 映射 + 内联主世界代表指针
实现对象 → 已保持包装器 强引用 Wrappablevisit_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 映射存放在按世界划分的包装器上,而不是实现对象上;WindowWrapperLocationWrapper 都遵循这一模式。由此带来两点设计收益:

  • 描述符缓存的可达性是"普通包装器可达性":缓存的描述符值与访问器经包装器追踪(trace),但它们不是根(root)——当没有任何循环外引用到达时,包装器/realm 循环整体仍可被收集。这消除了对"世界序列号"(world serials)或在 detach 时清理实现对象自有映射的需要;
  • 每个世界的包装器拥有自己的描述符映射。为某个世界缓存的描述符不可能与另一个世界的描述符混叠,因为它存储在另一个包装器对象里。

已接受的风险(Accepted Risks)

文档明确列出四项被接受而非修复的行为,理解它们有助于判断 LibWeb 包装器语义的边界:

  1. 被收养节点(adopted nodes)可能继续使用原主世界 realm 的包装器——当该包装器已被保持时。接受原因是:清除这条边会使包装器身份依赖导航/bfcache 时序;
  2. 状态回到默认后保持可能过度持有包装器(例如删除 expando 之后)。append-only 保持简单、确定性强,避免了"所有包装器状态都是默认"这种脆弱检测器;
  3. 没有 relevant_global_impl() 覆写的非 Node 实现回退到调用方首选 realm。若其未保持的包装器死亡,同一世界内新分配的替代包装器可能落在不同 realm——但 wrapper-world 身份不变;有覆写的实现则从所属全局实现派生稳定摆放;
  4. 跨源描述符缓存键使用 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 的测试必须遵守:

  1. 丢弃再获取(Drop and re-acquire)。internals.gc() 之间被活动局部变量持有的包装器永远无法被收集,测试因此断言不了任何东西。正确姿势:在 IIFE 内创建/修改(或把变量置 null)→ gc() → 经实现对象(getElementById、集合访问等)重新获取 → 对重新获取的对象断言。优先使用 include.js 中的 withCollectedWrapper()。只有当测试目的恰恰是"持有的引用钉住身份"时才可持有引用——并在注释中说明。
  2. Function 的参数签名是 (params..., body)。 Function("x => f(x)") 会把字符串当作零参函数的函数。跨 realm 调用应写成 frame.contentWindow.Function("arg", "return arg.item(0)"),并验证结果不是 undefined
  3. 提交期望值文件前逐字阅读。 如果期望输出含 falseundefined 或未变化的"before"值,那么要么测试记录了一个失败,要么是有意的负断言——后者必须在测试中写明。
  4. 证明测试能失败。 每个守护某个修复的回归测试,本地回滚修复、观察测试失败、再恢复,并把失败文本记录到变更说明中。有修复/无修复都通过的测试是"覆盖固定点"(coverage pin),必须如此标注。
  5. 验收标准未达成时不得标记工作完成——应记录显式偏差。多个测试失败共享同一签名时,先诊断共同原因,再写逐点修复;绿色门禁只是必要条件,绝不是目标。

小结

LibWeb 的包装器架构可以用三句话概括:实现对象继承 Wrappable 获得"可反射"资格;WebIDL 生成的 PlatformObject 包装器承载全部 JS 行为并以 (实现对象, WrapperWorld) 为身份键;WrapperWorld 以弱引用缓存维系每个可观测世界的身份一致性,配合显式的保持标志、ActivityRoot 活性管理和 finalize 顺序约束,实现"跨 realm 可见、跨世界隔离、GC 可回收"。对开发者而言,日常实现代码保持 realm-free,包装器相关工作收在 wrap()/impl_from()/preserve_wrapper() 的窄接口内,测试则必须按"丢弃再获取"范式编写——这三条纪律是这套架构长期演进(包括未来实现对象异构化)的前提。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384