技术随笔

从 Astro 到 Cloudflare:个人博客开发复盘

整理这次博客开发中遇到的真实问题,以及 Astro、D1、R2、认证、Markdown、LaTeX 和动态样式背后值得记住的技术。

约 3 分钟阅读

这个博客最初只是一个 Astro 静态页面,后来逐渐加入了在线文章管理、Markdown 与 PDF 导入、图片附件、子文件夹、注册审核、登录会话和评论区。功能越来越完整以后,它也不再只是一个“静态博客模板”,而成了一个很小但结构完整的内容系统。

这篇文章记录开发过程中真正遇到的问题,以及其中值得继续学习的技术。

一、静态生成与动态内容并不冲突

Astro 很适合以内容为中心的网站。页面主体可以在构建阶段生成 HTML,只在登录状态、评论、文章列表等需要更新的位置运行 JavaScript。

这次博客实际上同时存在两类文章:

  • 项目目录中的 Markdown,用于版本管理和静态构建;
  • Cloudflare D1 中的文章,用于在线新增、编辑与删除。

两者并不冲突,但必须明确谁是数据源。静态页面可以提供可靠的初始内容,浏览器加载后再用 API 数据更新列表。这样既保留了静态站点的简单,也获得了在线管理能力。

真正需要注意的是:如果动态数据会替换静态 HTML,那么动态生成的 DOM 必须拥有与静态内容相同的结构和样式规则。

二、Astro 局部样式与动态 DOM 的陷阱

这次最隐蔽的界面问题来自 Astro 的 scoped CSS。

Astro 会为组件中的元素增加类似 data-astro-cid-xxxx 的属性,并把样式编译成只匹配这些属性的选择器。构建阶段生成的元素没有问题,但使用 document.createElement() 创建的新元素不会自动获得这些属性。

结果就是:页面刚打开时排版正常,API 返回后文章列表被替换,样式却突然失效。

解决方法不是给每个动态元素手动复制内部属性,而是把动态区域限制在一个稳定的父容器下,再显式使用全局选择器:

:global(#latest-posts article) {
  display: grid;
  grid-template-columns: 150px minmax(0, 1fr);
}

这样规则仍然只影响 #latest-posts,但不再要求子元素拥有 Astro 的编译期标记。子文件夹和最近文章排版不一致,最终都是由这个问题引起的。

三、Cloudflare Pages、D1 与 R2 的职责

整个网站现在由三部分配合:

  • Cloudflare Pages:托管静态页面和 Pages Functions;
  • D1:保存文章、文件夹、用户、会话和评论等结构化数据;
  • R2:保存图片附件等二进制文件。

D1 适合查询“某个专栏有哪些文章”“某位用户是否已通过审核”,R2 则适合保存图片本身。数据库只记录附件的名称、类型、大小和对象键,不应把大型图片直接塞进数据库字段。

把结构化数据与文件分开以后,删除、查询和扩展都会更清晰。

四、在线管理不能只考虑编辑器

一个可用的在线 CMS 不只是一个大文本框。实际使用后很快会出现更多需求:

  • 上传 Markdown 或 PDF;
  • 从文件头部读取标题、日期、标签与专栏;
  • 上传图片并插入 Markdown;
  • 用子文件夹组织同一专栏中的文章;
  • 保存草稿;
  • 在删除附件前明确提示引用风险。

这说明内容管理的核心不是“能够输入文字”,而是降低整理和维护内容的成本。

目前采用的图片方案是把附件上传到 R2,再在 Markdown 中插入普通图片语法:

![图片说明](/media/example.png)

它比把图片编码进 Markdown 更容易缓存、替换和复用,也更接近 Obsidian 的附件管理方式。

五、密码安全还要考虑运行环境

用户系统采用“注册后等待管理员审核”的流程。密码不会明文保存,而是通过 PBKDF2、随机盐和服务端 Pepper 生成摘要;登录成功后使用随机会话令牌,并通过 HttpOnlySameSite=Strict 和 HTTPS 下的 Secure Cookie 保存。

本地开发时,PBKDF2 使用较高迭代次数可以正常运行,但部署到 Cloudflare 后出现了 500 错误。最终通过受管理员保护的诊断接口发现:Cloudflare Workers 的 Web Crypto 对 PBKDF2 迭代次数设有上限。

这件事很值得记住:

本地 API 与线上 API 名称相同,不代表所有参数范围和资源限制也完全相同。

遇到线上异常时,应当先把流程拆成“环境变量、密码派生、数据库写入”等阶段逐一验证,而不是反复猜测。

六、限流必须由数据库原子更新

注册限制要求同一 IP 一分钟最多尝试五次,并且累计不超过一百次。

如果先查询次数,再单独执行更新,并发请求可能同时读到相同值,从而绕过限制。因此计数通过 D1 的 INSERT ... ON CONFLICT DO UPDATE ... RETURNING 在一条语句中完成。

限流记录保存的是经过密钥参与哈希后的 IP,而不是直接保存原始地址。这样既能维持计数,又减少不必要的原始网络标识存储。

七、Markdown 公式需要两条渲染链路

博客同时存在静态 Markdown 和从 D1 读取的动态 Markdown,因此 LaTeX 不能只配置一次。

静态文章使用:

  • remark-math 识别数学语法;
  • rehype-katex 生成 KaTeX HTML。

动态文章使用 marked-katex-extension 扩展浏览器端的 Marked 渲染器。两边共同加载 KaTeX 样式,才能让下面两种写法保持一致:

行内公式:E=mc2E=mc^2

独立公式:

01x2dx=13\int_0^1 x^2\,dx = \frac{1}{3}

只修静态构建或只修浏览器渲染,都会导致一部分文章仍然无法显示公式。

八、部署成功不等于用户立刻看到新版本

Cloudflare Pages 部署完成后,正式域名的边缘节点有时仍会短暂返回上一版静态文件。浏览器自身也可能保留旧 CSS 或恢复旧页面状态。

因此发布后的检查应分为三层:

  1. 本地构建产物是否包含预期代码;
  2. 新部署是否成功;
  3. 正式域名实际返回的 HTML 和 CSS 是否已经更新。

必要时使用带查询参数的地址或强制刷新,但不能把缓存问题误判为代码没有生效。

九、测试要覆盖完整用户路径

单独测试某个函数远远不够。这个博客现在会自动验证一条完整流程:

  1. 注册临时用户;
  2. 确认待审核用户不能登录;
  3. 管理员审核并修改用户名;
  4. 使用新用户名登录;
  5. 创建、读取和删除评论;
  6. 停用用户并确认会话失效;
  7. 测试图片、文件夹、文章、草稿和删除;
  8. 自动清理所有测试数据。

另外,Markdown 数学公式也有独立测试,分别确认静态与动态渲染都能生成行内和块级 KaTeX。

结语

这次开发最有价值的部分,并不是最终多了多少按钮,而是逐渐看清了一个小型网站的边界:内容、文件、身份、权限、缓存和界面并不是彼此孤立的功能。

很多问题只有在真实使用中才会出现。先完成一个可以运行的版本,再根据实际问题一点点调整,往往比一开始追求完整架构更有效。

博客仍然很小,但它已经足够用来继续学习,也足够容纳接下来真正想写的内容。

Discussion

评论区

0 条评论