把本站的文档页用 topcoat 写会怎样
主线位置:三条主线落到一个真实页面上。本站(rustineverything)是 Dioxus fullstack,没有用 topcoat;本页是一次纸面对照,代码片段在实验 crate 里编译、跑过,但没有改本站的任何代码。站点源码链接指向提交
ceffc19。
flowchart TB
subgraph now["本站现在:Dioxus"]
r1["Route::DocPage { path: Vec<String> }"] --> c1["DocPage:两个 SuspenseBoundary"]
c1 --> f1["DocTree:use_server_future(list_doc_tree)"]
c1 --> f2["DocPageInner:use_server_future(get_doc_content)"]
f1 --> h1["HTML + 水合数据,wasm 接管"]
f2 --> h1
end
subgraph tc["topcoat 写法"]
r2["docs/doc_path.rs:module_param!(pub *doc_path)"] --> p2["#[page] doc:直接读文件"]
l2["#[layout]:suspense(sidebar())"] --> m2["#[memoize] doc_tree(cx)"]
p2 --> h2["HTML,没有水合数据,可以不加载任何框架 JS"]
l2 --> h2
end
选哪一页:/docs/:..path
就是你正在读的这种页面。本站的路由是 Route 枚举里的一项(routes/mod.rs):
#[route("/docs")]
Docs {},
#[route("/docs/:..path")]
DocPage { path: Vec<String> },
组件 DocPage 把侧栏 DocTree 和正文 DocPageInner 各放进一个 SuspenseBoundary,两者都用 use_server_future 调 server function(list_doc_tree、get_doc_content)。服务端渲染时直接调函数体,值进 HTML,也进水合数据;浏览器加载 wasm 后从水合数据取值、接管页面,侧栏里的 Link 换页时只重取正文(本站的页面怎么取数据)。
topcoat 写法
模块树(每个文件都在上一级 mod 声明):
src/
main.rs module_router!();#[layout] shell;#[memoize] doc_tree
docs.rs #[page] index → /docs
docs/doc_path.rs module_param!(pub *doc_path);#[page] doc → /docs/{*doc_path}
Route 枚举里的 /docs/:..path 对应 module_param!(pub *doc_path):catch-all 至少匹配一段,/docs 本身由 docs.rs 里的页面负责,和本站的两个变体一一对应(模块路由与发现)。页面(依赖:topcoat =0.10.0 的 full、tokio =1.54.1;正文用 <pre> 代替 Markdown 渲染,省掉渲染库):
module_param!(pub *doc_path);
#[page]
async fn doc(cx: &Cx) -> Result<impl View> {
let parts: Vec<&str> = path_param::<DocPath>(cx).collect();
if parts.iter().any(|p| p.is_empty() || *p == "." || *p == ".." || p.contains('/')) {
Err(not_found())?;
}
let file = crate::docs_root().join(parts.join("/")).join("index.md");
let Ok(markdown) = tokio::fs::read_to_string(&file).await else {
Err(not_found())?
};
Ok(view! { <article><pre>(markdown)</pre></article> })
}
侧栏放在 layout 里,目录扫描用 #[memoize] 让 layout 和页面在同一个请求里共用一份:
#[memoize]
pub fn doc_tree(cx: &Cx) -> Vec<String> { /* 扫描 docs 目录 */ }
#[component]
async fn sidebar(cx: &Cx) -> Result<impl View> {
let tree = doc_tree(cx).clone();
Ok(view! { <nav><ul>for name in tree { <li><a href=(format!("/docs/{name}"))>(name.clone())</a></li> }</ul></nav> })
}
#[layout]
async fn shell(slot: Slot<'_>) -> Result<impl View> {
Ok(view! {
<!DOCTYPE html>
<html><body>
suspense(fallback: view! { <nav></nav> }, sidebar())
<main>(slot)</main>
</body></html>
})
}
拿本卷所在的内容目录跑一遍:
/docs 200 1264 字节
/docs/topcoat 200 6937 字节(content-length,没有分块)
/docs/topcoat/nope 404 not found
/docs/topcoat/11-pitfalls/ 404 (尾斜杠产生一个空段,被路径检查拒掉)
/docs 的 layout 和页面都调了 doc_tree,函数体每个请求只跑一次。/docs/topcoat 没有分块:侧栏组件里没有真正的等待,suspense 在第一次 poll 就完成了,整页一次发出;扫描变慢时它才会先发 fallback。
和本站对照,差别在这几处:
- 数据只出现一次。 本站文档页的水合数据里装着整棵目录树(dioxus 卷测过:561 个节点,其中三分之二是侧栏用不到的描述),在 HTML 里又渲染一遍;topcoat 版只有渲染后的 HTML。侧栏本身的 HTML 体积不变,要瘦身还是得只渲染当前卷的子树。
- 没有 server function 这一层。 页面里直接
await读文件,也就没有/api/docs/tree、/api/docs/content两个公开端点;路径校验写在页面里(本站在get_doc_content里用safe_join_under做同样的事)。 - 换页是整页导航。 本站水合以后,点侧栏只重取正文、侧栏不闪。topcoat 版用普通
<a>是整页刷新;换成topcoat::runtime::link(...)才是客户端导航,浏览器取目的页的 NDJSON 帧、morph 进当前页(本卷没有实测link的浏览器行为)。 - 流式。 本站
StreamingMode::Disabled,侧栏和正文都等完才发;topcoat 默认流式,慢的部分可以先发 fallback。
mermaid、Prism 与 CSP
本站的 HeadAssets 用 document::Script 引 Prism 的脚本,再放一段引导脚本 GLOBAL_REHIGHLIGHT_BOOT_SCRIPT,靠 MutationObserver 盯着整个文档处理新出现的代码块和 mermaid 块。topcoat 里同样的东西:Prism 和 mermaid 的文件用 asset! 打包,view! 里写普通的 <script src=(...)>;引导脚本可以原样保留,link 导航 morph DOM 时它照样能看到变化。内联脚本要注意 04 章说过的转义:view! 里的 <script> 文本会被转义,=> 之类的写法要放进单独的 JS 文件。
CSP 是更硬的约束。本站的 csp_for 是 script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval',没有 'unsafe-eval',并且有测试挡住 document::eval。topcoat 运行时用 new Function 编译 $(...)(按源码,CSP 需要 'unsafe-eval',本卷未实测)。所以:
- 文档页本身不需要
$(...),不放script(),CSP 不用动。 - 一旦某一页要用
$(...)、shard 或link,就要给这一页放开'unsafe-eval',这和本站现在的安全取向相反。
评论区:交互的样例
本站的 CommentBox 在水合后用 use_resource 调 get_comments,提交调 post_comment(函数里 require_session_verified),Markdown 预览由 wasm 里的 Markdown 组件在本地渲染。topcoat 有三种写法,按需要的 JS 从少到多:
| 写法 | 列表 | 提交 | 预览 |
|---|---|---|---|
| 纯 HTML | 页面里直接 await 查库渲染 | <form method="post"> 到一个 #[route(POST ...)],校验会话、写库、see_other 回原页 | 没有,或提交到一个预览路由 |
| 运行时 | #[shard],参数是文章 id | #[procedure] 写库,成功后改一个 signal 让 shard 重渲染 | 一个以输入内容为参数的 shard,每次变化回服务端渲染一次 |
| htmx | 普通路由返回片段 | hx-post,返回新的列表片段 | hx-post 到预览路由,hx-trigger 加防抖 |
无论哪种,鉴权都写在被请求的那个函数里:shard、procedure、POST 路由各自调一次会话检查(常见坑:页面要登录,shard 却谁都能请求)。这一点和本站在每个 server function 里调 require_session_verified 是同一个做法。评论列表首屏进 HTML 这件事,topcoat 的纯 HTML 写法天然做到;本站现在评论在水合后才取。
中间件栈
本站在 main.rs 里依次加了五层(main.rs 起,后加的在外层;逐层分析见 本站怎么起服务)。对照 topcoat 0.10.0:
| 本站 | 作用 | topcoat |
|---|---|---|
body_limit_mw | /api/upload 8 MiB,其余 2 MiB;声明长度超限回 413 | 内置 BodyLimit,默认 2 MiB,BodyLimit::max(n).at("/upload") 按路径放宽;读体时检查 |
rate_limit_mw | /api/* 按 IP 限流,敏感路径更严 | 没有内置(README Roadmap 里列着),写一个 #[layer] 或经 TowerLayer 套 tower 的限流;取客户端 IP 用 client_ip(cx),前面有代理要配 TrustedProxies |
trailing_slash_mw | GET / HEAD 的尾斜杠和旧博客地址 308 | TrailingSlash::Redirect 是默认值,308;旧博客地址的跳转仍要自己写 |
CompressionLayer | br / gzip | 内置,默认开,跳过 SSE 和图片 |
security_headers_mw | CSP、nosniff、Referrer-Policy、X-Frame-Options | 没有内置,写一个没有路径的 layer 加头 |
只有登出路由手写了 Sec-Fetch-Site 检查 | 跨站登出 403 | OriginPolicy 默认对所有写请求生效 |
请求体上限那一行有一个实测得出的差别。本站的 body_limit_mw 看的是 Content-Length,dioxus 卷测过:不带长度的分块请求绕过它,在 dioxus 读 body 的 unwrap 上 panic,得到 500。topcoat 的 BodyLimit 在提取器读体时检查,同样的情形:
### POST /upload --data 'hello' (.at("/upload"),上限 16 字节)
got 5 bytes
### POST /upload -H 'Transfer-Encoding: chunked',23 字节
HTTP/1.1 413 Payload Too Large
分块请求同样得到 413。
登录
| 本站 | topcoat | |
|---|---|---|
| 流程 | OAuth + PKCE:PKCE 参数加密放进 oauth_pkce cookie,回调里校验后签发 JWT(auth_routes.rs) | 回调路由照样自己写;topcoat-session 只管 token |
| 会话 cookie | session=<JWT>; HttpOnly; Path=/; Max-Age=604800; SameSite=Lax,Secure 看部署地址 | __Host-session,强制 Secure、HttpOnly、SameSite=Lax、Path=/,默认 30 天 |
| 服务端存什么 | 不存会话;用户表里有 token_version | 存 token 的 SHA-256 哈希和过期时间,存储自己实现 |
| 撤销 | 写操作 require_session_verified 回查 token_version;读路径不查库,旧 JWT 在过期前仍能读 | 删掉那一行哈希即失效,每个请求都要查一次存储 |
| 扩容 | 无状态,任何实例都能验签 | 各实例共享同一个会话存储 |
两种方式各有代价:JWT 省掉每请求一次查询,撤销要额外的版本号;opaque token 撤销简单,代价是每个请求都要查一次存储。如果身份提供方用 form_post 回调,topcoat 那一侧要给回调路径加 exempt_paths(403 forbidden)。
结论:哪些页面形态合适
- 合适:文档、博客、案例这类内容页,首屏完整、几乎不需要 JS;评论、论坛列表这类「列表 + 表单」交互,纯 HTML 或 htmx 就能做,首屏进 HTML 对搜索引擎也更友好。
- 不合适:管理后台里大量依赖客户端状态的表格和编辑器、评论框这种按键即时预览的交互(要么每次回服务端,要么另引 JS 库)。
- 代价:放开
'unsafe-eval'才能用运行时;路由表从一个枚举变成一棵模块树;本站现有的 78 个 server function(ceffc19上 74 个#[post]、4 个#[server])和全部页面组件都要改写。
本章只做对照,不建议迁移:本站的问题(水合数据过大、公开列表不进首屏)在 Dioxus 里都有不换框架的改法(本站的页面怎么取数据的最小改动清单)。
小结
- 本站文档页 =
Route::DocPage+ 两个use_server_future;topcoat 写法是module_param!(pub *doc_path)的页面直接读文件,侧栏放在 layout 的suspense里,#[memoize]让同一请求只扫一次目录。 - topcoat 版没有水合数据、没有两个公开的 server function;换页要用
link才不整页刷新,而运行时要求 CSP 放开'unsafe-eval'。 - 中间件:请求体上限、尾斜杠、压缩、Origin 检查有内置对应,且
BodyLimit对分块请求也给 413;限流和安全头要自己写 layer。 - 登录:JWT 加版本号与 opaque token 加服务端存储,撤销和扩容的取舍相反。
- 内容页和表单交互适合 topcoat 的形态;客户端状态重的页面和要复用到桌面端的 UI 留在 Dioxus 更合适。