构建完整 Todo 应用
本教程使用 isomorphic 模板连接共享契约、原生状态和 Rabbita 界面。先运行生成的 Todo 应用,再跨三个模块加入 Complete all 和 Reopen all 操作。
请先完成环境准备,包括 Warren 构建工具需要的 Node.js。无需修改之前的 minimal 应用。
创建并体验应用
在另一个 MoonBit 工作区之外执行:
proton_cli new todo-app --template isomorphic --yes
cd todo-app
moon update
moon install moonbit-community/warren@0.4.5
proton_cli cef setup
生成的配置已经调用安装好的 Warren 可执行文件。启动应用:
proton_cli dev
添加“Alpha”和“Beta”,标记其中一个完成,再搜索它。生成的应用已经支持创建、完成、删除和过滤查询。列表保存在内存中,重启后会清空。
编辑前先关闭应用。保留生成的模块名与包导入,以下修改均在现有模板上追加。
模板组织
isomorphic 模板采用 MoonBit 前端,包含三个模块:
todo-app/
moon.work
proton.project.json
shared/
moon.mod
moon.pkg
todo_contract.mbt
backend/
moon.mod
app/
moon.pkg
main.mbt
todo/
moon.pkg
backend.mbt
commands.mbt
frontend/
moon.mod
main/
moon.pkg
main.mbt
internal/query/
public/
根目录的 moon.work 连接三个模块:
- shared 定义可序列化的载荷,以及命令、事件描述符,供两个构建目标使用。
- backend 编译为原生代码。
todo/backend.mbt保存 Todo 状态和业务操作,todo/commands.mbt绑定操作,app/main.mbt启动应用。 - frontend 编译为 JavaScript。
main/main.mbt定义 Rabbita 界面,public/保存 HTML 和样式表。
模板中的 frontend/internal/query 管理查询状态与订阅。它属于生成的应用,可以随应用一起修改,并不是 Proton 公共 API。
应用命令通过普通 bind 调用注册,这个模板无需命令代码生成规则。
理解已有流程
已有的创建操作中,Create 调用 create_todo,后端校验并修改状态,MutationReply 返回成功或业务拒绝,todos_changed 让前端快照失效并触发查询。
前端草稿是本地 UI 状态,后端列表是应用状态的权威来源。新操作应修改后端完整列表,而不只是当前搜索结果中可见的行。
1. 扩展共享契约
在 shared/todo_contract.mbt 末尾追加:
///|
pub(all) struct SetAllCompletedRequest {
completed : Bool
} derive(ToJson, FromJson)
///|
pub extend SetAllCompletedRequest with ToJson::{to_json}
///|
pub extend SetAllCompletedRequest with FromJson::{from_json}
///|
pub let set_all_completed : @proton_contract.Command[
SetAllCompletedRequest,
MutationReply,
] = @proton_contract.command("set_all_completed")
布尔值选择完成或重新打开。复用模板的 MutationReply,可以继续使用现有响应处理逻辑。两个构建目标导入同一个描述符。
2. 实现状态修改
在 backend/todo/backend.mbt 末尾追加:
///|
fn Backend::set_all_completed(
self : Backend,
completed : Bool,
) -> @shared.MutationReply {
let mut changed = false
for index = 0; index < self.todos.length(); index = index + 1 {
let todo = self.todos[index]
if todo.completed != completed {
self.todos[index] = { ..todo, completed, }
changed = true
}
}
if changed {
self.version += 1
}
@shared.Changed(version=self.version)
}
操作遍历完整列表,有实际变化时只将修订号增加一次。重复相同操作不会改变修订号;即使列表为空,也返回当前版本。
3. 注册处理器并通知数据失效
在 backend/todo/commands.mbt 的已有 Backend::register_commands 方法内,与其他绑定并列添加:
registrar.bind(@shared.set_all_completed, (_context, request) => {
let reply = self.set_all_completed(request.completed)
self.notify(reply)
reply
})
保留原有绑定。self.notify 是生成后端中已有的辅助方法,会向应用窗口生命周期中保存的事件目标发送 todos_changed。
这里不需要新事件类型。它与其他 Todo 修改一样改变了状态,已有观察者重新执行相同查询即可。
4. 接入前端操作
本步骤全部修改 frontend/main/main.mbt。
在已有的 enum Msg 内添加一个分支:
SetAllCompleted(Bool)
在 update 已有的 match msg 内添加以下分支:
SetAllCompleted(completed) =>
(
{ ..model, error: None, },
@proton_rabbita.invoke(
@shared.set_all_completed,
{ completed, },
reply => emit(MutationReceived(reply)),
error => emit(CommandFailed(error)),
),
)
model、emit、MutationReceived 和 CommandFailed 已由该函数与模块定义。成功路径复用模板的修改响应处理,错误路径继续显示 bridge 失败。
在 view 中找到 div(class="todo-toolbar", [...]),将下面两个按钮追加到其子元素数组中,放在已有 Refresh 按钮之后:
button(
type_="button",
on_click=emit(SetAllCompleted(true)),
"Complete all",
),
button(
type_="button",
on_click=emit(SetAllCompleted(false)),
"Reopen all",
),
模板已经导入 button,无需增加导入。消息中的布尔值经由请求传到后端。
5. 检查行为
在 backend/todo/backend_wbtest.mbt 中追加这个回归检查:
///|
test "bulk completion changes the whole list once" {
let backend = Backend()
ignore(backend.create("Alpha"))
ignore(backend.create("Beta"))
assert_true(backend.set_all_completed(true) is @shared.Changed(version=3))
assert_true(backend.snapshot("").todos.all(todo => todo.completed))
assert_true(backend.set_all_completed(true) is @shared.Changed(version=3))
assert_true(backend.set_all_completed(false) is @shared.Changed(version=4))
assert_true(backend.snapshot("").todos.all(todo => !todo.completed))
}
在项目根目录执行:
moon check --target js,native
moon -C backend test todo --target native
moon -C frontend test --target js
proton_cli dev
添加两个待办项,Complete all 应将两项全部完成,Reopen all 应重新打开两项。搜索到其中一项后重复操作,再清空过滤条件,应看到两项都发生变化。重复点击 Complete all 不应继续增加修订号。
如果后端变化但显示不更新,检查是否保留 self.notify 和窗口 attach/detach 钩子。如果新命令不可用,检查注册代码并重启后端。
界面如何保持最新
模板的 todo_app 创建 list_todos 查询,并用 todos_changed 使其失效。应用内的查询辅助包先订阅,再获取数据,在输入变化时取消被替代的旧请求。
修改响应刷新发起操作的界面,事件则使观察者的数据失效。两者可能重叠,所以辅助包跟踪请求代次,避免旧结果覆盖新的搜索。这些是 frontend/internal/query 中的应用代码,不是另一个服务器,也不是 Proton 的公共状态管理框架。
构建完成后的应用
停止开发进程,先执行 proton_cli build。下一步按宿主平台选择;以下命令都在 todo-app 项目根目录执行。
macOS 与 Windows
本地验收先生成未签名的 app 格式;Windows 的 app 是包含 EXE 和依赖的目录,不是 macOS bundle。
proton_cli package --release --format app --dry-run
proton_cli package --release --format app
打开 CLI 输出的 dist 下的应用路径。macOS 从 .app 启动,Windows 从产物目录中的应用 EXE 启动,不能只复制 EXE。本步骤不需要 NSIS,也没有完成面向用户分发的签名/公证。
Linux
默认模板不附带图标。将一张实际有效的 PNG 图标保存为项目内的 icons/app.png,并在 proton.project.json 的已有 package 对象中加入:
"icons": ["icons/app.png"]
确认 appimagetool --version 能执行。若尚未安装,可在 Ubuntu x64 终端执行以下命令,将工具保存在用户目录中;解包方式无需依赖 FUSE 来运行打包工具:
mkdir -p "$HOME/.local/lib/proton-appimagetool"
cd "$HOME/.local/lib/proton-appimagetool"
curl -fL -o tool.AppImage https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage
echo 'ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 tool.AppImage' | sha256sum -c -
chmod +x tool.AppImage
./tool.AppImage --appimage-extract
ln -sf squashfs-root/AppRun appimagetool
export PATH="$HOME/.local/lib/proton-appimagetool:$PATH"
appimagetool --version
回到 todo-app 项目根目录,再执行:
proton_cli package --release --format appimage --dry-run
proton_cli package --release --format appimage
从图形会话启动输出的 AppImage。运行环境无法挂载 AppImage 时,可先对产物执行 --appimage-extract,再运行生成目录中的 AppRun。
验收
在开发服务器已经停止的情况下启动产物,添加两个项目并验证 Complete all、Reopen all 和搜索操作。打包失败、启动失败与按钮行为失败是不同阶段;仅 dry-run 成功不算完成。
本教程的列表保存在内存,重启清空是预期行为。持久化应在后端实现。图标与分发要求见打包参考。