轨道 · 写下来的字

Leptos 全栈 SSR 博客

2026.09.13

目录
  1. 一、项目是什么
  2. 二、Leptos 是什么,为什么选它
  3. 三、开发过程时间线
  4. 四、关键技术决策(为什么这么选)
  5. 五、踩过的坑与解决方案
  6. 六、工程化沉淀
  7. 七、复盘

记录本项目从零开始到容器化部署的完整过程,包括关键的技术决策、踩过的坑和对应的解决方案。 时间跨度:2026-09-12 ~ 2026-09-13(高强度冲刺两天,共 20 次提交)。

一、项目是什么

单作者博客系统,核心诉求:

  • 文章的发布、列表浏览、详情查看,外加简单的评论功能;
  • SSR / SEO 优先——搜索引擎不执行 JS 也能读到完整内容;
  • 技术栈锁定 Leptos 0.8 + Axum,同一份 Rust 组件代码通过 feature 双端编译: ssr feature 编译为 Axum 服务端(含全部数据逻辑),hydrate feature 编译为 WASM 在浏览器端水合交互。

最终形态:首页/归档(分类标签筛选)/文章详情/登录/后台管理(文章 + 模块管理)、 Markdown 渲染(带安全过滤)、评论限流、分页、明暗主题、Docker 一键部署。

二、Leptos 是什么,为什么选它

Leptos 是什么

Leptos 是一个用 Rust 写的全栈同构(isomorphic)Web 框架(本项目使用 0.8.x)。 三个关键能力叠加在一起:

  • 细粒度响应式:借鉴 SolidJS 的信号(Signal)模型。状态是信号,UI 是信号的 派生——状态一变,只有依赖它的那一小块 DOM 更新,没有虚拟 DOM diff, 也没有”整个组件重跑”的心智负担。
  • 一份代码,两端编译:同一份组件代码,加 ssr feature 编译成原生服务端二进制 (Axum 跑在 tokio 上,带全部数据逻辑),加 hydrate feature 编译成 WASM 在 浏览器端水合(hydration)。本项目正是这个用法:#[server] 宏的 Server Functions 写起来像普通函数调用,编译期自动生成”客户端桩 + 服务端实现”, 前后端连 RPC 边界都不用手写。
  • 全路由 SSR + <Suspense>:页面在服务端渲染成完整 HTML 返回(SEO 友好), WASM 加载后在既有 DOM 上”接管”交互而不是重新渲染。配合 leptos_meta 管理 <title> / meta 标签,服务端输出即为爬虫可读的最终内容。

Leptos 的优势(在这个项目里实际兑现的)

  1. 类型安全贯穿全栈:从 SQL 结果到传输结构到页面组件全是 Rust 类型, server fn 的参数和返回值在两端由同一份类型定义约束,改一处编译器立刻指路。
  2. SEO 不需要妥协:传统 SPA 要靠预渲染或_head 插件补救 SEO;Leptos 全路由 SSR 下,服务端输出的 HTML 本身就是完整内容,爬虫零脚本也能读到正文—— 这正是博客的硬需求。
  3. 性能下限高:服务端是原生二进制(非 Node),客户端是 WASM + 细粒度更新, SQLite 单文件库,整套栈在低端 VPS 上也绰绰有余。
  4. XSS 面小是语言送的:所有用户内容默认按纯文本转义渲染,只有显式 inner_html 才注入 HTML——本项目只需围绕 Markdown 渲染这一处入口做安全过滤。
  5. 无 JS 工具链:没有 npm/webpack 一环,样式走 SCSS 编译,端到端只剩 Rust + 一份 Playwright 测试,依赖面小、可复现性好。

为什么选 Leptos,而不是别的

当时把候选放在一起比过(完整决策在 specs/001-blog-system/research.md):

候选放弃原因
Next.js / Nuxt 等 JS 全栈框架生态最成熟,但前后端两种语言两套类型,接口契约靠手维护;且本项目作者希望借项目深入 Rust 全栈
YewRust 前端最老牌,但 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):规格 → 基础功能 → 迭代打磨

  1. 起步:Leptos 0.8 + Axum starter 模板作为初始提交;随后批准项目章程 (constitution v1.0.0),确立 MUST 级治理约束。
  2. 规格驱动:不直接写代码,先用 Spec Kit 流程产出全套规格文档 (specs/001-blog-system/):spec(用户故事 P1–P3 + 验收场景)、plan、research (7 项技术决策)、data-model、contracts(Server Functions 契约)、quickstart (19 个手测场景)、tasks(32 项任务清单)。
  3. 主实现(f077b68):按任务清单一次性落地博客核心——浏览、发布、编辑、下架、 登录、评论(含内存限流器)。
  4. 第一个 bug 修复:未登录访问 /admin 返回 500 的问题(详见踩坑 #1)。
  5. 小重构:抽取共享的 StatusMessage 组件(T032),并写下 AGENTS.md 把项目 上下文沉淀给后续 AI 编码代理。
  6. 002 迭代:分类与标签体系(taxonomy 迁移、标签规范化、归档页筛选)、 Markdown 渲染 + 编辑器实时预览、分页、导航栏显示登录用户、会话与 e2e 稳定性加固。
  7. 又一个真 bug:客户端路由导航后分页列表不刷新(踩坑 #2)。
  8. 视觉重写:main.scss 按”朱砂”视觉系统重做,配合设计目录里的截图反复比对。

第 2 天(09-13):体验打磨 → 容器化

  1. 明暗主题切换;会话状态只在后台页显示,前台保持访客视角纯净。
  2. 编辑器增加分类下拉的过滤联想。
  3. 评论模块可开关,后台拆分为文章管理与模块管理两块。
  4. 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/SEOLeptos 全路由 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 版本对齐、 镜像源、构建缓存——真正写业务的时间远少于搭环境的时间,所以环境约定必须 文档化。
← 回到文章