轨道 · 写下来的字
Leptos 全栈 SSR 博客
记录本项目从零开始到容器化部署的完整过程,包括关键的技术决策、踩过的坑和对应的解决方案。 时间跨度:2026-09-12 ~ 2026-09-13(高强度冲刺两天,共 20 次提交)。
一、项目是什么
单作者博客系统,核心诉求:
- 文章的发布、列表浏览、详情查看,外加简单的评论功能;
- SSR / SEO 优先——搜索引擎不执行 JS 也能读到完整内容;
- 技术栈锁定 Leptos 0.8 + Axum,同一份 Rust 组件代码通过 feature 双端编译:
ssrfeature 编译为 Axum 服务端(含全部数据逻辑),hydratefeature 编译为 WASM 在浏览器端水合交互。
最终形态:首页/归档(分类标签筛选)/文章详情/登录/后台管理(文章 + 模块管理)、 Markdown 渲染(带安全过滤)、评论限流、分页、明暗主题、Docker 一键部署。
二、Leptos 是什么,为什么选它
Leptos 是什么
Leptos 是一个用 Rust 写的全栈同构(isomorphic)Web 框架(本项目使用 0.8.x)。 三个关键能力叠加在一起:
- 细粒度响应式:借鉴 SolidJS 的信号(Signal)模型。状态是信号,UI 是信号的 派生——状态一变,只有依赖它的那一小块 DOM 更新,没有虚拟 DOM diff, 也没有”整个组件重跑”的心智负担。
- 一份代码,两端编译:同一份组件代码,加
ssrfeature 编译成原生服务端二进制 (Axum 跑在 tokio 上,带全部数据逻辑),加hydratefeature 编译成 WASM 在 浏览器端水合(hydration)。本项目正是这个用法:#[server]宏的 Server Functions 写起来像普通函数调用,编译期自动生成”客户端桩 + 服务端实现”, 前后端连 RPC 边界都不用手写。 - 全路由 SSR +
<Suspense>:页面在服务端渲染成完整 HTML 返回(SEO 友好), WASM 加载后在既有 DOM 上”接管”交互而不是重新渲染。配合 leptos_meta 管理<title>/ meta 标签,服务端输出即为爬虫可读的最终内容。
Leptos 的优势(在这个项目里实际兑现的)
- 类型安全贯穿全栈:从 SQL 结果到传输结构到页面组件全是 Rust 类型, server fn 的参数和返回值在两端由同一份类型定义约束,改一处编译器立刻指路。
- SEO 不需要妥协:传统 SPA 要靠预渲染或_head 插件补救 SEO;Leptos 全路由 SSR 下,服务端输出的 HTML 本身就是完整内容,爬虫零脚本也能读到正文—— 这正是博客的硬需求。
- 性能下限高:服务端是原生二进制(非 Node),客户端是 WASM + 细粒度更新, SQLite 单文件库,整套栈在低端 VPS 上也绰绰有余。
- XSS 面小是语言送的:所有用户内容默认按纯文本转义渲染,只有显式
inner_html才注入 HTML——本项目只需围绕 Markdown 渲染这一处入口做安全过滤。 - 无 JS 工具链:没有 npm/webpack 一环,样式走 SCSS 编译,端到端只剩 Rust + 一份 Playwright 测试,依赖面小、可复现性好。
为什么选 Leptos,而不是别的
当时把候选放在一起比过(完整决策在 specs/001-blog-system/research.md):
| 候选 | 放弃原因 |
|---|---|
| Next.js / Nuxt 等 JS 全栈框架 | 生态最成熟,但前后端两种语言两套类型,接口契约靠手维护;且本项目作者希望借项目深入 Rust 全栈 |
| Yew | Rust 前端最老牌,但 SSR/全栈故事弱:服务端渲染与 hydration 的同构支持远不如 Leptos 完善,做 SEO 型站点要自己补很多轮子 |
| Dioxus | 同为 Rust 全栈框架,架构更偏”桌面/多平台优先”,0.x 阶段 web 全栈 SSR 的文档与生态成熟度当时不及 Leptos |
| 传统方案(Askama/Tera 模板 + 少量 JS) | SSR 没问题,但任何交互(编辑器预览、主题切换、评论表单)都要回到手写 JS,前端逻辑脱离类型系统,双端逻辑被迫写两遍 |
| HTMX + 服务端模板 | 交互简单时很优雅,但编辑器实时预览这类细粒度交互不合口味,且同样放弃类型共享 |
选 Leptos 的本质原因是:这个项目同时要求”SSR/SEO 硬指标”和”类型安全贯穿全栈”, Leptos 是当时唯一把两者都作为一等公民的 Rust 框架。代价也很清楚——框架相对年轻, 文档与社区规模不及 JS 生态,工具链(cargo-leptos、wasm-bindgen 版本对齐)有额外 学习成本,后文的坑多数源于此。这个权衡是主动接受的。
三、开发过程时间线
第 1 天(09-12):规格 → 基础功能 → 迭代打磨
- 起步:Leptos 0.8 + Axum starter 模板作为初始提交;随后批准项目章程 (constitution v1.0.0),确立 MUST 级治理约束。
- 规格驱动:不直接写代码,先用 Spec Kit 流程产出全套规格文档
(
specs/001-blog-system/):spec(用户故事 P1–P3 + 验收场景)、plan、research (7 项技术决策)、data-model、contracts(Server Functions 契约)、quickstart (19 个手测场景)、tasks(32 项任务清单)。 - 主实现(f077b68):按任务清单一次性落地博客核心——浏览、发布、编辑、下架、 登录、评论(含内存限流器)。
- 第一个 bug 修复:未登录访问
/admin返回 500 的问题(详见踩坑 #1)。 - 小重构:抽取共享的 StatusMessage 组件(T032),并写下 AGENTS.md 把项目 上下文沉淀给后续 AI 编码代理。
- 002 迭代:分类与标签体系(taxonomy 迁移、标签规范化、归档页筛选)、 Markdown 渲染 + 编辑器实时预览、分页、导航栏显示登录用户、会话与 e2e 稳定性加固。
- 又一个真 bug:客户端路由导航后分页列表不刷新(踩坑 #2)。
- 视觉重写:main.scss 按”朱砂”视觉系统重做,配合设计目录里的截图反复比对。
第 2 天(09-13):体验打磨 → 容器化
- 明暗主题切换;会话状态只在后台页显示,前台保持访客视角纯净。
- 编辑器增加分类下拉的过滤联想。
- 评论模块可开关,后台拆分为文章管理与模块管理两块。
- Docker 容器化:多阶段构建 + docker-compose,顺带修掉一个 release 构建必挂 的编译深度问题(踩坑 #7)。
四、关键技术决策(为什么这么选)
全部决策依据沉淀在 specs/001-blog-system/research.md,摘要如下:
| 决策 | 选择 | 核心理由 |
|---|---|---|
| 存储 | SQLite + sqlx(运行时查询) | 单服务器万级文章足够;零运维、备份即复制文件。不用 query! 编译期宏,避免构建期依赖 DATABASE_URL——在 cargo-leptos 双 feature 构建下简单得多,代价用单测补偿 |
| 鉴权 | Cookie 会话 + argon2 | 单作者场景下 JWT 是过度设计;会话落库,重启不失效;HttpOnly 免去前端 token 管理与 XSS 泄露面 |
| 文章标识 | slug 路由 /:slug | 可读可分享、SEO 友好;内部保留自增 id 供后台操作 |
| 摘要 | 服务端纯文本截断 | 简单、双端一致,不需要前端参与 |
| 评论限流 | 进程内存计数器 | 单实例部署足够,不引入 Redis |
| SSR/SEO | Leptos 全路由 SSR + leptos_meta | 页面全部服务端渲染,<Title>/<Meta> 管 SEO |
| 校验 | 服务端为唯一权威 | validate_* 函数集中在 models.rs;前端提示仅为体验 |
架构上定了一条铁律:前后端唯一边界是 #[server] 宏的 Server Functions,
禁止手写 Axum REST 端点,Router 只做页面路由注册与静态文件 fallback。
五、踩过的坑与解决方案
这些坑大多已沉淀进 AGENTS.md 的”已知的坑”一节,防止再犯。
1. 未登录访问后台返回 500
- 症状:访客直接访问
/admin,服务端日志出现 500 错误。 - 原因:
require_session在未登录时返回Err(Unauthenticated),被当成 服务器错误处理。但”未登录”在博客系统里是正常业务状态,不是异常。 - 解决:改为返回
Ok(None),守卫组件对None或传输错误统一重定向到/login。契约同步更新到 server-functions.md。教训:错误类型要区分 “业务状态”和”系统故障”,否则会制造大量 500 噪音。
2. 客户端导航后分页列表不刷新
- 症状:点击分页链接切换
?page=N,URL 变了但列表和当前页码不动; 直接刷新页面又正常。 - 原因:页面初次渲染时读了一次查询参数,之后没有建立响应式订阅。SSR 输出 正确掩盖了问题,只有客户端路由导航后才暴露。
- 解决:改为响应式地读取查询参数(
home.rs/archive.rs/posts.rs), 参数一变自动触发重新加载。教训:同构框架下 SSR 正确 ≠ 交互正确,每个 “看起来静态”的数据源都要问一遍:它是否声明了响应式依赖?
3. e2e 测试在 hydration 完成前填表单,输入静默丢失
- 症状:Playwright 脚本 fill 表单后提交,信号里却是空的。
- 原因:WASM 尚未水合完成时,原生 input 事件不会进入 Leptos 信号系统, fill 的值被静默丢弃——不报错,直接丢。
- 解决:
end2end/tests/helpers.ts提供公共的gotoAndWait,所有交互前先等 hydration 完成。教训:e2e 的敌人不是报错,而是静默失败。
4. dev SSR HTML + release WASM 混用 → hydration panic
- 症状:页面能看但点击毫无反应,控制台 hydration panic。
- 原因:
cargo leptos serve运行期间执行cargo leptos build --release, 两者共用target/site,release 资源覆盖了 dev 资源,浏览器加载的 WASM 与 服务端 HTML 版本不匹配。 - 解决:构建 release 前必须先停 serve。这条已写进 AGENTS.md 的注意事项, 属于”工具链约定”而不是代码 bug,但没有文档时极易反复踩中。
5. <Title>/<Meta> 放进 Suspense 条件分支 → hydration panic
- 症状:把
<Title>写在<Suspense>的 children 里,水合直接 panic。 - 原因:Suspense 解析前后分支结构不同,meta 组件的位置在水合时不稳定。
- 解决:
<Title>/<Meta>一律放在 Suspense 外面,用响应式闭包提供内容, 让标题随数据加载更新但不随分支结构移动。
6. 双向绑定 textarea 用错绑定方式
- 症状:依赖
value属性绑定的信号回读,输入内容取不到。 - 解决:需要双向绑定的 textarea 用
prop:value,提交时以信号值为准 (EditorCore / CommentForm 的写法成为模板)。
7. release 构建必挂:rustc 递归深度超限
- 症状:dev 一切正常,
--release构建在编译后台编辑器时报错——Leptos 的view!宏生成深度嵌套类型,超过 rustc 默认递归深度。 - 解决:在
main.rs/lib.rs加#![recursion_limit = "512"]。 这是容器化当天顺带修掉的老问题:Leptos 组件树很大时,release 代码生成 对 rustc 默认参数不友好。
8. 网络环境:GitHub 直连下载二进制不稳定
- 症状:cargo-leptos 构建时尝试下载 wasm-bindgen-cli / wasm-opt,经常失败。
- 解决:crates 走 rsproxy 镜像(
~/.cargo/config.toml);Dockerfile 中 预装 wasm-bindgen-cli(版本必须与 Cargo.lock 中 wasm-bindgen 一致,当前 0.2.128)和 wasm-opt,让 cargo-leptos 永远不需要现场从 GitHub 下载。构建缓存 用 BuildKit cache mount 固定住依赖、工具链和产物三层缓存。
9. Markdown 的 XSS 边界
- 风险:文章正文需要渲染 HTML,一旦放开
inner_html就是注入入口。 - 解决:在
markdown.rs做 pulldown-cmark 事件级过滤——丢弃原始 HTML 事件、URL scheme 白名单(防javascript:链接),并配单测锁定行为。同时立下 硬规矩:正文 Markdown 是唯一允许inner_html的地方,评论、标题等一切 其他用户内容按纯文本渲染(Leptos 文本节点自动转义)。
10. 路由声明顺序敏感
- 坑:
/:slug这种参数路由如果声明在/admin等静态段之前,会吞掉静态路由。 - 解决:参数路由(ParamSegment)一律声明在所有静态段之后。
六、工程化沉淀
两天冲刺结束后,项目留下了这些”资产”,让后续维护(无论人还是 AI)成本低很多:
- 规格文档全套(spec / plan / research / data-model / contracts / quickstart / tasks),其中 contracts 与代码同进同退——改 server fn 必须同步文档;
- AGENTS.md:把双端编译检查命令、已知的坑、架构铁律写成 AI 可直接执行的指引;
- 单测:纯逻辑(校验、slug、摘要、限流、Markdown 过滤)都配了
#[cfg(test)], 修 bug 前先写复现测试; - e2e:Playwright 按用户故事组织测试文件,公共等待逻辑收口在 helpers.ts;
- Docker:多阶段构建(rust:1-bookworm 构建 + bookworm-slim 运行),最终镜像
只含二进制和站点资源;SQLite 落 named volume,
AUTH_PASSWORD走.env。
七、复盘
- 规格先行是对的:32 项任务清单 + 19 个验收场景让两天的实现几乎没有方向性 返工,最大的两次 bug(500 噪音、分页不刷新)都是实现层问题而非需求问题。
- 同构框架的心智负担集中在”双端一致性”:本项目一半以上的坑(hydration panic、e2e 静默失败、SSR 正确但交互不刷新)本质都是服务端行为与浏览器行为 不一致。对策是把这类坑显式写成检查清单,而不是指望每次都靠调试发现。
- 工具链是隐性成本大头:cargo-leptos 双端构建、wasm-bindgen 版本对齐、 镜像源、构建缓存——真正写业务的时间远少于搭环境的时间,所以环境约定必须 文档化。