Rust in Everything
基于 topcoat 0.10.0

topcoat 与 Dioxus fullstack

主线位置:三条主线的对照。topcoat 一侧的每一项都在前面各章讲过,Dioxus 一侧链到 dioxus 卷,本页只把两边放在一起。本站(rustineverything)用的是 Dioxus fullstack,所以 Dioxus 一侧的例子都取自本站源码,提交 ceffc19,锁的是 dioxus 0.7.9(dioxus 卷写的是 0.7.10,两者只差 CLI)。

flowchart TB
  subgraph tc["topcoat"]
    t1["async 组件在服务端渲染"] --> t2["HTML + signal 注释 + data-topcoat 属性"]
    t2 --> t3["浏览器:通用运行时 JS"]
    t3 -->|"本地重算"| t4["DOM 属性、文本"]
    t3 -->|"shard、页面重跑"| t5["服务端再渲染 HTML,morph"]
  end
  subgraph dx["Dioxus fullstack"]
    d1["组件在服务端建 VirtualDom 渲染"] --> d2["HTML + 水合标记 + 水合数据"]
    d2 --> d3["浏览器:加载 wasm"]
    d3 --> d4["客户端重建 VirtualDom,接管 DOM"]
    d4 -->|"server function"| d5["POST /api/...,返回数据"]
  end

渲染模型

topcoat:HTML 只在服务端产生。浏览器里没有组件树,只有一份通用的运行时 JS 和写在属性里的 $(...) 译文($(...) 表达式)。只读浏览器 signal 的表达式就地重算;要服务端的部分,shard 或整个页面在服务端重跑一遍,返回 HTML,由运行时 morph 进 DOM(signal、shard 与 procedure)。服务端返回的永远是 HTML,不是数据。

Dioxus fullstack:服务端为每个请求建一个 VirtualDom 渲染出 HTML,元素上带 data-node-hydration,use_server_future 的结果另外编码成水合数据放在页面里;浏览器加载 wasm,按同样的顺序再建一棵 VirtualDom,从水合数据取值、接管已有的 DOM(水合、一次页面请求)。之后的交互在 wasm 里跑完整的组件逻辑,需要服务端时调 server function,返回的是数据,由客户端重新渲染。

一句话:topcoat 把「渲染」留在服务端,把少量「响应式」放到浏览器;Dioxus 把整套组件逻辑同时放在两边。

首屏里框架带来多少字节

两边功能不同,做不了同功能对比。下面的做法是:取本站一个内容页,把它的 <body> 去掉 Dioxus 特有的部分(水合属性、节点注释、脚本),原样塞进一个 topcoat 页面,再加一个 $(...) 开关和一个 suspense,看两边各自在同一份可见内容外面加了什么。

  • 本站一侧:本地 dev server(dx serve,debug 构建),GET /case/axum-realworld,内容来自写作时的一份本地内容目录。
  • topcoat 一侧:实验 crate(topcoat =0.10.0,full),页面 = layout(<head> 里放 topcoat::runtime::script())+ 一个 signal 控制的开关 + Unescaped::new_unchecked(清理后的 body) + 一个 20 毫秒的 suspense;debug 和 release 各跑一次。
  • 都在同一台机器上(M2 Max、macOS),curl 和一个打印每次 read 的小客户端,每个数字取 5–6 次中的典型值。
本站(Dioxus,debug)topcoat 页面(同一份可见内容)
HTML 字节(不压缩)55 50435 600(不加开关和 suspense 时 33 736)
gzip / br10 915 / 11 0586 618 / 6 638
框架加在元素上的标记160 个 data-node-hydration,4 700 字节;301 个节点注释,4 026 字节4 个 data-topcoat-* 属性,503 字节;3 个注释,268 字节
页内脚本16 个 <script>,10 844 字节(含 2 444 字节 base64 水合数据)3 个,1 047 字节(运行时入口、swap 函数、一次 topcoat.swap)
首屏后要下载的框架代码app.js 135 334 字节(br 17 157)+ app_bg.wasmtopcoat-<哈希>.js 49 018 字节(br 15 644,gzip 15 426)
首字节14.5–15.0 ms,全部内容在随后 2 ms 内到齐debug 0.5 ms、release 0.2–0.3 ms 发出 34 679 字节首屏;suspense 内容在约 22 ms 追加

几点说明:

  • 本站页面里的 16 个脚本有 11 个是 Prism(核心加 10 个语言包)、1 个是标注功能,是本站自己的功能,不是 Dioxus 的开销;Dioxus 自身的是水合数据、app.js 的入口和几段引导脚本。
  • wasm 的大小这里不比:dev server 是 debug 构建,app_bg.wasm 有 170 808 245 字节(br 32 321 030),带着调试信息,不代表线上。release 构建的大小本页未测。topcoat 的运行时 JS 是随 topcoat-runtime 发布的固定文件,和应用大小无关。
  • 首字节差得多,主要因为本站页面做的事多得多:导航、页脚、多个 use_server_future、主题与模块开关,而且是 debug 构建;topcoat 页面的正文是一个常量字符串。这一行只说明两边的「形状」:本站是 StreamingMode::Disabled,等所有 suspense 完成后一次发出(本站怎么起服务);topcoat 默认流式,首屏不等 suspense。
  • 本站页面的水合数据只有 2 444 字节,因为这个页面取的数据少;文档页带着整棵目录树时,水合数据能占到整页的大半(本站的页面怎么取数据)。topcoat 没有水合数据这一项:值只以 HTML 的形式出现一次,浏览器需要的 signal 初值以注释送达。

流式

两边的流式都是「先发 fallback,后面追加内容和一段脚本」:

topcoatDioxus
默认开(SuspenseMode::Stream),Wait 关掉关(StreamingMode::Disabled),OutOfOrder 打开
追加的东西<template data-topcoat-swap> + 一句 topcoat.swap(...),swap 函数是首屏里的一段内联脚本带 hidden 的 div + dx_hydrate(...),由 wasm 搬到原位
状态码与 cookie首屏就绪时响应头已发;之后写 cookie 会 panic(常见坑)首块之后设的状态码丢失(header-after-commit)
没有 JS 时<template> 的内容按 HTML 规范不渲染,只看得到 fallback(按规范推断,本卷未在禁用 JS 的浏览器里实测)只看得到 fallback(dioxus 卷 流式渲染 实测)

topcoat 的 swap 不依赖运行时脚本:不放 script() 的页面照样能流式(一个请求从监听到 HTML)。Dioxus 的替换块要等 wasm 加载完才落位。

路由

topcoatDioxus
声明模块树:#[page] 所在模块的 module_path!() 推出 URL(模块路由与发现),也可以写显式路径一个 #[derive(Routable)] 枚举,每个变体一个 #[route(...)](Routable)
收集inventory,链接期枚举本身,编译期
链接href!(settings::profile::page):目标编译期检查,参数运行期查表Link { to: Route::X {} }:变体和字段都是编译期检查
匹配失败404,正文 not foundParseRouteError,本站正文是全部候选路由的匹配日志(本站怎么起服务)
漏掉的风险文件没有 mod 声明,路由静默缺席忘了加变体,编译器不会提醒,但 Route 枚举本身就是完整的路由表

本站的 Route 有 39 个 #[route] 变体,一张表看全站;topcoat 的路由表要到启动时才拼出来,读代码时看的是目录结构。两边都用到了 inventory:topcoat 用它收集页面,Dioxus 用它收集 server function(server function 的「登记」一节)。

服务端函数

topcoatDioxus
形式#[procedure]:在 $(...) 里 await,参数和返回值是带类型标签的 JSON;#[shard]:返回 HTML 的组件,有自己的端点#[post("/api/...")] / #[server]:任意 async 函数,客户端得到一个同名函数
端点都是公开的 POST,不经过页面和 layout 的守卫都是公开路由(server-fn-public-endpoint)
鉴权写在 shard / procedure 函数体里(常见坑)写在函数体里,本站用 require_session_verified
跨站请求OriginLayer 默认拦截框架不检查,靠 cookie 的 SameSite 和应用自己的中间件
首屏取数据组件里直接 await,不经过 HTTPuse_server_future 在服务端直接调函数体,不经过 HTTP;水合后 use_resource 的调用才走 HTTP

结论相同:两边的服务端函数都是公开端点,参数都是用户输入,鉴权都只能写在函数里。差别在默认值:topcoat 在路由最外层放了 Origin 检查,Dioxus 没有。本站只在登出路由上手写了一次 Sec-Fetch-Site 检查,其余写操作依赖 SameSite=Lax 的会话 cookie 在跨站 POST 时不被发送,以及函数体里的会话检查。

资产、会话、长连接

  • 资产:两边都是「编译器决定用到了哪些、构建工具决定放在哪里」,不需要 build.rs;差别是 URL 什么时候确定,asset! 与 bundler 末尾已经和 manganis 比过。
  • 会话:topcoat 自带 topcoat-session,32 字节随机 token、应用只存哈希、rotate 换 token(Origin 检查、cookie 与 session)。Dioxus 不带会话,本站自己签 JWT 放进 session cookie(HttpOnly、SameSite=Lax、7 天,Secure 看部署地址是不是 https),撤销靠用户表里的 token_version:写操作回查数据库,版本不符就拒绝。opaque token 每次都要查存储,撤销是删一行;JWT 读路径不查库,撤销要额外的版本号。
  • SSE / WebSocket:topcoat 的 router 内置(content/sse、content/websocket),运行时自己也用 WebSocket;本站目前没有 SSE 或 WebSocket 路由,需要时走 axum。

开发与部署

topcoatDioxus
开发循环topcoat dev:监听文件、cargo build、打包资产、重启进程,浏览器经 WebSocket 刷新;没有热补丁(tailwind、font、icon 与 topcoat CLI)dx serve:rsx 热重载,另有 Rust 代码的热补丁;debug 构建的服务端收到补丁消息时重建整张路由表(一次请求的渲染)
编译一次,服务端二进制两次:服务端二进制和 wasm 客户端,同一份代码按 feature 编两遍
产物二进制 + assets/(bundler 生成)服务端二进制 + public/(wasm、JS、资产)
前端工具链不需要;tailwind feature 在 build.rs 下载独立 CLIdx 负责 wasm-bindgen、资产处理

各自适合什么

topcoat 合适的页面:内容为主、交互是表单、列表、筛选、局部刷新的页面,比如后台、文档站、博客、电商的浏览和下单流程。数据都在服务端,组件直接查库,首屏是完整 HTML,浏览器只多一份 49 KB(br 后 16 KB)的通用运行时;想零前端构建的团队也合适。

Dioxus fullstack 合适的页面:客户端状态复杂、交互不能每次都等一个往返的页面,比如编辑器、白板、离线可用的工具;同一套 UI 还要发桌面或移动端(Dioxus 的同一份组件可以跑在桌面和移动端的 webview 里);希望前后端共享类型、客户端也写完整 Rust 逻辑的场合。本站评论框的 Markdown 预览就在 wasm 里渲染(CommentBox),换成 topcoat,这一步要么每次按键回服务端渲染一次,要么用 raw! 接一个 JS 的 Markdown 库。

两边共同的边界:服务端函数都是公开端点;流式的内容都要 JS 才能落位;topcoat 仍标着「Early-stage and experimental」,运行时更是「highly experimental」,Dioxus 0.7 也还在 0.x,大版本之间 API 变化不小。

小结

  • topcoat 浏览器里没有组件树,交互靠译成 JS 的表达式和服务端重渲染 HTML;Dioxus 浏览器加载 wasm 重建 VirtualDom 接管页面,交互在客户端跑完整组件逻辑。
  • 同一份可见内容,本站的 Dioxus 页面多出约 8.7 KB 水合标记和一段水合数据,再加 app.js 与 wasm;topcoat 页面多出不到 1 KB 的标记,加一份 49 KB 的通用运行时。wasm 的 release 大小本页未测。
  • topcoat 默认流式,Dioxus(本站)默认不流式;两边流式追加的内容都需要 JS 才能落位。
  • 路由:模块树 + inventory vs Routable 枚举;服务端函数:两边都是公开端点,topcoat 多了默认的 Origin 检查。
  • topcoat 适合内容和表单为主、想零前端构建的站点;Dioxus 适合客户端状态复杂、要跨端或要在客户端跑完整 Rust 逻辑的应用。

💬 关联讨论

发起讨论
加载中...