0%

topcoat 0.6 踩坑实录:用 Rust 全栈框架写一个本地便笺应用

最近用 topcoat 0.6 写了一个本地单机便笺应用 Stickies:增删改查、置顶、标签、全文搜索、回收站(30 天自动清理),外加多窗口 WebSocket 实时同步。项目本身还在实验期,但开发过程几乎全程在跟框架的”年轻”搏斗,积累的经验值得单独成文。

topcoat 是 tokio-rs 出品的一个很年轻的 Rust 全栈框架:服务端渲染 + $(...) 运行时表达式做客户端响应式,#[page]/#[route]/#[shard]/#[procedure]/#[component] 五个宏驱动。文档稀少、API 不稳定,很多行为要靠读源码和试错。本文按技术层组织,每个坑给出”现象 → 根因 → 修法 → 通用教训”。

什么是 topcoat

topcoat 是 tokio 生态(tokio-rs 组织)出品的 Rust 全栈 Web 框架,官方 README 给自己的定位是 “The full full-stack framework for Rust”,并自称 modular、batteries-included(模块化、电池全含),核心目标是最小化样板代码、最大化生产力(simplicity and productivity)。生态位置上的对标物很直接——正如 Next.js 之于 React,topcoat 之于 Rust/tokio:服务端渲染、客户端响应式、服务端过程、组件库、模块化路由一应俱全,而且整个链路没有 Node 参与。

先说清楚这个框架的模型,后面看坑才有上下文:

  • SSR + 客户端响应式:所有标记在服务端渲染,组件可以是 async 的、直接查数据库,省掉传统前后端分离那一层 API 样板代码——这是官方 README 的核心卖点(”Client reactivity without the boilerplate”)。$(...) 表达式是普通 Rust 代码:服务端求值用于初始渲染,同时被翻译成 JavaScript 塞进 HTML,浏览器端即时重跑、建立响应式绑定。无 wasm bundle、无客户端构建步骤。信号(signal)变化时,绑定的 DOM 自动更新,或触发服务端重新渲染某个区块。
  • 模块化路由:可以从 src/ 模块结构自动推断路由树(类似 Next.js 的 app router 文件约定),无需构建步骤;也支持 #[page]/#[route] 手动声明。
  • 核心宏#[page] 定义页面路由;#[route] 定义子路由;#[shard] 定义”服务端重查的视图块”——参数一变,浏览器请求服务端重渲染整块;#[procedure] 定义客户端可调用的服务端过程(对标 Next.js 的 server actions / API route);#[component] 复用 UI 片段。
  • Topcoat UI:基于 Tailwind 的组件库,受 shadcn/ui 启发,topcoat ui 命令把组件源码拷贝进项目、可自由改——“batteries-included”的直接体现。
  • 现状:官方明确标注 “Early-stage and experimental. Expect breaking changes.”,0.6 版本 README 和示例都很少。用它做生产项目要谨慎,但作为”Rust 全栈还能长什么样”的探索,值得一试。

1. $(...) expr DSL 的词汇表边界

expr 是服务端求值、序列化成 JS 塞进 HTML 的 DSL,词汇表很小:只有 f64Stringbool 和信号/事件的 surrogate。大多数坑源于”想用 Rust 世界的类型/能力,但 DSL 里没有”。

1.1 u64 传参要过 f64

#[procedure] 参数里只有 f64 数字。便笺 id 是 u64,于是 signal id = note.id as f64;,服务端再 id as u64 转回。丑,但绕得过去。教训:DSL 边界上妥协类型,进服务端立刻还原,并记录下理由,避免后人看到 as f64 时把它”修”回去。

1.2 没有定时器 → raw! 兜底

防抖 500ms 自动保存需要 setTimeout/clearTimeout,词汇表里没有。用 raw!(js_string, rust_fallback) 塞原生 JS:

1
timer.set(raw!("setTimeout(${_save}, 500)", 0.0));

${_save} 是插值,其余是字面 JS。教训:把框架没提供的原生能力包在 raw! 里,${...} 负责桥接

1.3 raw! 的 JS 必须是表达式(两次翻车)

第一次把 raw!("if (...) { clearTimeout(...) }", 0.0); 当独立语句写,展开后是裸块 { into_surrogate(0.0) },编译报 E0308 expected (), found F64Surrogate。改成 let _cleared = raw!(...) 后能编译了,但浏览器Unexpected token 'if'——if 是语句不是表达式,出现在 let x = if(...) 里直接语法错误。最终包成 IIFE:

1
let _cleared = raw!("(() => { if (${timer}.get() !== -1) { clearTimeout(${timer}.get()); } })()", 0.0);

教训:raw! 的 JS 必须是表达式(IIFE / 逗号表达式都行),并且”能编译 ≠ 能跑”——这类错只在真实浏览器里暴露。

1.4 procedure 参数走 dehydrate():裸值要包 surrogate

自动保存广播需要把”来源窗口 id”传给 procedure。第一次写 let cid = raw!("window.__stickiesClientId || ''", String::new()),浏览器报 r.dehydrate is not a function。查了 runtime 源码才明白:浏览器端调用 procedure 时对每个参数调 .dehydrate() 序列化,raw 出来的是裸字符串,不是 surrogate。用 cx.hydrate(...) 包一下:

1
let cid = raw!("cx.hydrate(window.__stickiesClientId || '')", String::new());

教训:DSL 里任何要跨进程的值,都得是 surrogate 形态cx.hydrate 是造 surrogate 的万能入口(数字/字符串/bool 直接传字面值即可)。

1.5 闭包捕获循环变量 → E0597

标签 chips 行想在 for tag in tags 里给每个按钮写 @click=$(|_e| tag_id.set(tid))let tid = tag.id as f64; 在循环体内声明,闭包捕获它 → 借用生命周期 E0597(handler 存进 view 活得比局部变量久)。绕法:把 id 放进按钮 value 属性,handler 用 raw + parseFloat 读:

1
<button value=(tag.id) @click=$(|_e: Event| raw!("${tag_id}.set(parseFloat(${_e}.target.value))", 0.0))>

教训:循环/临时变量别想捕获进 handler;把值放 DOM 属性、事件里读,是通用出路。

2. 数据层:toasty 0.7 的现实

2.1 push_schema 非幂等 → 手动建表

toasty 0.7 的 sqlite 驱动 push_schema 不幂等,重跑会坏。绕法:不用它,在 db.rs 里手写 CREATE TABLE IF NOT EXISTS,建表语句和模型手维护同步。教训:新 ORM 的”自动迁移”别轻信,先验证幂等性

2.2 查询 DSL 限制 → repository 层内存过滤

搜索(LIKE 标题+正文)、标签过滤、回收站清理的表达式在 toasty DSL 里要么不支持要么写不出来。约定:所有数据访问收敛到 repository 层,需要复杂查询时先全量拉回、在 Rust 里过滤/排序。数据量小(本地单机)时这是合理的务实选择。

2.3 时间用 i64 epoch-ms

DateTime 类型在 toasty sqlite 驱动里未验证,直接用 i64 存毫秒,排序和 30 天清理阈值都好写。教训:框架类型未验证时,用最朴素的可比较类型,别赌

2.4 每请求拿句柄

toasty 的语句要 &mut Db,而 handler 只有 &Cx。用 app_context::<Db>(cx).clone()——Db 是池句柄,clone 便宜。

3. SSR + 响应式:组件 / shard 生命周期

3.1 shard 参数变化触发服务端重查

搜索框 @input 更新 query signal,note_wall(query, tag_id) shard 参数一变,浏览器请求服务端重渲染整块列表。这是”即时过滤”的实现基础。教训:shard 是”服务端重查的视图块”,用它做列表/过滤天然合适

3.2 shard 重渲染替换内部 DOM:隐藏字段会丢

ws.js 需要在所有 POST 表单里注入隐藏的 client 来源 id。第一版在 DOMContentLoaded 注入一次——但 note_wall 是 shard,搜索/标签过滤会重渲染出全新表单,注入字段丢了。提交时 source 为空 → 广播回来自己窗口不认 → 自我 reload。修法:改成提交时注入

1
2
3
4
5
6
7
8
document.addEventListener('submit', (event) => {
const form = event.target;
if (form && form.querySelector && !form.querySelector('input[name="client"]')) {
const input = document.createElement('input');
input.type = 'hidden'; input.name = 'client'; input.value = myId;
form.appendChild(input);
}
});

教训:凡是”外部脚本往 SSR 输出的 DOM 里塞东西”,都要考虑该 DOM 会被 shard/组件重渲染替换;在事件发生时注入,而不是在页面加载时注入。

3.3 表单按钮 stopPropagation

卡片整块 @click 进编辑态,卡片上的”置顶/删除”按钮会冒泡触发编辑态。给按钮所在 <form>@click=$(|e: Event| e.stop_propagation())。小坑,但容易漏。

4. 多窗口同步:WebSocket + 广播

4.1 自我广播竞态:核心一课

自动保存(procedure)广播 note_updated编辑窗口自己会收到location.reload() → 打断编辑。第一版 hack 是”表单提交后跳过下一条消息”(skipNext),不优雅且漏编辑场景。正解:给每次页面加载一个来源窗口 id,广播带 {source, type},客户端只刷新 source ≠ 自己的消息。

教训:任何”广播全量变化”的系统,必须给消息带来源身份,接收方排除自己;用”跳过下一条”这种时序 hack 迟早漏。

4.2 窗口 id 别用 sessionStorage

第一版把 id 存 sessionStorage 想跨刷新稳定——但复制标签页共享 sessionStorage,两个窗口会互认成”自己”,彼此跳过同步。改成每次页面加载 Math.random() 生成:id 只需在单页生命周期内稳定(广播在旧页面收到、导航前比对即可),不需要跨刷新。

4.3 表单 PRG 与广播的竞态

表单提交 → PRG 导航,同时广播到达。时序对就没事(消息由旧页面收到并比对),但”提交瞬间在表单里补隐藏字段”(3.2 的修法)必须做对,否则 source 为空、所有窗口都 reload。

5. 资源打包与样式

5.1 cargo build 不重建 assets:主题”改了不生效”的假象

target/debug/assets(runtime JS、tailwind.css、ws.js)由 topcoat dev 生成,cargo build 不重建。第一次集成主题时改完 build.rs 发现产物没变,以为是 @theme 语法不支持,其实是缓存旧 bundle。教训:先确认”改动到底有没有进产物”再怀疑框架;删掉 target/debug/assets 重建即真相大白。

5.2 topcoat ui 主题需要 Tailwind v4 CLI

topcoat ui init 装的 neutral 主题(styles.css)用了 Tailwind v4 的 @theme inline/@source/@custom-variant。topcoat-tailwind 默认 CLI 版本是 4.3.2,build.rs 里 .input("styles.css") 即可让 token 正常产出;页面再改用 bg-background/text-foreground/border-border 等 token 类。教训:@theme inline 是 v4 语法,确认构建管线用的 CLI 版本

5.3 Tailwind 只生成扫描到的类

内容扫描决定产物,没在源码里出现的类不会进 CSS。搜索框、卡片这些动态渲染的类名都在 .rs 里,扫描能覆盖;但任何”运行时拼出来的类名”都会丢——别用字符串拼接类名。

6. 测试策略

6.1 无 TestClient → 先 binary 集成,后迁 Playwright

topcoat 0.6 没有 TestClient。第一版按 spec 做了 binary-level HTTP 集成测试(起真实二进制 + reqwest 打 HTTP),验证 CRUD/回收站/标签。后来按需求全部迁到 Playwright:E2E 一律走真实浏览器,Rust 侧只留 repository 单测(内存 SQLite),删掉 reqwest dev-dep。教训:“E2E 归浏览器、单测归纯逻辑”的分层更干净,HTTP 层测试在浏览器 E2E 覆盖后是重复劳动。

6.2 E2E 直连 dev 服务

Playwright webServerreuseExistingServer: true 直接连 topcoat dev 的 3000 端口;无服务时才自动拉起带隔离测试库的实例。注意:webServer 先于 globalSetup 启动,删测试库只能在应用启动时做,所以应用在测试环境变量下先删库。

6.3 E2E 常见坑

  • strict mode violation:多张卡片有同名文本(每个卡片都有隐藏的”已保存”),getByText('已保存') 解析到多个元素 → 断言必须限定在卡片作用域内。
  • WS 自我 reload:见 4.1。
  • Windows 特有.cargo-build-lock 残留(删掉即可)、运行中的 exe 占用 target/debug/stickies.exe 导致 rebuild 报”拒绝访问”、CLI 要用全路径。

已知局限

  • toasty 自动迁移:仍用手写建表,等 toasty 0.7 的 push_schema 幂等后再考虑迁移。
  • WS 事件结构:目前是 {source, type} 最小 JSON;将来可以带 note_id/note 做增量更新,替代”整页 reload”。
  • 深色模式:neutral 主题自带 .dark token,但页面没接 dark 类切换,属未启用能力。

结语

topcoat 最大的特点是”思路新、文档少”,所以开发过程基本是”读源码 + 试错”双轨并行。对后来者的三条经验:

  1. 先读 runtime 的 browser/TS 源码再猜行为——dehydrate()cx.hydrate、shard 重渲染这些机制,源码一读就通,猜则踩坑。
  2. 能编译 ≠ 能跑:expr/raw! 生成的 JS 错只在真实浏览器暴露,尽早跑 E2E。
  3. 把”绕行”当成一等决策:DSL 限制、类型妥协、测试分层,每条都写进 spec/ADR,让未来的改动知道”这里为什么长这样”。

参考链接:

扫码加入技术交流群🖱️
QR code