moobile:MoonBit 写 UI,交给 React Native 渲染 —— 跨端 UI 层(内含 rabbita vendor fork)
用 MoonBit 写一次 UI,交给 React / React Native 渲染 —— Web 与 Android 一套代码。
✅ 已发布: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。
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", # 版本号在工作区解析时被忽略
}// 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(一行)。
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 });npm install expo-sqlite # 加能力 = 装那个包
npx moobile-host regen # 从 package.json 生成 registry.generated.js| 能力 | 提供者 | MoonBit 入口 | 两端情况 |
|---|---|---|---|
| db 本地数据库 | expo-sqlite | XiLaiTL/moobile/sqlite | Android:预编译 AAR,gradlew assembleDebug 即可;Web:官方标 alpha,只用异步 API(实测异步不需要 crossOriginIsolated) |
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`| 项 | 现状 |
|---|---|
| 标签 | 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 没有这两者的后端) |
你的 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 + 我们 |
| 路径 | 是什么 |
|---|---|
| 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 的测量产出与原始数据 |
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 # 行尾检查| 项 | 做法 |
|---|---|
| 版本 | _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 |
| 文档 | 内容 |
|---|---|
| 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 | 设计期可行性证据:实测数据、复现方式、已否决方案的论证链 |
pub fn app() -> @moobile.JsValue { @moobile.handlers(model=initial(), update~, view~) }let host_contract_version : IntInstall
Download zipmoobile:MoonBit 写 UI,交给 React Native 渲染 —— 跨端 UI 层(内含 rabbita vendor fork)