目录

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

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

每一种传统全栈框架——无论 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”——不是省略步骤,而是步骤压根不存在。

2.3 类型的统一性

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

更微妙的是:signal(信号)这种状态原语也是跨端统一的signal open = false 在服务端执行时创建一个初始状态,在客户端执行时创建同一个名字的 JS 变量。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 的负担。

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"。tokio-rs 团队把它放在 GitHub 上,以收集反馈为主,而不是生产就绪的稳定版本。

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

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

  1. $() 表达式能覆盖多复杂的 JS 子集? 闭包、模式匹配、trait 方法——这些都能一一对应翻译吗?目前的展示样本偏向简单表达式。
  2. shard 重渲染的请求合并与缓存——高频输入(如搜索框)如何避免请求风暴?是否有 dedup/debounce 策略?
  3. 状态共享边界——signal 在客户端是 JS 变量,在服务端是什么?跨 shard 共享状态如何协调?
  4. 与 tokio 生态的深度集成——既然出自 tokio-rs,async runtime、hyper、tower 这些组件的复用程度有多深?

八、小结

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

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

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


仓库:tokio-rs/topcoat 许可:MIT 状态:早期实验,预期破坏性变更