跳到正文

目录

Tokio-rs Topcoat:服务端渲染即客户端响应性——Rust 全栈框架的范式突破

Tokio-rs Topcoat:服务端渲染即客户端响应性——Rust 全栈框架的范式突破

一句话判断:Topcoat 不解决"怎么在浏览器里跑 Rust"(那是 wasm 的活),它解决"怎么让服务端渲染的页面自己会动"。$() 表达式是一段编译两次的 Rust——服务端求值一次生成 HTML,翻译成 JS 后在浏览器再跑一次;#[shard] 组件则在参数变化时回到服务端重渲染。三种执行模型共用同一份类型,没有 wasm bundle,没有客户端构建步骤。代价是:表达式只支持一个很小的 Rust 子集,整个 runtime 官方明示"高度实验性,预期破坏性变更"。

目录

一、问题的本质:为什么"全栈"总是意味着"双份代码"

每一种传统全栈框架——无论 Next.js、Remix、SvelteKit 还是 Leptos/Axum 组合——都在回答同一个问题:如何把"UI 表达"从服务端跨越到客户端?

经典的答案有两条:

  1. 复制一套语言:服务端用 Rust/Node/Python,客户端用 JavaScript/TypeScript。同一段逻辑写两遍,接口靠 OpenAPI/GraphQL/tRPC 缝合,类型一致性靠生成代码或编译时工具勉强维持。
  2. 复制一份运行时:服务端跑 SSR,客户端把同一份代码编成 wasm bundle(Leptos/Dioxus/Yew)。开发体验统一了,但 bundle 体积、首屏延迟、工具链复杂度随之而来。

Topcoat 选择第三条路。tokio-rs 团队给出的范式是:不要复制。$() 表达式本身就是 Rust——服务端跑一次,翻译成 JS 再在浏览器跑一次。shard 标记的组件则在需要时回到服务端重渲染。三种执行模型共用同一份类型,不需要客户端构建步骤。

这不是渐进式改良,而是范式上的重新分工:服务端永远是真相之源,客户端只做"反应"。

二、$() 表达式:一份 Rust,两端执行

Topcoat 的核心抽象是 $(...) 表达式。它的形式非常克制:

view! {
    signal open = false;

    <button @click=$(|_e| open.set(!open.get()))>"What is Topcoat?"</button>
    <p :hidden=$(!open.get())>"A fullstack Rust framework."</p>
}

注意 $ 包裹的部分:它不是字符串,不是 DSL,是真实的 Rust 代码——带类型检查、带借用规则、带生命周期。Topcoat 编译器对它做了一件巧妙的事:双编译。

2.1 服务端执行:初次渲染的真相之源

当请求抵达 /,服务端调用 home() 函数,view! 宏展开成一棵 DOM 节点树。在展开过程中,遇到 $() 包裹的子表达式,编译器真的执行它:读取当前 open.get() 的值(false),生成 :hidden 属性;为 <button> 注入 onclick 处理器占位(初始 HTML 不需要这个值,因为客户端会接管)。

这一步输出的是普通的 HTML 字符串,所有标记都是服务端渲染的产物——搜索引擎、辅助技术、关闭 JS 的爬虫,看到的和最终用户看到的一致。

2.2 客户端执行:同源代码的 JS 翻译

但 $() 表达式不只是被求值——它还以某种形式被翻译成 JavaScript。Topcoat 的策略是:让这段 Rust 代码本身就是合法的 JS 子集。!open.get()、open.set(!open.get())、e.target.value 这种表达式,逐字符替换 set/get 方法调用为属性访问后,就是原生 JS:

// 翻译后的伪代码(示意)
let open = false;

button.addEventListener('click', (e) => { open = !open; render(); });
p.hidden = !open;

这段 JS 在浏览器里运行,直接操控 DOM,完全不需要 wasm,不需要 npm install,不需要 webpack/vite。Topcoat 团队把这种思路称作 “no client build step”——不是省略步骤,而是步骤压根不存在。

需要说清边界:官方文档明确,“runtime expression 必须是类型检查过的 Rust,但它会被编译两次”,并且只支持一个很小的类型与方法词汇表。$(...) 是 expr! 宏的语法糖,那个宏记录了支持哪些语法、捕获变量如何取用,以及一个叫 raw! 的逃生舱——实在表达不了时,允许手写一段 JavaScript 混进去。所以"一份 Rust,两端执行"在现阶段要打个折扣:两端都认的是一份受约束的 Rust。

2.3 类型的统一性

双编译最大的好处不是省事,是类型不可能不一致。传统方案里,“服务端 schema” 和 “客户端类型” 总有一天会漂移——后端改了字段名,前端忘了同步,运行时报错。Topcoat 里没有"两份类型",因为 $() 表达式只写了一次,编译期 Rust 类型检查同时覆盖了两端。

更微妙的是:signal(信号)这种状态原语也是跨端统一的。signal open = false 的初始值是一个普通 Rust 表达式,在服务端渲染时求值一次并序列化进页面;浏览器加载后把它作为响应式状态接住。open.get() 和 open.set(v) 是同一套 API,只不过前者跑在服务端 tokio runtime,后者跑在浏览器 V8。

三、shard:跨越"什么时候该回服务端"的边界

$() 表达式解决了"小粒度交互"——开关切换、输入框实时校验、本地状态变化。但有一类交互天然需要服务端:搜索、查询数据库、调外部 API。Topcoat 用 #[shard] 注解标记这类组件,它的语义是:当它的 $() 参数变化时,在服务端重渲染并原地替换 HTML。

#[component]
async fn search() -> Result {
    view! {
        signal query = String::new();

        <input @input=$(|e: Event| query.set(e.target.value))>

        search_results(query: $(query.get()))
    }
}

#[shard]
async fn search_results(cx: &Cx, query: String) -> Result {
    view! {
        <ul>
            for product in search_products(cx, &query).await? {
                <li>(product.name)</li>
            }
        </ul>
    }
}

3.1 触发模型:细粒度脏标记

search_results 上的 #[shard] 不只是装饰,它把组件注册成可分片的响应单元。当 query.get() 在客户端变化,Topcoat 的运行时检测到这个 search_results 的某个 $() 参数变了,自动发起一个局部请求——只请求这个组件的新 HTML,而不是整个页面。

服务端收到这个"局部请求",重新执行 search_results(query: ...),view! 宏展开出新的 <ul> HTML 片段,直接流回浏览器。客户端拿到片段,原地替换对应的 DOM 节点。无需 SPA、无需客户端路由、无需状态管理库。

这种模型在 HTMX 时代被称为 “HTML over the wire”——但 Topcoat 的实现更彻底:shard 组件的"重渲染"是真实的 Rust async 函数重新执行,await 数据库调用,带回类型安全的结果。没有 HTML 属性字符串拼接的脆弱性,没有手写 endpoint 的负担。

顺带一提,#[shard] 不是唯一的"回服务端"通道。runtime 还有一套 procedure(过程) 机制:注册一个服务端函数,浏览器用与调用 $() 表达式相同的方式调用它,拿回 JSON。区别在于,shard 返回的是"一段要替换进 DOM 的 HTML",procedure 返回的是"一份要交给 JS 处理的数据"。前者适合"整块视图刷新",后者适合"取完数据自己拼交互"。discover() 会把这两类端点一并注册进路由。

3.2 为什么不用 wasm?

shard 机制的存在本身就是一个宣言:Topcoat 认为,绝大多数客户端交互不值得打包 wasm。点击展开/收起?本地状态,JS 翻译够用。表单提交?服务端处理,shard 重渲染更简单。富文本编辑器?这才需要 wasm,Topcoat 不替你做这个决定——你可以在需要时引入 wasm 组件,但默认路径是 zero-JS-runtime。

这种克制是有数据支撑的。一个 wasm runtime(哪怕是只读 wasmGC 子集)动辄 50-200KB,gzip 后也至少 20KB。对于工具类应用、内部管理系统,这份开销并不换来显著体验提升。Topcoat 的取舍是:把 wasm 留给真正需要它的场景,把剩下的事用最朴素的方式解决。

四、view! 宏:HTML 即 Rust

view! 宏是 Topcoat 表达能力的载体。它的设计哲学只有一条:让 HTML 看起来像 HTML,让 Rust 控制流看起来像 Rust。

view! {
    <nav>
        for item in nav_items {
            <a
                href=(item.url)
                if item.url == current_path {
                    aria-current="page"
                    class="active"
                }
            >
                (item.label)
            </a>
        }
    </nav>
}

几个细节值得注意:

  • for item in ... 不是字符串拼接,是 Rust 的迭代器语法。编译器会展开成 nav_items.iter().map(|item| { ... }).collect::<Vec<_>>()。
  • if condition { ... } 是 Rust 块表达式,根据条件返回不同的属性集合或子节点。
  • (item.url) 圆括号包裹的 Rust 值——这是 view! 的"插值语法",编译器把它识别为 Rust 表达式插入到对应位置。
  • topcoat fmt CLI 命令能格式化 view! 代码块(以及其它宏),避免缩进混乱。

这种设计避免了"另造一套模板语言"的陷阱。Leptos 的 view! 已经很接近,但 Topcoat 更进一步:它让 $() 表达式成为一等公民,把"局部 JS 行为"用同一种语法表达。模板、组件、状态、副作用——同一棵语法树,同一套类型。

五、模块化路由:从文件系统推断 URL 树

路由是另一个常被过度工程化的领域。Topcoat 的 discover() 方法直接扫描模块结构:

src/
|-- app.rs              -> /            (and the root <html> layout)
`-- app/
    |-- about.rs        -> /about
    |-- _marketing.rs                  (layout, no URL segment)
    |-- _marketing/
    |   `-- pricing.rs  -> /pricing
    |-- posts.rs        -> /posts
    |-- posts/
    |   `-- id.rs       -> /posts/{post_id}
    `-- api/
        `-- health.rs   -> GET /api/health

约定很简洁:

  • app.rs 是根路由 /,同时也承担根 <html> 布局。
  • 下划线前缀的模块(_marketing)是布局文件,不产生 URL 段,但其下的子模块继承它的布局上下文。
  • 嵌套目录 自动映射为路径段;id.rs 会被识别为 {post_id} 参数(基于 #[page] 注解的参数名)。
  • API 路由用 #[api] 注解区分 HTTP 方法与返回 JSON,而不渲染 HTML。

这种"约定优于配置"的设计并不新鲜(Next.js、Nuxt 都是先驱),但 Topcoat 的关键差别是:路由推断在编译期完成。discover() 不是运行时反射,宏展开时已经知道整个路由树,IDE 跳转、链接静态检查、错误处理都建立在这棵已知树上。

六、资产与样式:Tailwind 的零配置集成

Topcoat 把"前端构建"里最常见的两个需求也内化了:

6.1 asset! 宏:编译期资产扫描

const FERRIS: Asset = asset!("./ferris.png");

view! { <img src=(FERRIS)> }

asset! 不只是路径字面量——编译时,Topcoat 的 bundler 扫描编译后的二进制,把 ./ferris.png 复制(甚至下载)到本地资产目录,并生成稳定的哈希文件名,自动配置浏览器缓存策略。整个过程不需要你手写 dist/ 目录管理。

6.2 内置 Tailwind

启用 tailwind feature 后,只需一行:

view! { <link rel="stylesheet" href=(topcoat::tailwind::stylesheet!())> }

Tailwind 的 CLI 在后台跑,样式表按需生成。Topcoat UI 组件库(灵感来自 shadcn/ui)通过 topcoat ui CLI 复制到项目里——不是 npm 包,是真正的源代码,你可以自由改设计:

card(
    card_header(
        card_title("Delete workspace")
        card_description("This permanently removes the workspace and all of its data.")
    )
    card_footer(
        attrs: attributes! { class="justify-end" },
        button(variant: ButtonVariant::Ghost, "Cancel")
        button(variant: ButtonVariant::Destructive, "Delete workspace")
    )
)

这些组件函数返回的就是 view! 节点——可读、可改、可继承,不是被封装在某个 framework 黑盒里的"组件实例"。

七、适用场景与早期阶段警告

需要诚实标注的是:Topcoat 仍是早期实验项目,官方明确写了"Expect breaking changes"。crates.io 上版本号已迭代到 0.8.x,说明 API 仍在快速变动;官方文档把 runtime 描述为"高度实验性,目前相当受限",表达式的词汇表很小,很多模式还没有顺手的写法。tokio-rs 团队把它放在 GitHub 上,以收集反馈为主,而不是生产就绪的稳定版本。

但作为"范式探索",Topcoat 提供了一个非常重要的参照:全栈框架不一定需要复制代码。当 $() 表达式能够无缝跨越服务端和客户端,当 shard 机制把"何时回服务端"标准化成编译器可分析的边界,“双份类型"问题就不再是 Web 开发必须接受的代价。

对于想跟进的开发者,值得观察的几个关键点:

  1. $() 表达式能覆盖多复杂的 Rust 子集? 闭包、模式匹配、trait 方法——这些都能一一对应翻译吗?目前的展示样本偏向简单表达式;raw! 逃生舱的存在说明边界还很近。
  2. shard 重渲染的请求合并与缓存——高频输入(如搜索框)如何避免请求风暴?是否有 dedup/debounce 策略?
  3. 状态共享边界——signal 的初始值由服务端求值并序列化进页面,但跨 shard 之间的共享状态如何协调?目前官方文档还没给出完整答案。
  4. 与 tokio 生态的深度集成——既然出自 tokio-rs,async runtime、hyper、tower 这些组件的复用程度有多深?

八、小结

Topcoat 的核心贡献不是某个新算法,而是重新分配了"什么在哪跑”。服务端永远渲染所有标记(SEO、可访问性、JS-off 兼容);$() 表达式让局部交互以零构建步骤方式运行在浏览器;shard 让需要服务端的交互按需回流——三种执行模型共享同一份类型树、同一份 view! 语法、同一份 Rust 代码。

对于正在被"前端构建链复杂度"困扰的团队,Topcoat 提示了一种可能:也许我们不需要把 React 重新发明一遍,只需要让服务端渲染本身的响应性再往前走一步。

仓库本身还在剧烈演化,但设计哲学值得作为一面镜子——照一照你现在的全栈栈,有多少复杂度是"复制"带来的,而不是"问题本身"需要的。

五道自测题

  1. $() 表达式"编译两次"分别在哪两个地方执行?为什么说它避免了"双份类型"问题?
  2. $() 表达式现阶段不是完整的 Rust——它受什么约束?实在表达不了时靠什么逃生?
  3. #[shard] 组件和普通组件的区别是什么?它和 procedure 机制(返回 JSON 的服务端函数)各自适合什么场景?
  4. signal 的初始值是怎么从服务端到达浏览器的?为什么说 open.get() 在两端是"同一套 API"?
  5. discover() 路由推断的"编译期完成"和 Next.js 的"文件系统路由"本质区别是什么?

答案都在正文里,这里只给定位:一是在服务端求值生成初始 HTML、翻译成 JS 后在浏览器重跑,类型只写一次、编译期检查覆盖两端;二是只支持很小的类型与方法词汇表,raw! 是手写 JS 的逃生舱;三是 shard 在参数变化时回服务端重渲染整块 HTML 并原地替换,procedure 返回数据交给 JS 处理;四是初始值在服务端求值一次并序列化进页面,浏览器接住后作为响应式状态;五是 discover() 在宏展开期就已知整棵路由树,跳转与静态检查建立在这棵树上,而非运行时扫描。

参考资料

  • 仓库:tokio-rs/topcoat — MIT 许可,早期实验,预期破坏性变更
  • crates.io:topcoat — 版本号、依赖与 README
  • API 文档:docs.rs/topcoat — runtime 模块详述表达式、signal、shard 与 procedure
  • 官方文档:Learn Topcoat — 入门与路线图

参与讨论

使用 GitHub 登录。欢迎补充事实、异议与实践。