Sign in

    moobile

    moobile:MoonBit 写 UI,交给 React Native 渲染 —— 跨端 UI 层(内含 rabbita vendor fork)

    moonbit
    moobile
    react-native
    rabbita
    Download zip
    Author
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    last month
    Downloads
    50

    #moobile

    用 MoonBit 写一次 UI,交给 React / React Native 渲染 —— Web 与 Android 一套代码。

    moobile 是给 rabbita(MoonBit 的 TEA 声明式 UI 框架) 换一个渲染后端:保留它的 Model / Msg / update / view 与 @html DSL, 把最后一跳从"操作 DOM"换成"产出 React 元素"。于是同一份 MoonBit 代码, 经 React Native 上安卓/iOS,经 react-native-web 上浏览器。

    状态:一个真应用已经跑起来了 —— 前后端俱全的 Todo(本地库 + 网络同步 + 多页面), 四道门全绿:Web UI 26/26、本地库 8/8(含刷新后仍在)、同步链路 14/14、 真机(Android 14 / x86_64)13/13。宿主侧收成了一个 npm 包,应用入口只剩一行。 interest/yi《御纂周易折中》阅读器的完整移植仍未开始 —— 计划见 PLAN.md。 实测事实在 docs/FINDINGS.md(R2 那一节是这一轮的)。

    ✅ 已发布:moon add XiLaiTL/moobile@0.2.0(0.1.0 是 2026-09 首发,仓库 https://github.com/XiLaiTL/moobile)。 也可以按 §1.1 的本地工作区方式用它(开发本库时更方便)。 宿主侧:npm install moobile-host(§1.3)—— 0.1.0 是旧接口,下面的写法对应 0.2.x。

    怎么读这份文档:使用者看 §1 → §2;想理解为什么这么设计看 §3;要改这个库看 §4。


    #1. 快速上手

    #1.1 把库加进来

    今天(未发布):把它当本地依赖,用 moon.work 工作区(这也是 _tools/ext_probe/ 那个"外部模块冒烟测试"用的办法,可以直接抄它):

    mkdir myapp && cd myapp cat > moon.work <<'EOF' members = [ "D:/ai_project/interest/moobile", # ← 本库所在的路径 "app", ] EOF

    # app/moon.mod name = "you/myapp" version = "0.1.0" preferred_target = "js" import { "XiLaiTL/moobile@0.2.0", # 版本号在工作区解析时被忽略 }

    发布之后:moon add XiLaiTL/moobile@0.2.0,其余一样。

    #1.2 MoonBit 侧:写视图 + 一行入口

    // app/moon.pkg
    import {
    "XiLaiTL/moobile" @moobile, // ← 库本体就是模块根包
    "XiLaiTL/moobile/style",
    "XiLaiTL/moobile/html",
    "XiLaiTL/moobile/cmd",
    }

    options(
    link: {
    "js": {
    "format": "esm",
    // ★ 只有一个导出名(0.1.0 是四个)
    "exports": [ "app" ],
    },
    },
    )

    fn view(m : Model, emit : @cmd.Emit[Msg]) -> @html.Html {
    @html.div(
    attrs=@html.Attrs::build().styles(
    @style.Style::new().font_size(16.0).padding_horizontal(@style.px(12.0)),
    ),
    [ @html.button(on_click=emit(Bump), ["+1"]) ],
    )
    }

    /// 唯一入口:交出整张句柄表(start / snapshot / subscribe / element + 诊断)。
    /// 0.2 才有 `handlers_with_init`(首帧之前先读本地库);纯 `Model` 起步用 `handlers`。
    pub fn app() -> @moobile.JsValue {
    @moobile.handlers_with_init(init=init_app, update~, view~)
    }

    为什么库能替应用做这件事(0.1.0 时做不到,所以那时要应用自己写四件套): 导出的函数必须是单态的,而 mount 对 Model/Msg 泛型 —— 但 Mount 本身是非泛型的具体类型, 泛型只存在于构造那一刻。于是库能把四个入口包成一张表交出去。 完整形状见 demo/main.mbt(一行)。

    #1.3 JS 侧:宿主是 npm 包 moobile-host

    MOBILE_HOST 契约(React、组件表、调度钩子、后端地址)、契约版本比对、 能力注册表的安装与核对,全部在包里 —— 应用侧只剩:

    import { mountApp } from 'moobile-host'; import { app } from './myapp.js'; // MoonBit 编译产物 import { registry } from './registry.generated.js'; // npx moobile-host regen 生成 export default mountApp(app, { registry });

    没有能力依赖时 mountApp(app) 就够了。参考实现就是本仓库的 host/App.js(12 行,其中 8 行是注释)。

    契约版本:库在句柄表里带 contract(整数),宿主有 CONTRACT,不等就当场报错并说出两个版本号 —— 而不是让你对着某个"莫名其妙是 undefined"的函数猜。包与库的版本对应见该包 README 的兼容表。

    #1.3b 能力(本地库、以后的相机等)

    "能力"就是宿主注册到 MOBILE_HOST.<名字> 上的对象,MoonBit 侧用 extern "js" 取。 清单不手写:

    npm install expo-sqlite # 加能力 = 装那个包 npx moobile-host regen # 从 package.json 生成 registry.generated.js

    生成物是静态 import(Metro 不能靠运行时拼字符串 import),注释里会列出 "识别了什么 / 哪些没装";启动时还会再核对一遍:声明了 db 但装完是空的 → 报错点名能力与提供它的包。MoonBit 侧同样 fail-fast(见 sqlite/sqlite.mbt 的 ensure())。

    已提供的能力:

    能力提供者MoonBit 入口两端情况
    db 本地数据库expo-sqliteXiLaiTL/moobile/sqliteAndroid:预编译 AAR,gradlew assembleDebug 即可;Web:官方标 alpha,只用异步 API(实测异步不需要 crossOriginIsolated)

    #1.4 跑起来

    moon build --target js cp _build/js/debug/build/app/app.js <你的 Expo 工程>/myapp.js cd <你的 Expo 工程> && npx expo start --port 8081 # 浏览器 http://localhost:8081 ;模拟器/真机先 `adb reverse tcp:8081 tcp:8081`

    本仓库的 build.sh 做的就是上面这两步(demo 的产物 → host/moobile.js)。 环境、真机流程、排错、禁区 → DEV.md。


    #2. 能力边界(上手前先看这张表)

    项现状
    标签42 条有映射(render.mbt 的 tag_table());12 条明确排除并写明理由(excluded_tags()):img video audio canvas svg table iframe select details summary dialog marquee
    未收录标签兜底成 View(不崩),但会计数 —— unmapped_tag_count(),验证脚本断言为 0
    不可移植节点Children::RawHtml 会被跳过并计数(unsupported_count())
    事件载荷Mouse / Keyboard / Scroll 在 React 后端一律是零值:能写、不崩、拿不到坐标。真实手势要接 RN 手势通道(见 PLAN.md 的 D/E 轨道与设计文档 R2)
    target只支持 js(moon check --target native 会失败,见 docs/ARCHITECTURE.md §4.4-3)
    样式类型化 StyleValue;class= 与 style="…" 在 RN 上不生效(前者 RN 无类名,后者会被类型化样式覆盖)—— 这两件不报错,只是没效果
    宿主组件必须提供 View Text Pressable TextInput ScrollView 五个;缺哪个,对应标签就变 undefined(moobile-host 已内置)
    副作用 / 订阅update 返回 Cmd、subscriptions? 都通(0.2 起);持续型能力(传感器等)走 @sub.custom_sub,尚未在真机上验证(PLAN 的 N4)
    原生能力生态有现成 RN/Expo 包 → 纯 MoonBit 绑定即可(不写 Kotlin);自研原生模块仍需 Kotlin/Swift + shim(MoonBit 没有这两者的后端)

    这些边界是可测量的:node _verify.js 把"未收录标签 = 0""不可移植节点 = 0""布局无溢出"都做成了断言。


    #3. 原理

    你的 MoonBit 应用 Model / Msg / update / view ← 与 rabbita 完全同款的 TEA 写法 │ │ 视图用 @html DSL 写;样式用 @style(类型化,不是 CSS 字符串) ▼ ┌────────────────────────────────────────┐ │ VNode 树 + Cmd / Sub 运行时 │ vendor 的 rabbita │ (事件派发、订阅、异步 effect 都在这跑)│ (fork:15 个 patch,见 FORK.md) └────────────────────────────────────────┘ │ │ moobile/render.mbt —— 翻译层,只做三件事: │ ① 标签表:42 条 HTML 标签 → RN 组件名(div→View、span→Text…) │ ② 事件映射:click→onPress、input→onChangeText…(要结合目标标签判断) │ ③ 样式 map → RN 的 style 对象 ▼ ┌────────────────────────────────────────┐ │ React 元素 │ ← React.createElement(...) 的产物, │ (不是 DOM 节点,也不是 HTML 字符串) │ 直接交给 React 渲染 └────────────────────────────────────────┘ │ 宿主 host/App.js(约 30 行)把它交给 React ├──────────────► React Native ──────► Android / iOS 原生视图 └──────────────► react-native-web ──► 浏览器 DOM

    #谁负责什么

    层在哪归属
    宿主(JS)host/ —— Expo + React + RN + react-native-web应用侧
    FFI 边界模块根 host.mbt —— 全套代码里 %identity 不安全性唯一的集中地我们
    翻译层模块根 render.mbt —— 标签表 42 条 + 排除表 12 条 + 样式/事件映射我们
    TEA 挂载模块根 app.mbt —— mount(model, update, view) -> Mount我们
    vendor 运行时internal/runtime/react_host.mbt —— 把最后一跳换成"产出 React 元素"我们(fork 新增)
    树 / DSL / 样式internal/vdom/、html/、cmd/、sub/、dom/(vendor)+ 公开包 style/vendor + 我们

    #四条设计原则(决定了上面这张图的样子)

    1. 不实现渲染。 布局、排版、文本引擎全部交给 React / RN。 凡是 RN 已有的能力,moobile 只用不造(设计文档 §10 原则 1)。
    2. 只换挂载层。 Cmd / Emit / 订阅 / 异步 effect 仍由 rabbita 自己的运行时执行 —— 所以 on_click=emit(Msg) 这种照抄 yi 的写法可以直接跑。
    3. 样式必须走类型化通道。 RN 没有 CSS 类、也不吃 CSS 字符串, 所以样式写成 Attrs::styles(@style.Style::new().font_size(16.0)); "长度必须带单位、无单位量必须是数字"这类规则由类型保证。
    4. 事件解码是策略,不是硬编码。 DOM 事件在 RN 上拿不到, 于是把 13 处解码点做成可替换的查表:换掉一张表,on_input 这类处理器就换了语义 —— 换来的是"不崩、可降级",不是"载荷等价"。


    #4. 开发者相关

    #4.1 仓库结构

    我们自己的(git 跟踪):

    路径是什么
    moon.pkg + host.mbt render.mbt app.mbt store.mbt schedule.mbt库本体(模块根包)
    style/公开包:类型化样式层(使用者写视图的入口)
    demo/演示应用(待办 + R1 排版样本)
    host/Expo 宿主:App.js + 一个根组件
    _tools/环境脚本 + vendor_sync.sh / patches/(见 4.3)
    _verify.js _r1.js验证与测量脚本
    _r1/R1 的测量产出与原始数据

    第三方(rabbita fork)不进仓:html/ cmd/ dom/ js/ internal/ sub/ svg/ … 共 16 个目录 是生成物。

    #4.2 开发与验证

    全新 clone 的第一件事(第三方代码不在仓里,不跑这步 moon check 会报找不到包):

    bash _tools/vendor_sync.sh --apply # 拉 rabbita@0.15.4 → 铺开 → 打 patch → 统一行尾

    日常:

    moon check --target js # 类型检查 ./build.sh # 产物 → host/moobile.js cd host && npx expo start # Web 预览 http://localhost:8081(或手机扫码) node _verify.js # Web 端到端 26 项断言(需 Metro 在 8081) bash _tools/check_external.sh # 外部模块可用性:别的模块能不能依赖这个库 bash _tools/vendor_sync.sh --check # 断言「工作区 == 上游 + patch」(提交前/CI) bash _tools/lf_normalize.sh --check # 行尾检查

    #4.3 第三方代码怎么来的(生成式 vendor)

    项做法
    版本_tools/vendor.lock 的 RABBITA_VERSION=0.15.4(注册表制品,不可变)
    改动_tools/patches/*.patch(15 个,共约 983 行)
    生成_tools/vendor_sync.sh --apply(--capture 回写、--from <版本> 试升级)
    为什么必须铺在模块根MoonBit 的 internal 可见性按包路径前缀判 —— 放 vendor/ 子目录会让 internal/vdom 对模块根包不可见
    ⚠️ 改了要回写第三方目录是 gitignore 的,git status 不会提醒你漏了 --capture

    逐条说明、上游版本现状与升级演练 → FORK.md。

    #4.4 许可证与第三方

    Apache License 2.0(见 LICENSE)。

    本模块内含 rabbita 的 fork(同为 Apache-2.0, 来源版本 0.15.4,我们做了 15 处修改)—— 版权声明、修改声明与分发形态见 THIRD-PARTY-NOTICE.md,改动明细见 FORK.md。


    #5. 文档导航

    文档内容
    PLAN.md当前计划:定位与远景(多端脚手架、迁移工具链)、六条轨道、决策点与里程碑
    docs/PLAN-2026Q3-yi-port.md(归档)以"移植 yi 阅读器"为终点的旧计划;旧任务编号(T0.x–T7.x)以它为准
    DEV.md环境与运行手册:工具链、磁盘布局、Web/安卓怎么起、排错表、禁区
    FORK.md对 rabbita 改了什么:15 个 patch 逐条说明 + 跟版/升级流程 + 上游化提案
    docs/ARCHITECTURE.md架构总览 + 发布成库的可行性:分层与契约、依赖闭包、发布体检
    docs/FINDINGS.md实现侧实测结论:R1 判决、样式差集、DOM 缝隙普查、逐条发现(原 README)
    docs/DESIGN.md设计文档(主):定位、架构、路线图、未决问题
    docs/EVIDENCE.md设计期可行性证据:实测数据、复现方式、已否决方案的论证链

    Frame

    pub struct Frame {
    element : JsValue
    store : Store
    }

    一帧的产物。

    pub 只是为了让 Mount 这个公开类型能持有它 —— 它是一个引用类型, 于是 on_frame 的闭包可以写进去,而 Mount 之后再读出来。

    JsValue

    #external
    pub type JsValue

    JS 侧的不透明值。整套后端只经由这一层与宿主对话。

    Mount

    一个已挂载的应用。

    关键点:Cmd / Emit / 订阅 / 异步 effect 全部由 rabbita 自己的运行时执行 (@runtime.ReactHost 是 fork 出来的 React 后端), moobile 只负责最后一跳:VNode → React 元素。

    于是 on_click=emit(Msg) 这种原样照抄 yi 的写法可以直接跑 —— 这正是"渲染可以整体外包"的含义:连抽干循环都留着,只换挂载层。

    Mount::element

    fn Mount::element(self : Mount) -> JsValue

    当前帧的 React 元素。还没画过时是 JS null(React 能直接渲染)。

    Mount::handles

    fn Mount::handles(self : Mount) -> JsValue

    把一次挂载打包成一张句柄表交给宿主 —— 这是 H2「单导出」的实现(PLAN §3.7)。

    为什么库能替应用做这件事:Mount 是非泛型的具体类型, "必须有泛型"的只是构造它的那一刻;构造完就退化成 {contract, start, snapshot, subscribe, element} 这张表。 于是应用侧的导出名从 4 个降到 1 个,宿主也不必知道 Model / Msg 是什么。

    Mount::snapshot

    fn Mount::snapshot(self : Mount) -> Int

    给 useSyncExternalStore 的快照。

    Mount::start

    fn Mount::start(self : Mount) -> Unit

    同步抽干一次并画出首帧。调用后 element() 立刻有内容。

    Mount::subscribe

    fn Mount::subscribe(self : Mount, f : (Int) -> Unit) -> (() -> Unit)

    给 useSyncExternalStore 的订阅。返回退订闭包。

    Store

    pub struct Store {
    version : Int
    next_id : Int
    subscribers : Map[Int, (Int) -> Unit]
    }

    极简版 Val:一个版本号 + 订阅者表。

    对齐设计文档 §4.2 —— React 的 useSyncExternalStore 要的就是 "订阅 + 一个不可变快照"。这里快照就是版本号:变了就整棵树重建, 交给 React 去 diff(§4.3 的取舍:本类应用更新频率低,全树 diff 不是问题)。

    Store::bump

    fn Store::bump(self : Store) -> Unit

    版本号 +1 并通知所有订阅者。

    通知前先拷贝一份订阅者列表:回调里可能发生退订,直接遍历会踩到迭代中修改。

    Store::new

    fn Store::new() -> Store

    Store::snapshot

    fn Store::snapshot(self : Store) -> Int

    Store::subscribe

    fn Store::subscribe(self : Store, f : (Int) -> Unit) -> (() -> Unit)

    订阅,并返回取消订阅的闭包 —— React 需要拿到它。

    excluded_tags

    fn excluded_tags() -> Array[(String, String)]

    刻意不收的标签 + 理由。

    这张表的存在意义:不把"其实不能跑"的东西默默映射成 View 让它看起来能跑。 想用这些能力,得走 RN 生态的对应组件,而不是假装标签表能覆盖。

    handler_count

    fn handler_count() -> Int

    handlers

    fn[Model : Eq, Msg] handlers(model~ : Model, update~ : (Model, Msg,
    Emit
    [Msg]) -> (Model,
    Cmd
    ), view~ : (Model,
    Emit
    [Msg]) ->
    Html
    , subscriptions? : (Model,
    Emit
    [Msg]) ->
    Sub
    ) -> JsValue

    挂载并直接交出句柄表(应用侧的推荐入口)。

    应用只需要一个导出:
    pub fn app() -> @moobile.JsValue { @moobile.handlers(model=initial(), update~, view~) }

    handlers_with_init

    fn[Model : Eq, Msg] handlers_with_init(init~ : (
    Emit
    [Msg]) -> (Model,
    Cmd
    ), update~ : (Model, Msg,
    Emit
    [Msg]) -> (Model,
    Cmd
    ), view~ : (Model,
    Emit
    [Msg]) ->
    Html
    , subscriptions? : (Model,
    Emit
    [Msg]) ->
    Sub
    ) -> JsValue

    带初始命令的单导出入口(清单类应用多半要这个:首帧之前先读本地库)。

    host_contract_version

    let host_contract_version : Int

    宿主契约版本。

    宿主(npm 包 moobile-host)与库各自声明自己实现的契约版本,宿主在启动时比对, 不等就直接抛错并同时报出两个版本号 —— 否则"npm 包与 mooncakes 包两条版本线各走各的" 会表现成"某个函数莫名其妙是 undefined",极难查。改 MOBILE_HOST 的形状时必须 +1。

    js_null

    fn js_null() -> JsValue

    真正的 JS null。用它在"还没有内容"时占位 —— 比 Option[JsValue] 更可靠,因为 React 能直接渲染 null。

    map_tag

    fn map_tag(tag : String) -> String

    查表。没有默认兜底成 View —— 未收录会被计数并回报。

    mount

    fn[Model : Eq, Msg] mount(model~ : Model, update~ : (Model, Msg,
    Emit
    [Msg]) -> (Model,
    Cmd
    ), view~ : (Model,
    Emit
    [Msg]) ->
    Html
    , subscriptions? : (Model,
    Emit
    [Msg]) ->
    Sub
    ) -> Mount

    挂载(无初始命令)。

    签名与上游 rabbita.elmish 对齐(见 internal/rabbita/top.mbt):
    • update 返回 (Model, Cmd) —— "改状态顺便干件事"(读本地库、发请求)可以直接写在 update 里;
    • subscriptions? 透传给运行时 —— 持续数据流(传感器、网络状态、返回键)能挂上来。

    这两条在 0.1.0 里是缺的(update 只能返回 Model,且没有 subscriptions), 0.2.0 补上。缺口的代价很具体:update 里发起不了副作用、持续型原生能力一个都接不上 —— 见 PLAN.md §3.6(N1)。

    mount_with_init

    fn[Model : Eq, Msg] mount_with_init(init~ : (
    Emit
    [Msg]) -> (Model,
    Cmd
    ), update~ : (Model, Msg,
    Emit
    [Msg]) -> (Model,
    Cmd
    ), view~ : (Model,
    Emit
    [Msg]) ->
    Html
    , subscriptions? : (Model,
    Emit
    [Msg]) ->
    Sub
    ) -> Mount

    挂载(初始就带命令)。

    对齐上游 create_state_with_init:首帧之前要做的事(例如"从本地数据库读上一次的清单") 写在这里,而不是塞进 view,也不是塞进模块初始化(那时宿主还没装好 MOBILE_HOST)。

    render_html

    真实 @html.Html → 一个 React 元素。

    render_node

    翻译层的核心:一个 4 分支递归 —— 对真实的 rabbita VNode。

    • Elem → 查标签表;Children 有 Array / Map / RawHtml 三个构造器,都要管
    • Text → 包一层 Text(RN 里裸字符串不能当 View 的子节点)
    • Frag → React.Fragment
    • Thunk → 强制求值后继续

    注意这里没有 diff、没有布局、没有文字排版 —— 那些都是 React / RN 的活。

    tag_table

    fn tag_table() -> Array[(String, String)]

    可移植标签表 —— HTML 标签 → 宿主组件名。

    这就是 DESIGN §4.1 里的 tags/ 层。rabbita/html 有 116 个标签, 其中不少在 RN 上没有对应物,所以 moobile 自己维护这张表。

    收不收的判据只有一条:两端都有等价物。

    unmapped_tag_count

    fn unmapped_tag_count() -> Int

    unmapped_tag_names

    fn unmapped_tag_names() -> String

    unsupported_count

    fn unsupported_count() -> Int