# moon-miniprogram 使用指南

面向「想用 MoonBit 写微信小程序」的开发者。读完这本指南，你能从零开始
做出一个真机可跑的小程序，并且学会把业务写成可无头测试的纯 MoonBit。

> 项目主页：[README](../README.mbt.md) ｜ 包：`Magic486/moon-miniprogram`
> 版本要求：MoonBit（moon.mod 使用 `preferred_target = "js"`）+ Node.js ≥ 20

## 目录

1. [从零到真机](#1-从零到真机)
2. [运行时模型：事件进 · 状态出](#2-运行时模型事件进--状态出)
3. [页面与事件](#3-页面与事件)
4. [状态管理](#4-状态管理)
5. [跨页状态 store](#5-跨页状态-store)
6. [自定义组件](#6-自定义组件)
7. [路由与导航](#7-路由与导航)
8. [wx API 参考](#8-wx-api-参考)
9. [无头测试](#9-无头测试)
10. [构建与发布](#10-构建与发布)
11. [常见坑与 FAQ](#11-常见坑与-faq)

---

## 1. 从零到真机

```bash
# 1) 脚手架生成项目（自动从 mooncakes 拉取框架）
node scripts/new.cjs myshop
cd myshop

# 2) 开发闭环（不需要微信工具）
node mmp.cjs check    # 类型检查
node mmp.cjs test     # 业务单测
node mmp.cjs dev      # watch：engine/*.mbt 一改就自动重编译并装配

# 3) 真机/模拟器
#    打开 微信开发者工具 → 导入项目 → 目录选 myshop/miniprogram/
#    AppID 用测试号（touristappid）即可
```

项目结构（脚手架生成）：

```text
myshop/
├── engine/            # 业务包：写 PageDef / 组件 / 纯逻辑
├── engine-export/     # CJS 导出包装（不要动，只做转发）
├── miniprogram/       # 微信开发者工具打开的壳：app.js/pages 只有一行装配
├── mmp.cjs            # 一键 CLI（自动复制）
├── moon.mod / moon.pkg
└── README.mbt.md
```

**规则**：业务逻辑只写在 `engine/`，永远不在 `miniprogram/` 的 js 里写业务。

---

## 2. 运行时模型：事件进 · 状态出

微信小程序 = 界面（WXML）+ 逻辑（JS）。本框架接管逻辑侧，把它翻译成 MoonBit：

```text
用户在界面点按钮
   │  WXML bind:tap="onTap"
   ▼
微信运行时调用页面配置里的 onTap（框架预埋的“翻译壳”）
   │  框架把事件对象包成 Payload
   ▼
你的 MoonBit 处理器  (ctx: PageCtx, payload: Payload) -> Unit
   │  你用 ctx.set_state(...) / store.set(...) 更新状态
   ▼
框架对状态自动 diff → 最小 setData 补丁 → 微信刷新界面
```

- **事件进**：WXML 事件名 = `PageDef.handlers` 里的名字，一一对应
- **状态出**：所有界面数据都在微信的 `data` 里；你通过 `PageCtx` 方法更新
- **能力通道**：弹提示/存储/网络等走 wx API 的类型化函数

---

## 3. 页面与事件

一个页面 = 一个 `PageDef`（构造后交给 `register_page`，由 `launch/page` 装配）。

```moonbit
// engine/engine.mbt
let my_page : @mp.PageDef = {
  path: "pages/index/index",          // 必须与 app.json pages、页面文件一致
  data: @mp.jobj([
    ("count", @mp.jnum(0.0)),
    ("label", @mp.jstr("点我")),
  ]),
  handlers: [
    // 生命周期与事件同表：onLoad/onShow/onPullDownRefresh/自定义事件名…
    ("onLoad", (ctx, payload) => {
      // onLoad 的 payload 是页面参数对象
      match payload.string_at(["from"]) {
        Some(v) => ctx.set_state(@mp.jobj([("label", @mp.jstr("来自 \{v}"))]))
        None => ()
      }
    }),
    ("onTap", (_ctx, _payload) => ()),
  ],
  returns: [   // 需要向微信“返回对象”的钩子（onShareAppMessage 等）
    ("onShareAppMessage", (_ctx, _payload) =>
      @mp.share_card("我的小程序", path="pages/index/index")
    ),
  ],
}

///| 应用入口定义
@mp.register_app({
  handlers: [("onLaunch", (_ctx, _payload) => ())],
  global_data: @mp.jobj([("version", @mp.jstr("0.1.0"))]),
})
```

小程序侧：

```javascript
// app.js
require("./engine/moon-engine.js").launch();
// pages/index/index.js
require("../../engine/moon-engine.js").page("pages/index/index");
```

**Payload 访问器**（事件/生命周期参数都是只读的）：

| 方法 | 用途 | 示例 |
|---|---|---|
| `payload.string_at(["detail","value"])` | 取深层字符串 | 输入框内容 |
| `payload.number_at([...])` | 取数值 | 金额 |
| `payload.json()` | 整体转 Json | onLoad 参数对象 |

> 注意：微信事件对象里 `detail.value` 等是常见取值点；
> 不存在时返回 `None`，用 `match` 处理，绝不 panic。

**注册**：重复 `register_page` 同名路径会打印 dev 警告并覆盖
（幂等注册请收敛到 `ensure_registered` 之类的函数里做一次）。

---

## 4. 状态管理

`PageCtx` 提供三种更新方式，**默认推荐 `set_state`**：

| 方法 | 语义 | 适用 |
|---|---|---|
| `ctx.set_data(patch)` | 补丁式透传，等价 `this.setData` | 精确控制、逐键小改 |
| **`ctx.set_state(new_state)`** | 部分合并（React setState）：与你当前 data 自动 diff，**只把变化的路径发给 setData**；无变化时零调用 | 日常首选 |
| `ctx.replace_state(new_state)` | 全量替换，支持删除键 | 一次大改 / 重置页面 |

```moonbit
// data: { "list": [...], "total": 0 }
ctx.set_state({ "total": 42 })
// 实际 setData: { "total": 42 }（只有它变了）

ctx.set_state({ "list": [..原列表, 新项] })
// 实际 setData: { "list[1000]": 新项 }（数组增长按下标级最小补丁）
```

`set_state` 的 diff 是纯函数（`diff_to_paths`/`diff_partial`），有 12 组黄金用例 +
200 轮随机不变式守护。**读当前值**用 `ctx.get_data()`（返回 `Json?`）。

**别做**：拿 `ctx.get_data()` 改完再整包 `set_data` 回去——那会退化成全量传输；
想表达“把整页换成新状态”就用 `replace_state`，它同样走 diff。

---

## 5. 跨页状态 store

多页面共享一个数据源，任意页面更新、所有订阅页面自动收到最小补丁。
对标 React context / mobx，适用于购物车、登录态、主题等跨页状态。

```moonbit
// 引擎初始化处（全局定义一次）
let cart : @mp.Store = @mp.create_store(
  "cart",
  @mp.jobj([
    ("count", @mp.jnum(0.0)),
    ("note", @mp.jstr("")),
  ]),
)

// 订阅方：页面的 onLoad / 组件的 attached
("onLoad", (ctx, _) => ctx.bind_store(cart))
("onUnload", (ctx, _) => ctx.unbind_store(cart))   // 必须配对，避免悬挂引用

// 更新方：任意页面/组件里
("onBuy", (_ctx, _) => {
  let n = /* 读 cart 当前值（或从 ctx.get_data()） */ 
  cart.set(@mp.jobj([("count", @mp.jnum(n))]))   // 一处 set → 全员同步
})
```

语义：

- `bind_store` 会先把 store 快照同步进页面 data（用 diff，页面其它键零打扰）
- `store.set(patch)` 顶层键合并到 store，再对每个订阅页面做最小化 diff 推送；
  没有变化的页面不产生任何 setData
- 页面 data 不要与 store 键同名（store 键归 store 管）
- `bind` 幂等；调试用 `store.snapshot()` / `subscriber_count()` / `label()`

> 为什么不用 wx 的 storage？storage 走磁盘序列化，也没有“订阅-推送”，
> 做不了响应式同步。store 是内存态 + diff 推送，页面切后台也能即时收到。

---

## 6. 自定义组件

组件与页面同构：定义 `ComponentDef` → 注册 → 页面 WXML 里
`<my-tag text="..." bind:pick="onPick" />`。

```moonbit
///| 组件：文本标签，点击把 text 以 pick 事件送出
fn tag_def() -> @mp.ComponentDef {
  {
    key: "components/tag/tag",   // 必须与 usingComponents 路径一致
    properties: @mp.jobj([
      // "t" = 类型名，框架在 JS 侧翻译成微信构造函数（String/Number/...）
      ("text", @mp.jobj([("t", @mp.jstr("String")), ("value", @mp.jstr(""))])),
    ]),
    data: @mp.jobj([]),
    handlers: [
      (
        "onTap",
        (ctx, _payload) => {
          match ctx.get_data() {
            Some(Json::Object(m)) =>
              match m.get("text") {
                Some(Json::String(s)) =>
                  ctx.trigger_event("pick", @mp.jobj([("value", @mp.jstr(s))]))
                _ => ()
              }
            _ => ()
          }
        },
      ),
    ],
    observers: [],      // [("text", (ctx, v) => ...)] 监听字段变化
    page_lifetimes: [], // [("show", ...)] 组件所在页面的生命周期
  }
}
```

组件侧 js（一行）与属性接收规则：

```javascript
// components/tag/tag.js
Component(require("../../engine/moon-engine.js").component("components/tag/tag"));
```

**properties 规范**：`{ 名字: { t: 类型名, value: 默认值, optional?: true } }`
——`t` 只接受 `String | Number | Boolean | Object | Array`，由框架翻译为
微信的属性构造函数。**事件出去用 `ctx.trigger_event(name, detail)`**，页面在
WXML 用 `bind:<name>` 接收（`bind:pick` → 页面的 `onPick` handler，
detail 从 `payload.string_at(["detail", ...])` 读）。

**observers**：键支持微信的字段路径语法（`"a.b"`、`"text"`），值变化时回调
`(ctx, value)`，value 是变化后的值。空数组时框架不生成 observers 键。

**一个完整可抄的组件四件套**（以 tag 为例，四个文件都在组件目录）：

```javascript
// components/tag/tag.js —— 一行装配
Component(require("../../engine/moon-engine.js").component("components/tag/tag"));
```

```json
// components/tag/tag.json —— 声明自己是组件
{ "component": true }
```

```xml
<!-- components/tag/tag.wxml —— 界面；文本来自框架注入的 text 属性 -->
<view class="chip" bind:tap="onTap">{{text}}</view>
```

```css
/* components/tag/tag.wxss */
.chip { display: inline-block; padding: 12rpx 28rpx; border-radius: 999rpx; background: #f0f0f0; }
```

```json
// 页面 app.json / 页面 json 里声明后即可用
{ "usingComponents": { "tag": "/components/tag/tag" } }
```

```xml
<!-- 页面 wxml：属性进入 text，点击事件 pick 由页面 onPick 消费 -->
<tag text="报销单" bind:pick="onPick" />
```

> 组件生态可以这样一层层长出来：每个组件 = ComponentDef（MoonBit，可测）+
> 四个模板文件，放进自己的小程序或独立的 mooncakes 组件包。

---

## 7. 路由与导航

用路由表代替手拼 url，参数受白名单约束：

```moonbit
// 集中声明（初始化一次）
@mp.register_route({ path: "pages/index/index", params: [] })   // 不接受参数
@mp.register_route({ path: "pages/about/about", params: ["from"] })

// 导航（页面 handler 里直接调，零风险）
@mp.navigate_to_route("pages/about/about", params={ "from": "index", "x": 1 })
// 实际导航: pages/about/about?from=index
//          ↑ "x" 不在白名单 → 自动剥离 + console.warn

@mp.redirect_to_route("pages/about/about")
@mp.switch_tab_route("pages/index/index")   // tab 页导航，不带参
```

接收端：`onLoad` 的 payload 就是 query 对象，`payload.string_at(["from"])` 取值。

安全设计：路由未注册 / 参数越界都只 `console.warn` + 安全降级，**绝不 raise**
（raise 在 JS 后端会静默丢失，所以凡是要在页面 handler 里调用的框架函数，
一律不做 raise 式失败）。

底层等价物（想手动控制时）：`navigate_to(url)` / `redirect_to` / `switch_tab` /
`navigate_back(delta=1)`。

---

## 8. wx API 参考

> 以下为本包（`runtime`）公开 API 一览。用法细节见 mooncakes 包页的文档
> （docstring）与仓库 `runtime/*.mbt`。`raise` 标注 = 调用方需处理错误。
> 覆盖情况对照微信官方见 [📊 wx API 覆盖对照表](API覆盖对照表.md)。

### 注册与装配

| API | 说明 |
|---|---|
| `register_app(AppDef)` / `app_config()` | App 定义 / 取微信 App 配置 |
| `register_page(PageDef)` / `page(path)` / `page_config(path)` | 页面注册 / 装配 / 取配置（`raise`） |
| `register_component(ComponentDef)` / `component(key)` / `component_config(key)` | 组件注册 / 装配 / 取配置（`raise`） |
| `launch()` | app.js 入口（`raise`） |

### 模型类型

`AppDef{handlers, global_data}` ｜ `PageDef{path, data, handlers, returns}` ｜
`ComponentDef{key, properties, data, handlers, observers, page_lifetimes}` ｜
`RouteDef{path, params}` ｜ `Payload`（事件参数只读包装）｜ `PageCtx`

### Json 构造助手

`jstr(String)` `jnum(Double)` `jint(Int)` `jbool(Bool)` `jarr(Array[Json])`
`jobj(Array[(String, Json)])`

### 状态

| API | 说明 |
|---|---|
| `PageCtx::set_data(Json)` | 补丁式透传（this.setData） |
| `PageCtx::set_state(Json)` | 部分合并 + 自动 diff（推荐） |
| `PageCtx::replace_state(Json)` | 全量替换 + diff |
| `PageCtx::get_data() -> Json?` | 读当前页面 data |
| `PageCtx::trigger_event(name, detail)` | 组件向页面发事件 |
| diff 底层 | `diff_to_paths(old,new)` `diff_partial` `diff_from_scratch` `patch_to_json` |

### 跨页 store

| API | 说明 |
|---|---|
| `create_store(label, initial) -> Store` | 新建状态源 |
| `Store::set(patch)` / `snapshot()` | 顶层键合并更新 / 全量快照 |
| `PageCtx::bind_store(store)` / `unbind_store(store)` | 订阅 / 退订 |
| `Store::subscriber_count()` / `label()` | 调试 |

### 路由

`register_route(RouteDef)` ｜ `navigate_to_route(path, params~)` ｜
`redirect_to_route` ｜ `switch_tab_route(path)` ｜ `route_url(path, query)`（底层纯函数）

### 提示与反馈

| API | 说明 |
|---|---|
| `toast(title, icon~, mask~, duration~)` | 轻提示，icon ∈ Success/Error/Loading/Plain |
| `hide_toast()` / `show_loading(title, mask~)` / `hide_loading()` | 加载态 |
| `confirm(title, content, show_cancel~, on_result~)` | 模态确认（Bool 回调） |
| `vibrate_short()` | 短震动 |

### 剪贴板与存储

`set_clipboard(data)` ｜ `copy_with_toast(data, tip~)` ｜
`set_storage(key, value: Json)` ｜ `get_storage(key) -> Json`（缺失返回 Null）｜
`remove_storage(key)` ｜ `clear_storage()`

### 导航与页面工具

`navigate_to(url)` `redirect_to(url)` `switch_tab(url)` `navigate_back(delta~)` ｜
`page_scroll_to(top, duration~)` ｜ `stop_pull_down_refresh()` ｜ `set_nav_title(title)`

### 设备 / 系统 / 媒体 / 位置

| API | 说明 |
|---|---|
| `system_info() -> Json` | 系统信息快照 |
| `make_phone_call(number)` | 拨号 |
| `choose_image(count~, on_result~)` | 选图，回调本地路径数组 |
| `preview_image(urls, current~)` | 图片预览 |
| `scan_code(on_result~)` | 扫码，回调内容（取消为空串） |
| `open_location(lat, lng, name~, address~)` | 地图打开位置 |

### 网络与分享

| API | 说明 |
|---|---|
| `request(url, http_method~, data~, on_response(status, body, header), on_fail)` | HTTP，method ∈ HttpMethod |
| `share_card(title, path~, image_url~) -> Json` | onShareAppMessage 返回体 |

### 全局数据

`get_global_data() -> Json?` ｜ `set_global_data(Json)`（对应 `getApp().globalData`）

### 错误类型

`MpError`：`PageNotFound(path)` / `ComponentNotFound(key)` / `AppNotRegistered`。
导出入口（launch/page/component）的 raise 会被包装成 JS 异常抛出。

### 扩展绑定（设备 / 界面 / 媒体 / 文件 / 位置 / 开放）

> 约定：异步回调在**无 wx / API 缺失 / 失败**时也必达 fallback
> （如空串、-1、空对象），调用方永远不会遇到"回调没来"。

| API | 说明 | 失败回退 |
|---|---|---|
| `get_clipboard_data(on_result~)` | 读剪贴板文本 | `""` |
| `get_network_type(on_result~)` | 网络类型字符串（wifi/4g/…） | `""` |
| `get_battery_info(on_result~)` | 电量百分比 | `-1` |
| `get_screen_brightness(on_result~)` / `set_screen_brightness(v)` | 屏幕亮度 [0,1] | `-1.0` |
| `keep_screen_on()` | 保持常亮 | — |
| `vibrate_long()` | 长震动 | — |
| `show_nav_bar_loading()` / `hide_nav_bar_loading()` | 导航栏加载态 | — |
| `set_nav_bar_color(fg, bg)` | 导航栏配色（fg: black/white） | — |
| `get_image_info(src, on_result~)` | 图片宽高 | `(0, 0)` |
| `save_image_to_album(path, on_ok~, on_fail~)` | 保存到相册（需授权） | on_fail |
| `download_file(url, on_result~)` | 下载到临时文件 | `""` |
| `get_location(on_result~)` | 当前位置（gcj02，需授权） | `{}` |
| `login(on_result~)` | 微信登录 code | `""` |
| `detect_platform() -> MiniPlatform` | 平台探测（跨端适配层） | `Other` |

平台适配的完整设计见 [docs/rfc/0001-平台适配设计.md](rfc/0001-平台适配设计.md)。

---

## 9. 无头测试

**为什么重要**：业务是纯 MoonBit，所以 `moon test` 能直接测逻辑；
要测“装配链路”（事件绑定、组件属性翻译、set_state 补丁、store 同步），
用仓库自带的模拟器 `scripts/sim/wx-sim.js`。

在你自己的项目里写无头端到端测试：

```javascript
// scripts/smoke.js（示例）
const { createWxSim } = require("./sim/wx-sim.js");  // 拷贝 wx-sim.js 到项目
const sim = createWxSim();
sim.install();                       // 挂载 global wx / App / Page / Component
const engine = require("../miniprogram/engine/moon-engine.js");

engine.launch();
engine.page("pages/index/index");
const inst = sim.makeInstance(sim.pageCfg.data);

// 模拟点击 → 断言状态与补丁
sim.pageCfg.onTap.call(inst);
console.assert(inst.data.count === 1, "count incremented");
console.assert(Object.keys(inst.__patches[0]).length === 1, "minimal patch");
```

模拟器能力：`sim.calls`（wx 调用记录）、`sim.storage`、`sim.navigations`（导航 url）、
`makeInstance`（data 深拷贝 + **路径感知 setData** + 事件记录）。仓库自身的
36 项冒烟就是用它写的（`scripts/smoke.js`），可以直接抄。

**MoonBit 侧测试**：普通业务函数用 `moon test`；涉及 wx 的绑定层测试在 node
环境里运行（wx 未定义时框架的 extern 有安全 guard，不会崩）。

---

## 10. 构建与发布

```bash
# 开发 / 发布
node mmp.cjs dev        # watch 自动重编译（日常）
node mmp.cjs release    # release 构建 + 装配到 miniprogram/engine/

# 产物再压缩（可选，微信上传体积更小；233KB → ~134KB）
node scripts/minify.cjs miniprogram/engine/moon-engine.js

# 发布到 mooncakes（包版本在 moon.mod 的 version，语义化，发布前 bump）
moon publish
```

> mooncakes 展示的 README 取自 moon.mod 的 `readme` 字段（本项目指向
> `README.mbt.md`，与 GitHub 的 README.md 保持同步）。

**版本规则**：改公开 API 加 minor；新增能力加 patch；移除/破坏性变更必须 bump
主版本。每个发布版本记得补 `CHANGELOG.md` 并打 git tag（`v<版本>`）。

---

## 11. 常见坑与 FAQ

**Q：为什么我的 setData 补丁不生效？**
先确认你用的是 `ctx.set_state` / `set_data`（不是直接改 `ctx.get_data()` 的返回值
——那是只读镜像）。再看 data 里是不是有与 store 同名的键（被 store 接管了）。

**Q：页面 handler 里能 `raise` 吗？**
不能指望它“抛给 JS”。MoonBit 的 raise 在 JS 后端编译为 Result 返回值，微信那边
看不到异常。所以：要么在 handler 内 `catch` 处理，要么调用框架那些“绝不 raise”
的安全函数（路由/导航都是警告降级式）。

**Q：wx 的能力在单测里能调吗？**
能，安全降级：无 `wx` 全局时（node 单测）框架的 extern 会静默跳过；
要断言行为就在 sim 里装 mock 并查 `sim.calls`。

**Q：怎么调 WXML 里组件/页面的关系？**
事件名对事件名：组件 `trigger_event("pick", ...)` ↔ WXML `bind:pick="onPick"` ↔
页面 handlers 里的 `"onPick"`。事件不进 handler 时检查三处拼写是否一致。

**Q：老是有 dev 警告刷屏？**
`[moon-miniprogram]` 前缀的警告都是设计内的“安全降级提示”（路由未注册、
参数越界、重复注册），说明调用姿势与注册表不一致——先看警告文本再决定改哪里。

**Q：想复用别的 MoonBit 代码？**
业务包（engine）的 moon.pkg 加 `import` 依赖即可，跨包代码用 `@包别名` 调用；
框架本身也是按这个方式从 mooncakes 拉取的。
