【本文由 AI 辅助完成】
最近用 topcoat 0.6 写了一个本地单机便笺应用 Stickies:增删改查、置顶、标签、全文搜索、回收站(30 天自动清理),外加多窗口 WebSocket 实时同步。项目本身还在实验期,但开发过程几乎全程在跟框架的”年轻”搏斗,积累的经验值得单独成文。
topcoat 是 tokio-rs 出品的一个很年轻的 Rust 全栈框架:服务端渲染 + $(...) 运行时表达式做客户端响应式,#[page]/#[route]/#[shard]/#[procedure]/#[component] 五个宏驱动。文档稀少、API 不稳定,很多行为要靠读源码和试错。本文按技术层组织,每个坑给出”现象 → 根因 → 修法 → 通用教训”。
什么是 topcoat
先说清楚这个框架的模型,后面看坑才有上下文:
- SSR + 客户端响应式:页面在服务端渲染成 HTML,
$(...)表达式在服务端求值、序列化成 JS 塞进 HTML,浏览器端靠这段 JS 建立响应式绑定。信号(signal)变化时,绑定的 DOM 自动更新,或者触发服务端重新渲染某个区块。 - 五个宏:
#[page]定义页面路由;#[route]定义子路由;#[shard]定义”服务端重查的视图块”——参数一变,浏览器请求服务端重渲染整块;#[procedure]定义客户端可调用的服务端过程(类似 RPC);#[component]复用 UI 片段。 - 现状:0.6 版本,README 和示例都很少,API 尚未稳定,破坏性变更随时可能发生。用它做生产项目要谨慎,但作为”Rust 全栈还能长什么样”的探索,值得一试。
1. $(...) expr DSL 的词汇表边界
expr 是服务端求值、序列化成 JS 塞进 HTML 的 DSL,词汇表很小:只有 f64、String、bool 和信号/事件的 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 | document.addEventListener('submit', (event) => { |
教训:凡是”外部脚本往 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 webServer 用 reuseExistingServer: 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 主题自带
.darktoken,但页面没接dark类切换,属未启用能力。
结语
topcoat 最大的特点是”思路新、文档少”,所以开发过程基本是”读源码 + 试错”双轨并行。对后来者的三条经验:
- 先读 runtime 的 browser/TS 源码再猜行为——
dehydrate()、cx.hydrate、shard 重渲染这些机制,源码一读就通,猜则踩坑。 - 能编译 ≠ 能跑:expr/raw! 生成的 JS 错只在真实浏览器暴露,尽早跑 E2E。
- 把”绕行”当成一等决策:DSL 限制、类型妥协、测试分层,每条都写进 spec/ADR,让未来的改动知道”这里为什么长这样”。
参考链接:
- Stickies 项目: https://github.com/duyixian1234/stickies
- topcoat: https://github.com/tokio-rs/topcoat



