Rust in Everything
基于 topcoat 0.10.0

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!() 推出 URL02
「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({&quot;t&quot;:&quot;signal&quot;,&quot;id&quot;:&quot;997b9b79b6af27b60c5f3ec25000f50c&quot;,&quot;v&quot;:false})-->
<p hidden="" data-topcoat-bind:hidden="(cx.hydrate({&quot;t&quot;:&quot;Signal&quot;,&quot;id&quot;:&quot;997b9b79b6af27b60c5f3ec25000f50c&quot;})).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 里了,是默认开启的 compression feature;限流确实没有,要自己写 #[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;压缩已经内置。

💬 关联讨论

发起讨论
加载中...