Rust in Everything
基于 dioxus 0.7.10

本站的页面怎么取数据

主线位置:首屏主线的中段,「等 suspense → 带水合标记渲染 → 水合数据」。页面里的 use_server_future 在服务端跑完,值同时进 HTML 和水合数据;use_resource 服务端不等,水合以后才走调用主线发 POST。本页数本站两种写法各有多少、分别用在哪,再用 curl 看首屏里到底有什么。

flowchart TB
  req["GET 页面"] --> vdom["每请求一个 VirtualDom"]
  vdom --> usf["use_server_future 23 处"]
  vdom --> ur["use_resource 36 处"]
  usf --> html["值进 HTML"]
  usf --> hyd["值进水合数据"]
  ur --> none["服务端不等"]
  hyd --> reuse["水合时直接取值"]
  none --> post["水合后发 POST"]

站点源码链接都指向提交 89a61b1。下面的计数在 89a61b1 和写作时的 eefd161 上相同(两者之间的 Rust 改动不涉及这些写法)。curl 的对象是本地 dx serve 起的 eefd161 debug 构建,内容目录是写作时的私有内容仓库,所以页面大小只代表那一刻的本地内容。

三个数

在本站仓库根目录:

grep -rhoE --include='*.rs' '^\s*#\[(server|get|post|put|delete|patch)(\(|\])' crates | sed 's/.*#\[//; s/[](]$//' | sort | uniq -c
grep -rn --include='*.rs' 'use_server_future(' crates | wc -l
grep -rn --include='*.rs' 'use_resource(' crates | wc -l
grep -rn --include='*.rs' 'ServerFnError::new(e.to_string())' crates | wc -l
  72 post
   4 server
      23
      36
      85

server function 76 个:72 个 #[post("/api/...")],4 个不写路径的 #[server](看板和博客各两个),没有 #[get]。它们怎么变成路由见 axum 卷的 dioxus 生成的 Router。仓库里另有一个 #[post("/api/greet")] 写在 crates/app/assets/docs/ 的演示 Markdown 里,不是代码,不计入。

use_server_future:23 处,都带 ?

23 处全部写成 use_server_future(...)?,挂起由外层的 SuspenseBoundary 接住(有的组件自带一层,比如 PageMeta)。其中 9 处依赖路由参数,用 use_reactive!(|slug| async move { ... }) 把参数变成依赖,参数变了会重取;其余 14 处里,只有看板列表 BoardIndexList 在闭包里捕获了 props 的 board.id,但每个看板是一个独立的路由组件,换看板时整棵子树重建,不会拿旧值。没有一处在 async 块里读 signal,所以 use-resource-on-server 说的「水合后不重新请求」碰不到。

? 的作用是让组件在值没好之前挂起,服务端的渲染因此会等它(suspense 与错误);值好了以后写进 HTML,同时存进水合数据,客户端水合时直接取,不再请求(水合)。几个页面的 curl 结果:

路径状态HTML 字节水合数据(base64)首屏数据在 HTML 里吗
/200222 577156 700案例、课程、4 篇博客在;「论坛热帖」是 spinner
/blog200299 028139 236在
/docs200625 312546 092在
/docs/tokio/00-intro2001 220 748550 104在
/embedded20057 2091 664在
/about20053 4072 984在
/topics20048 066200不在:正文是「加载中...」
/blog/does-not-exist-xyz40445 801240「找不到」提示,状态码 404

表里是本地 debug 开发服务的数字。线上 release(2026-10-28 在服务器上 curl):/docs 576 228 字节,其中水合数据约 505 KB,br 压缩后 127 743 字节,首字节 0.46 秒;/docs/tokio 1 128 083 字节,br 156 780 字节,首字节 0.51 秒;首页 215 285 字节,首字节 0.09 秒。

用 use_server_future 的页面首屏都是完整的。不完整的两处(首页的论坛热帖、/topics)用的是 use_resource,下一节说。表里更显眼的是水合数据的大小。

水合数据:一份值,发两遍,再翻一倍

use_server_future 的值在页面里出现两次:一次是渲染好的 HTML,一次是水合数据。水合数据的编码见 一次页面请求:每个值先用 ciborium 编成 CBOR 字节,整个 Vec<Option<Vec<u8>>> 再由 serialized 编一次 CBOR、转 base64。serde 把 Vec<u8> 当成整数序列,CBOR 里 0–23 的整数占 1 字节,24–255 占 2 字节,所以内层的每个字节在外层大约变成 2 字节,base64 再乘 4/3。

把 curl 拿到的 HTML 里 window.initial_dioxus_hydration_data 的值解开(一个 30 行的 Python 脚本:base64 解码,按「数组里每项是 null 或整数数组」读外层 CBOR,payload 是内层字节的总和):

b_.html                          html= 222577 b64= 156700 cbor= 117524 entries= 35 payload=  58761 largest=[52150, 6557, 22]
b_blog.html                      html= 299028 b64= 139236 cbor= 104426 entries= 32 payload=  52206 largest=[52150, 22, 6]
b_docs.html                      html= 625312 b64= 546092 cbor= 409569 entries= 32 payload= 204931 largest=[204875, 22, 6]
b_docs_tokio_00-intro.html       html=1220748 b64= 550104 cbor= 412577 entries= 34 payload= 206439 largest=[205589, 778, 22]
b_topics.html                    html=  48066 b64=    200 cbor=    149 entries= 31 payload=     58 largest=[22, 8, 1]

/docs 的 20 万字节值在页面里变成 54.6 万字节,是整页的 87%。这个值是 list_doc_tree 的返回:所有卷、所有页面的目录树。每个文档页的侧栏 DocTree 也取同一棵树,所以每个文档页都带着它。再往里解一层,按 DocTreeNode 的字段统计字符串字节:

entry bytes 205589 nodes 561 top 11
  description  137787
  path         15208
  title        12729
  slug         7094

561 个节点里,description 占了三分之二,而 DocTree 只用 title、path、has_content 和 children。侧栏本身也按整棵树渲染:/docs/tokio/00-intro 去掉水合脚本还有 66.6 万字节、565 个指向 /docs/ 的链接,正文 <article> 从第 643 634 个字节才开始。br 压缩后整页是 168 358 字节(gzip 177 101)。

首页的情况相似:BlogColumn 调 list_blog_posts() 拿到全部文章,再 .take(4)。那个 52 150 字节的值是 164 篇文章的摘要(内层 CBOR 开头 98 a4,即 164 项的数组),页面上显示 4 篇。水合数据存的是 server function 的整个返回值,不是组件用到的部分。

use_resource:36 处,服务端不等

use_resource 在服务端只是起了一个任务,渲染不等它,HTML 里是它没有值时的样子(use-resource-on-server)。本站的 36 处按用途分:

用途处数例子
只对登录用户或管理员有意义17管理后台 9、我的订单与会员 2、课程进度与权益 5、我的话题 1
每个页面都有的界面状态6主题 CSS、布局、模块开关 2、主题列表、登录方式
公开内容与公开配置13论坛列表与标签 4、首页论坛热帖、评论、讨论区、案例筛选 2、播客卡片、支付方式 2、标注开关

第一类放在客户端是对的:服务端渲染时这些数据要读 cookie,烘进 HTML 会和公共缓存冲突。App 里取当前用户那段注释写的就是这个理由。

第二类决定了每个页面首屏的样子,后果分三种:

  • 主题 CSS。 theme_css 用 use_resource 调 get_aggregated_theme_css,结果放进 style { id: "wasm-theme-style" }。curl 过的 17 个 HTML 页面(上表之外还有 /case、/podcast、/course 和几个详情页)里都没有 wasm-theme-style:首屏先按 main.css 和 Tailwind 的样式画,wasm 加载、请求回来之后主题样式才出现。注释里写了「首屏 FOUC 可接受」。
  • 布局和模块开关。 Navbar 在值回来之前按 classic 渲染,use_enabled_modules 按「编译进来的模块全开」渲染。site.json 里换了布局或关了某个模块,服务端给的 HTML 仍是经典布局、全部模块的导航,水合后才变过来。
  • ModuleGate 不是访问控制。 它在 is_module_enabled 返回 false 之前一直渲染 children。按代码,被关掉的模块服务端照样渲染整页内容、回 200(本地没有关掉的模块,没有实测),浏览器里水合后才换成「模块已停用」;爬虫和不跑 JS 的客户端看到的是完整内容。
📝NOTE
没有蠢问题

问: 服务端渲染时 use_resource 没有值,客户端过一会儿有了值,两边渲染的东西不一样,为什么不会水合错位?

答: 水合比的是客户端第一帧和服务端的 HTML。客户端水合时 use_resource 刚起任务,同样没有值,第一帧和服务端一样是「没有值」的那个分支;值回来以后是一次普通的更新,走 diff,不走水合(水合)。use_enabled_modules 的注释「server fn 返回前按编译进来的全集渲染,SSR 与 hydration 首帧一致」说的就是这个。会错位的是两边调用顺序不一致,不是值不一致(hydration-mismatch)。

第二类还意味着请求数。按代码,accounts 构建里一个包在 ModuleGate 里的页面(比如 /blog)水合以后至少发 7 个 POST:主题 CSS、当前用户、布局、启用的模块、主题列表(ThemePicker)、登录方式(AuthModal,弹窗没打开也会取)、is_module_enabled。博客和文档详情页还有评论、讨论区、标注开关。它们都走 /api/*,都扣 rate_limit_mw 的令牌(本站怎么起服务)。

第三类里,/topics 和首页的论坛热帖是公开页面的主体内容,首屏是「加载中...」和 spinner。论坛是要登录才能发帖的模块,但读是公开的;如果希望这些内容被搜索引擎收录,它们该换成 use_server_future。

状态码:NotFound 和 LoadFailed

内容不存在时,页面渲染 widgets 的 NotFound,读失败渲染 LoadFailed。两个组件在服务端调 FullstackContext::commit_http_status,分别是 404 和 500,共 15 处使用。上表 /blog/does-not-exist-xyz 的 404 就是它给的:整页 HTML,带站点外壳。能这样做的前提是不开乱序流式,状态码在所有 suspense 完成后才读(header-after-commit)。将来要开 StreamingMode::OutOfOrder,在首块之后才渲染出来的 NotFound 设的状态码会丢,响应退回 200。

LoadFailed 只显示调用方传进来的通用提示,不显示 server function 的错误文本,组件注释里写了原因:以前会把原始报错渲染到页面上。路由层面的 404(URL 对不上任何路由)不经过这两个组件,正文是路由匹配日志,见上一页。

页面里的 JS:不用 eval

本站的 CSP(csp_for)是 script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval',没有 'unsafe-eval'。dioxus 在 web 上的 eval 用 new Function 编译 JS(document 与 eval),会被这条 CSP 拦下;WebDocument 的 set_title 也是拼一句 document.title = ... 交给 eval,所以 document::Title 在客户端同样过不了。本站因此:

  • 用 web-sys 写了一组浏览器操作(widgets::browser),标题用自己的 PageTitle:服务端走 Document::set_title 输出 <title>,客户端在 use_effect 里直接设 document.title。
  • 加了一个测试 no_eval_based_document_apis_in_site_crates,扫描所有 crate 的源码,出现 document::eval 或 document::Title 就失败。全仓库 eval( 只出现在这个测试和注释里。

mermaid 和 Prism 怎么跑:HeadAssets 里一个 document::Script 放一段固定的引导脚本 GLOBAL_REHIGHLIGHT_BOOT_SCRIPT,靠 'unsafe-inline' 执行,用 MutationObserver 盯着整个文档,看到新的代码块或 pre.mermaid 就处理,mermaid 第一次用到时才加载。代码块和 mermaid 块的内容用 dangerous_inner_html 写入:dioxus 不跟踪这种节点的子树,脚本改写它们不会弄坏水合和之后的 diff。HeadAssets 是无参数组件,只挂载一次,避开了「Script {} 的 props 变了不支持」的警告。

还有两处 document::Script 输出 JSON-LD,内容是动态的。script 里的动态文本 dioxus-ssr 不转义(script-escape),本站在 ld_json_text 里把 < 写成 \u003c,标题里带 </script> 也跳不出去,有单元测试覆盖。

其余几项核对

  • 全局状态。 只有一个 GlobalSignal:课程定价用的 REGION,初值 None,只在 use_effect 里按浏览器时区写入。GlobalSignal 每个 VirtualDom 一份,服务端每个请求新建 VirtualDom,不会串请求(global-state-ssr)。其余共享状态是 5 个 use_context_provider:语言、登录弹窗、当前用户、主题版本、搜索弹窗。
  • 站内跳转。 有 11 处 a { href: "/..." } 指向站内路由(论坛 6 处、管理后台 3 处、案例 2 处),另有管理后台的 AdminNavLink 也渲染 a。它们会整页刷新(link-full-reload),App 里从 cookie 恢复语言那段 use_effect 就是为整页刷新补的。
  • 表单。 只有一个 form(审核设置),onsubmit 里同步调 prevent_default,不受 prevent-default 里 .await 之后失效的影响。
  • server function 的权限和错误。 按函数体粗扫,写操作都在函数里取会话或调 require_admin;读操作是公开的,这是有意的(server-fn-public-endpoint)。85 处 ServerFnError::new(e.to_string()) 把数据库错误的原文交给前端,sea-orm 卷的 本站怎么用 sea-orm 已经逐条看过它带出什么;dioxus 这一侧的机制见 server-fn-error-leak。
  • dioxus-ssr。 widgets 的 Cargo.toml 只在 [dev-dependencies] 里引了它,唯一的调用是测试里的 dioxus_ssr::render_element:把 Markdown 组件渲染成字符串再断言标题、id 和转义。线上的 HTML 由 dioxus-server 内部的渲染器出,不经过这里。

最小改动清单

只列结论,改不改由站长决定。按收益排序:

  1. 文档树瘦身。 侧栏用的树去掉 description(单开一个只给 /docs 索引页卡片用的函数,或者只给顶层节点带描述),按上面的统计,文档页的水合数据能少三分之二左右;再进一步只发当前卷的子树,HTML 里的侧栏也跟着变小。
  2. 首页博客栏只取 4 篇。 给 list_blog_posts 加一个上限参数,或新开一个「最新 N 篇」的 server function。水合数据存的是整个返回值,组件里 .take(4) 省不下来。
  3. 论坛公开列表进首屏。 /topics、按标签的列表、首页论坛热帖从 use_resource 换成 use_server_future(...)?,外面包 SuspenseBoundary。这几处不读 cookie,没有缓存上的顾虑。
  4. 主题 CSS 进首屏(可选)。 根组件不想挂起,可以把 style 拆进一个子组件,子组件里 use_server_future,外面单独包一个 fallback 为空的 SuspenseBoundary:挂起只影响这一个 style。这一条本页没有在本站上验证。
  5. ModuleGate 的判断搬到服务端(可选)。 要让关掉的模块在服务端就不出内容,判断要在 SSR 时做(use_server_future 读开关,关了就渲染占位并 commit_http_status)。只是不想在导航里闪一下的话,不用改。

小结

  • 76 个 server function(72 个 #[post]、4 个 #[server]),23 处 use_server_future、36 处 use_resource、85 处 ServerFnError::new(e.to_string()),命令和输出见上文。
  • use_server_future 都带 ?、参数依赖用 use_reactive!,首屏数据都在 HTML 里;代价是每个值发两遍,水合数据里的 Vec<u8> 被编成整数数组,体积约是值的 2.7 倍。
  • 文档页带着全站 561 个节点的目录树,三分之二是侧栏用不到的描述;首页为 4 篇博客发了 164 篇的摘要。
  • use_resource 里一半是登录用户的数据,放客户端是对的;主题、布局、模块开关和论坛列表不进首屏,ModuleGate 只在客户端生效。
  • 状态码靠 NotFound / LoadFailed 里的 commit_http_status,前提是不开乱序流式;CSP 不放 'unsafe-eval',本站用 web-sys 和一个测试挡住了 document::eval 与 document::Title;JSON-LD 自己转义了 <。

💬 关联讨论

发起讨论
加载中...