本站的页面怎么取数据
主线位置:首屏主线的中段,「等 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 里吗 |
|---|---|---|---|---|
/ | 200 | 222 577 | 156 700 | 案例、课程、4 篇博客在;「论坛热帖」是 spinner |
/blog | 200 | 299 028 | 139 236 | 在 |
/docs | 200 | 625 312 | 546 092 | 在 |
/docs/tokio/00-intro | 200 | 1 220 748 | 550 104 | 在 |
/embedded | 200 | 57 209 | 1 664 | 在 |
/about | 200 | 53 407 | 2 984 | 在 |
/topics | 200 | 48 066 | 200 | 不在:正文是「加载中...」 |
/blog/does-not-exist-xyz | 404 | 45 801 | 240 | 「找不到」提示,状态码 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 的客户端看到的是完整内容。
问: 服务端渲染时 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 内部的渲染器出,不经过这里。
最小改动清单
只列结论,改不改由站长决定。按收益排序:
- 文档树瘦身。 侧栏用的树去掉
description(单开一个只给/docs索引页卡片用的函数,或者只给顶层节点带描述),按上面的统计,文档页的水合数据能少三分之二左右;再进一步只发当前卷的子树,HTML 里的侧栏也跟着变小。 - 首页博客栏只取 4 篇。 给
list_blog_posts加一个上限参数,或新开一个「最新 N 篇」的 server function。水合数据存的是整个返回值,组件里.take(4)省不下来。 - 论坛公开列表进首屏。
/topics、按标签的列表、首页论坛热帖从use_resource换成use_server_future(...)?,外面包SuspenseBoundary。这几处不读 cookie,没有缓存上的顾虑。 - 主题 CSS 进首屏(可选)。 根组件不想挂起,可以把
style拆进一个子组件,子组件里use_server_future,外面单独包一个 fallback 为空的SuspenseBoundary:挂起只影响这一个style。这一条本页没有在本站上验证。 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 自己转义了<。