topcoat 解决什么问题
主线位置:三条主线的起点。本页不讲实现,只说 topcoat 要做到什么、刻意不做什么,后面每条主线都是在兑现这里的某一条承诺。
flowchart LR
subgraph server["服务端"]
page["page、component:async fn"] --> html["HTML"]
page --> db[(数据库)]
end
subgraph browser["浏览器"]
dom["DOM"] --> expr["$(...) 译成的 JS:就地重算"]
expr -->|"需要服务端时"| req["WebSocket 或 HTTP 请求"]
end
html --> dom
req --> page
它自己怎么说
README 的副标题是「The full full-stack framework for Rust」,第一段:
Topcoat is a modular, batteries-included Rust framework for building full-stack apps.
It prioritizes simplicity and productivity.
紧接着一行粗体:「Early-stage and experimental. Expect breaking changes.」
README 里的第一个例子是这样的(缩进改成了 2 空格):
topcoat = "=0.10.0"
tokio = { version = "=1.54.1", features = ["full"] }
use topcoat::{
Result,
router::{module_router, page},
view::{View, component, view},
};
#[tokio::main]
async fn main() {
topcoat::start(module_router!().build()).await.unwrap();
}
#[page]
async fn home() -> Result<impl View> {
Ok(view! {
<!DOCTYPE html>
<html>
<body>
hello(name: "World")
</body>
</html>
})
}
#[component]
async fn hello(name: &str) -> Result<impl View> {
Ok(view! { <h1>"Hello, " (name) "!"</h1> })
}
cargo run,再 curl -i http://127.0.0.1:3000/(去掉了 date 头):
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
vary: Accept
vary: accept-encoding
content-length: 63
<!DOCTYPE html><html><body><h1>Hello, World!</h1></body></html>
这几行里已经有了本卷的三条主线中的两条:topcoat::start 绑在 127.0.0.1:3000 上(请求主线的起点),#[page] 没写路径却服务在 /,因为 module_router!() 把它所在的模块当成根(发现主线)。两个 vary 头一个来自视图响应(它会按 Accept 头换格式),一个来自默认开启的压缩。view! 里标签之间的空白都没了:模板按元素和文本节点生成 HTML,缩进不是内容。
README 列出的几件事
README 的「What makes Topcoat different」一节有六个小标题。前四个(客户端响应式、模板、模块路由、资产打包)说的是框架本身,每句话都对应源码里的一块:
| README 的说法 | 实际是什么 | 本卷 |
|---|---|---|
| 「Topcoat renders all markup on the server: components can be async and query the database directly」 | 组件是 async fn,在服务端渲染;同一个 view! 里的兄弟组件在同一个任务里并发 poll;没完成的部分先发占位,完成后追加到同一个响应里 | 01、04 |
「A $(...) expression is ordinary type-checked Rust that Topcoat evaluates on the server for the initial render and also translates to JavaScript」,「No wasm bundle, no client build step」 | 一个宏同时生成服务端 Rust 和写进 HTML 属性的 JS;浏览器端是一份随框架发布的 JS 运行时 | 05 |
「mark the component as a #[shard]. Topcoat re-renders it on the server whenever one of its $(...) arguments changes」 | shard 有自己的端点,参数变了浏览器发请求,服务端只重跑这个组件 | 05 |
「The view! macro stays true to HTML and Rust」 | 元素照 HTML 写,for / if / match 直接写在模板里 | 04 |
| 「Topcoat can optionally infer your route tree from your app's module structure (without a build step)」 | 链接期用 inventory 登记,启动时按 module_path!() 推出 URL | 02 |
「The bundler scans your compiled binary for asset! calls」 | asset! 在二进制里留下一段字节标记,CLI 扫出来再拷文件 | 06 |
另外两个小标题是工具:topcoat ui 把组件源码拷进你的项目(像 shadcn/ui 那样),tailwind feature 在 build.rs 里下载 Tailwind 的独立可执行文件,所以「不需要 Node」,但构建要联网。
README 里交互的例子:
#[component]
async fn faq(cx: &Cx) -> Result<impl View> {
let open = signal(cx, || false);
Ok(view! {
// Runs entirely in the browser; no server round-trip.
<button @click=$(|_e| open.set(!open.get()))>"What is Topcoat?"</button>
<p :hidden=$(!open.get())>"A full-stack Rust framework."</p>
})
}
服务端渲染出来的 <p>(下一页的实验里把这一行放进了页面)是这样的:
<!--::topcoat::signal({"t":"signal","id":"997b9b79b6af27b60c5f3ec25000f50c","v":false})-->
<p hidden="" data-topcoat-bind:hidden="(cx.hydrate({"t":"Signal","id":"997b9b79b6af27b60c5f3ec25000f50c"})).get().not()">hi</p>
!open.get() 在服务端求值成 true,所以首屏就带着 hidden;同一个表达式还被译成了一段 JS 放在 data-topcoat-bind:hidden 里,! 变成了 .not(),open 变成了按 id 取回的 signal。signal 的初值以注释的形式送到页面。浏览器加载 topcoat 的运行时脚本后,靠这些属性和注释把交互接起来。整个过程没有 wasm,没有前端构建。05 章逐段拆这件事。
和同类方案的位置
上游在 benchmarks/ 里放了两个对照项目:axum-maud 和 leptos。它自己拿来比较的就是「axum 加模板库」和「wasm 水合框架」这两类。下表按各家文档能查到的事实写,只比渲染模型:
| 方案 | HTML 在哪里生成 | 交互怎么来 | 浏览器要下载什么 |
|---|---|---|---|
| axum + maud / askama | 服务端,模板库只管拼字符串 | 自己写 JS,或配 htmx 之类 | 你写的脚本 |
| Leptos、Dioxus fullstack | 服务端先渲染一次,带水合标记 | 浏览器加载 wasm,在客户端重建组件树接管页面(见 dioxus 卷的水合) | wasm 包 + 胶水 JS |
| Phoenix LiveView | 服务端 | 按 Phoenix 文档的描述,每个页面连接在服务端保持一个有状态的进程,事件经 WebSocket 发上去,服务端算出差异发回来 | 一份通用的客户端 JS |
| Rails(默认带 Hotwire)、Laravel | 服务端 | 局部替换 HTML 片段,交互逻辑写在 JS 控制器或服务端组件里 | 框架的客户端 JS |
| topcoat | 服务端,组件是 async fn | 简单状态在浏览器用译出来的 JS 就地算;要服务端的部分(shard)发请求重跑,返回 HTML 再 morph 进页面 | 一份通用的运行时 JS,不按应用生成 |
和 axum 比,topcoat 不只是「加了一个模板库」:路由(含模块发现)、静态资产、cookie、会话、Origin 检查、压缩、SSE、WebSocket 都在框架里,默认就开。代价是它没有建在 axum 上,axum 的提取器和 Handler 不能直接用,tower 中间件要经过一个适配层(tower 互操作与可信代理)。
和 wasm 水合框架比,topcoat 的浏览器端没有组件树,也不运行你的 Rust 代码,只运行一份通用运行时和你写进 $(...) 的那一小段表达式。好处是没有 wasm 包,首屏后不用再跑一遍渲染;代价是 $(...) 只支持 Rust 的一个子集,更复杂的客户端状态要么交给服务端(每次一个往返),要么用 raw! 写原生 JS。
和 LiveView 比,两者都把交互的计算放在服务端,但 topcoat 的服务端没有「每个连接一个常驻状态」:shard 重跑是一个普通的请求,最后被改写成一次 GET 走完整的路由;交互状态放在浏览器端的 signal 里,重跑时随请求带上来(05 章细讲)。
刻意不做的事
从源码里能看出几条明确的取舍,后面章节会逐一展开:
- 不建在 axum 上。服务器直接用 hyper 1 和 hyper-util 的
auto::Builder,路由表直接用 matchit 0.9,一个请求的上下文是自己的Cx。 - 没有构建步骤。路由不靠 build.rs 生成、不扫文件系统,靠 inventory 在链接期收集;资产不靠 build.rs 列清单,靠二进制里的标记。
- 不发 wasm。浏览器端是一份通用 JS 运行时,随
topcoat-runtime一起发布。 - 安全默认值偏严。跨站的写请求和 WebSocket 握手默认 403;会话 cookie 默认
__Host-前缀加Secure。
还没有的东西
README 的 Roadmap(0.10.0)里列着这些计划:topcoat new 命令、静态导出、更多响应式能力、更多 UI 组件和整块的「blocks」、更好的 Toasty 集成、表单校验、本地化、OpenAPI、部署文档、静态页预渲染、WebTransport、图片优化、Markdown、更好用的中间件(限流、压缩等)、认证、后台任务、Islands。
其中两条要对照着看:
- 「更好用的中间件,如 rate-limiting、compression」:压缩其实已经在 0.10.0 里了,是默认开启的
compressionfeature;限流确实没有,要自己写#[layer]或经TowerLayer套 tower 的中间件。 topcoat new:tag 之后的 #501 已经加上了,0.10.0 的 CLI 没有它(topcoat --help只列出dev、fmt、asset、ui)。
小结
- topcoat 自称「modular, batteries-included」的全栈框架,而且明说早期、实验性;本卷只对 0.10.0 负责。
- 所有 HTML 都在服务端生成,组件是
async fn;交互用$(...),同一段代码服务端求值一次、译成 JS 在浏览器再算,不发 wasm、不要前端构建。 - 和 axum 加模板库比,它自带路由、资产、会话、安全默认值,但不能直接用 axum 的提取器;和 wasm 水合框架比,浏览器里没有组件树;和 LiveView 比,服务端没有每连接常驻状态。
- Roadmap 里还没有静态导出、认证、后台任务、限流、Islands;压缩已经内置。