🥮 mooncakes.io

    XiLaiTL/moobile/vendor/rabbita/vdom does not have a README file

    Children

    pub(all) enum Children[T] {
    Array(Array[T])
    Map(Map[String, T])
    RawHtml(String)
    }

    Event

    #external
    pub type Event

    ★ moobile 改造:事件值是后端无关的不透明类型,不再别名为 @dom.Event。

    为什么必须改:原来的别名让 Props.handlers 的公开签名带着 @dom, 而 @dom 是 js-only 的 DOM 绑定包。后果是外部包既命名不了这个类型、 也换不掉它 —— 这正是当初不得不加 Props::each_handler 的原因。

    现在:DOM 后端在监听器边界处把 @dom.Event 强转进来(dom_event), React 后端直接把合成事件透传。语义上两边都是"一个事件值", 谁也不需要认识对方的具体类型。

    Props

    Props::attr

    fn Props::attr(self : Props, key : String, value : String) -> Props

    设置字符串属性(placeholder / value 这类)。

    Props::attrs_map

    fn Props::attrs_map(self : Props) -> PropsTable[String]

    字符串属性表(只读)。

    Props::copy

    fn Props::copy(self : Props) -> Props

    独立副本 —— 语义与上游逐条重插等价,但不逐条重插: 四张表的不可变数据(HAMT 根)共享,四张表的句柄各自新建 (PropsTable::share)⇒ O(1)、与元素数无关,而"副本与原件互不可见"这条语义是真的。

    ⚠️ 这里修正过一个真 bug:第一版把四张表原样共享(连句柄也共享)—— 因为 PropsTable 是可变的(set 原地改 entries),于是往副本里写会漏回原件, 上游的回归测试 attrs_copy_test.mbt 直接红(详见 PropsTable::share 的注释)。

    上游这一处曾用 copy_map 逐条重插(四张表 × 每元素每帧)—— 那是被替换掉的那笔开销。

    Props::each_handler

    fn Props::each_handler(self : Props, scheduler : &
    Scheduler
    , f : (String, (Event) -> Unit) -> Unit) -> Unit

    Props::empty

    fn Props::empty() -> Props

    空属性集。

    Props::has_handlers

    fn Props::has_handlers(self : Props) -> Bool

    有没有 handler(O(1))—— 给热路径用来"没 handler 就别建迭代器"。

    为什么值这一行:each_handler 里的 for event, handler in self.handlers 每次都要 先构造一个 HAMT 迭代器;而绝大多数元素根本没有 handler (基准负载:7007 个元素里只有 1001 个有)。含时归因里 each_handler 占 4.9%(PERF.md §14.5)。

    Props::on

    fn Props::on(self : Props, event : String, f : (Event, &
    Scheduler
    ) -> Unit) -> Props

    注册事件处理器。Event 由签名提供,调用方不必命名它。

    Props::prop

    fn Props::prop(self : Props, key : String, value :
    Variant
    ) -> Props

    设置非字符串属性(bool / int / float)。

    Props::props_map

    非字符串属性表(只读)。

    Props::styles

    合并一份类型化样式。

    Props::styles_map

    fn Props::styles_map(self : Props) -> Styles

    类型化样式表(只读)。

    PropsTable

    pub struct PropsTable[V] {
    entries :
    HashMap
    [String, V]
    nonempty : Bool
    }

    ★ moobile 改造(D 轨道性能,见 docs/PERF.md §8):属性表 = 字符串键的不可变表。

    为什么必须有这个包装:Props 的四张表在每个元素每帧都会被 Props::copy() 整份复制一遍 —— 因为元素构造器要一份"私有草稿纸"把自己那些显式参数 (class / style / on_click …)写进去(html.mbt 里 resolve_attrs 之后那串 push_*)。用可变 Map 时"复制"只能逐条重插 ⇒ 实测 0.64 µs/元素, 占每帧翻译成本的 28%(N=5000)~36%(N=1000)(消融探针实测,PERF.md §7.3)。

    换成不可变表之后,Props::copy() 退化成指针拷贝:O(1)、零分配, 而**"每个元素拿到独立副本"这条语义一点没变** —— 不可变结构的共享是安全的, "改副本"等于用一张新表替换掉那一格,原件不受影响。

    ⚠️ 为什么做成包装类型、而不是把四处字段直接换成 HashMap: 这样 attrs[k] = v / .get(k) / .contains(k) / for k, v in attrs 这些写法 在上游那 ~200 处调用点一个字都不用改(#alias("_[_]=_") 接住索引赋值)。 换字段类型的代价是编译器强制的(漏一处就编译不过),比手工改写入点安全得多。

    PropsTable::contains

    fn[V] PropsTable::contains(self : PropsTable[V], key : String) -> Bool

    PropsTable::get

    fn[V] PropsTable::get(self : PropsTable[V], key : String) -> V?

    PropsTable::is_empty

    fn[V] PropsTable::is_empty(self : PropsTable[V]) -> Bool

    O(1)(见 nonempty 字段的注释)。

    PropsTable::iter2

    fn[V] PropsTable::iter2(self : PropsTable[V]) -> Iter2[String, V]

    PropsTable::length

    fn[V] PropsTable::length(self : PropsTable[V]) -> Int

    ⚠️ 仍然是 O(N)(core 的不可变表没有 O(1) 的 size)—— 别把它放进"每元素每帧"的路径。 要问"空不空"请用 is_empty()。

    PropsTable::new

    fn[V] PropsTable::new() -> PropsTable[V]

    空表。

    PropsTable::remove

    fn[V] PropsTable::remove(self : PropsTable[V], key : String) -> Unit

    PropsTable::set

    #alias("_[_]=_")
    fn[V] PropsTable::set(self : PropsTable[V], key : String, value : V) -> Unit

    索引赋值 —— 接住上游 self.0.attrs["name"] = value 那种写法(约 200 处)。

    PropsTable::share

    fn[V] PropsTable::share(self : PropsTable[V]) -> PropsTable[V]

    独立句柄 —— 一个新的 PropsTable 对象,共享同一份不可变数据。

    ★ 为什么必须有它(2026-10-03 修的真 bug):PropsTable 是可变对象 (set 原地改写 entries 那一格),而 P1 的第一版 Props::copy() 把那四张表原样共享 —— 于是"复制"出来的 Props 与原件是同一批表对象,往副本里写会漏回原件。 这不是理论风险:它正是 docs/PERF.md §0 警告过的那类静默污染 (同一个 Attrs 传给第二个元素,第二个元素就把自己的属性写进了第一个)。

    上游自带的回归测试 vendor/rabbita/html/attrs_copy_test.mbt ("Attrs::copy isolates later mutations")抓到了它 —— ⚠️ 而它没在门里跑(tools/verify_all.sh 不跑 moon test),所以一直没被发现。

    正确做法:只共享不可变数据(HAMT 根),不共享那个可写的格子 —— 复制仍然是 O(1)、不逐条重插(这是 P1 的收益),但副本与原件从此互不可见。

    Styles

    pub struct Styles {
    entries : Array[(String,
    StyleValue
    )]
    }

    样式表:扁平数组(moobile 改造,2026-10-03)。

    为什么不再用 PropsTable(不可变 HAMT)

    这条路每个元素每帧要插 4 条样式(N=1000 时 4005 次/帧),而 HAMT 的 add 每次都要 哈希 + 路径拷贝 + 分配。消融探针(把"插入"做 3 遍、输出逐项不变)实测 min +61.4%(区间不重叠) ⇒ 反推现存那一遍约 1.8 ms/帧 ≈ 翻译层的 30%(PERF.md §12.3)。 ⚠️ 同一处的 profile 份额只报 3.2% —— profile 估大小会低一个数量级。

    换成数组之后:写入 = 一次数组拷贝(没有哈希),读取/迭代 = 顺序扫,is_empty = O(1)。 表的规模在这里天然很小(一条 Style 几条到十几条),所以线性扫描比哈希更快。

    三条不变式(Props::copy 的 O(1) 与"副本独立"全靠它们)

    1. entries 指向的那个数组永不原地修改 —— 所有"改"都换成新数组。 所以两个句柄可以安全共享同一个数组。 ⚠️ 这条是约定,不是类型保证:曾试过用 ArrayView 把它变成类型保证, 实测更慢 8~15%(§14.2)⇒ 留在约定这一侧,靠"不留可变别名的访问器"+ 测试守着。
    2. 句柄本身是可变的(mut 字段 ⇒ Styles 是引用类型), 这样 Props::styles() 才能像原来那样"就地"把新样式写进这一格; 而 Props::copy() 走 share() 给新句柄 ⇒ 副本怎么改都碰不到原件。 (⚠️ 这正是 PropsTable 第一版栽过的地方:句柄共享 + 原地改 = 串味。)
    3. 存进来的数组是从调用方复制来的:@style.Style 的构造器是可变的, 调用方之后继续用那份 Style,不能影响这里已经存下的内容。

    Styles::apply

    fn Styles::apply(self : Styles, s :
    Style
    ) -> Unit

    就地合并一份样式(后来的赢,与原来的 HAMT 覆盖语义一致)。

    只改本句柄那一格;那个数组本身不动(不变式 1)。

    Styles::at

    fn Styles::at(self : Styles, i : Int) -> (String,
    StyleValue
    )

    热路径用的按序号取(styles_to_js 每帧要遍历 4005 条)。

    为什么不直接用 for k, v in styles(iter2):那一条走的是闭包迭代器 —— 每个元素都要一次间接调用 + 一个 Option[(K,V)]。含时归因(PERF.md §14.5)里 Iter2::next 在 styles_to_js 下就占 1.3~2.4%,而这条按序号循环是纯索引。 (仍然只读:at 返回的是值,拿不到那个数组。)

    Styles::contains

    fn Styles::contains(self : Styles, key : String) -> Bool

    Styles::empty

    fn Styles::empty() -> Styles

    空样式表。

    Styles::from_style

    从应用给的 Style 复制一份(不变式 3)。

    Styles::get

    线性扫描:表很小(≤ 十几条),比哈希更快。

    Styles::is_empty

    fn Styles::is_empty(self : Styles) -> Bool

    O(1)(数组长度)。

    Styles::iter2

    Styles::length

    fn Styles::length(self : Styles) -> Int

    Styles::share

    fn Styles::share(self : Styles) -> Styles

    新句柄、同一个数组 —— Props::copy() 用它。

    之所以安全:写入一律换新数组(不变式 1),所以两个句柄永远不会互相看见对方的改动。

    VDom

    type VDom

    VDom::initialize

    fn VDom::initialize(vnode : VNode, scheduler : &
    Scheduler
    , target_element_id? : String) -> VDom

    VDom::initialize_with_hydration

    fn VDom::initialize_with_hydration(vnode : VNode, scheduler : &
    Scheduler
    ) -> VDom

    VDom::to_string

    fn VDom::to_string(self : VDom) -> String

    VDom::update

    fn VDom::update(self : VDom, root : VNode, scheduler : &
    Scheduler
    ) -> Unit

    VNode

    pub enum VNode {
    Elem(String, Props, Children[VNode], namespace_uri~ : String?)
    Text(String)
    Frag(Array[VNode])
    Thunk(Int, () -> VNode)
    }

    impl Eq for VNode

    VNode::document

    fn VNode::document(props : Props, head : VNode, body : VNode) -> VNode

    VNode::elem

    fn VNode::elem(tag : String, props : Props, children : Children[VNode], namespace_uri? : String) -> VNode

    VNode::equal

    fn VNode::equal(a : VNode, b : VNode) -> Bool

    VNode::fragment

    fn VNode::fragment(childs : Array[VNode]) -> VNode

    fn VNode::link(props : Props, children : Children[VNode], escape? : Bool) -> VNode

    VNode::not_equal

    fn VNode::not_equal(x : VNode, y : VNode) -> Bool

    VNode::text

    fn VNode::text(s : String) -> VNode

    VNode::thunk

    fn VNode::thunk(hash : Int, f : () -> VNode) -> VNode

    as_dom_event

    Event → @dom.Event。DOM 侧的辅助函数(从事件里取值)使用。

    server_side_render

    fn server_side_render(render : () -> VNode) -> String