把个人博客搬到 Cloudflare:TanStack Start、D1 与 R2 的一次重构

把个人博客搬到 Cloudflare:TanStack Start、D1 与 R2 的一次重构

一个 Cloudflare Worker 如何同时运行 TanStack Start 和 Elysia?记录个人博客用 D1、R2 保存内容,保留段落评论,完成服务端渲染、搜索发现与同域访问统计的架构取舍。

—次点击14分钟阅读

这个博客使用一个 Cloudflare Worker 同时运行 TanStack Start 页面和 Elysia API:D1 保存文章、账号、评论与计数,R2 保存图片,KV 保留部分互动与兼容数据。下面按 2026 年 9 月 25 日的实现,说明正文、登录、搜索与统计怎样接到一起,以及为什么正式域名仍保留 Vercel 代理入口。

这几天给博客补了不少文章,顺手也把它当成了一个真正需要长期维护的产品。结果最先暴露的问题,不是页面够不够漂亮,而是一些平时很容易忽略的小事:编辑器里明明有代码,发布后却挤成了普通段落;旧文章还有评论,换了数据模型就看不见了;浏览量继承过来了,最近访客的位置却一直没有更新。

这些问题让我重新看待“迁移博客”这件事。把页面跑到一个新平台上,只完成了很小的一部分。文章、图片、登录、评论、统计和搜索引擎看到的内容,都需要重新接到同一条链路上。

个人博客搬到 Cloudflare,需要哪些组件?

旧项目以 Next.js 为入口,内容、账号和计数分别接过 Sanity、Clerk、数据库以及 Upstash Redis。它不是不能工作,只是每次改一个功能,都要先想清楚这份状态到底属于哪一层。

新版本没有把这些服务的接口照搬一遍。我更希望文章和互动数据有明确的归属,前台与后台使用同一份模型,部署时能一起验证。

左右滑动查看完整表格

部分

当前实现

负责的事情

页面

React + TanStack Start

公开页面的服务端渲染、路由切换、后台界面

API

Elysia

内容读写、评论、订阅、媒体和统计接口

数据库

Cloudflare D1 + Drizzle

文章、正文块、账号、评论、邮件记录和计数

文件

Cloudflare R2

文章封面、配图和上传文件

KV

Cloudflare KV

已迁移的互动数据与兼容读取

登录

Better Auth

邮箱验证码、会话与管理员身份

搜索与分析

sitemap、结构化数据、GA4

搜索发现、页面摘要、访问行为分析

这里有一个需要如实交代的细节:正式域名目前仍保留 Vercel 的反向代理入口,请求再转到 Cloudflare Worker。 应用代码和数据服务在 Cloudflare,不等于整个访问链路已经完全移除了 Vercel。我保留这个过渡层,是为了让现有域名入口继续工作;它也带来了后面会讲到的域名跳转和访客定位问题。

正式域名、单个 Worker、页面渲染、API 与 D1 R2 KV 的职责关系
正式域名、单个 Worker、页面渲染、API 与 D1 R2 KV 的职责关系

一个 Worker 怎样同时处理页面与 API?

页面与 API 可以在同一个 Worker 内按路径分流,并共享 D1、R2 和 KV 绑定。项目使用 Bun workspace 组织网页、服务端与公共代码,部署时仍作为一个应用版本发布。

代码
apps/
  web/       TanStack Start 页面和后台
  server/    Worker 入口与 Elysia API
packages/
  auth/      Better Auth 服务端与客户端
  db/        Drizzle 模型和数据库迁移
  editor/    正文块与稳定 ID 的处理
  shared/    公共类型、校验与邮件模板
  ui/        共享界面组件

请求进入 Worker 后,先按路径分流。/api/auth 交给 Better Auth;其他 /api/ 请求进入 Elysia;sitemap、RSS、robots 和历史跳转由公开路由处理;静态文件走资源绑定;剩下的页面请求进入 TanStack Start 的服务端渲染。

下面是这个顺序的简化示意,省去了异常处理与资源回退:

ts
if (pathname.startsWith('/api/auth')) {
  return auth.handler(request)
}
if (pathname.startsWith('/api/')) {
  return api.handle(request)
}
const publicResponse = await handlePublicRoute(request, env)
if (publicResponse) return publicResponse
const assetResponse = await tryFetchStaticAsset(request, env)
if (assetResponse) return assetResponse
return webServer.fetch(request, env)

这样做的好处很朴素:页面与 API 共享同一组 D1、R2、KV 绑定,公开内容不需要绕到另一个域名读取,发布时也能把前后端作为一个版本验收。对应的代价是它们共用一次应用发布;一个公共模型变更,需要同时考虑编辑器、API 与渲染器。

对个人博客来说,这个复杂度比较合适。我暂时不需要独立扩缩容多个业务服务,也没有把文章发布塞进分布式队列的实际需求。

为什么正文与段落 ID 要分开保存?

正文 JSON 保存内容和格式,稳定的块 ID 保存段落身份。两者分开后,段落即使修改或移动,原来的评论与链接仍能找到它。正文还包含代码、表格、图片和行内样式,不能只按一大段字符串迁移。

D1 里有两层重要结构。imported_posts 保存文章级信息,包括标题、slug、摘要、封面、发布时间,以及兼容旧内容的 Portable Text 和当前使用的 Slate JSON。post_blocks 保存拆开的正文块、排序、纯文本和对应的块 ID。

名字里保留了 imported,是因为这套模型从历史内容迁移开始。现在后台新建的文章也使用同一套保存流程,并没有另开一份“新文章数据库”。

为什么要单独保存块?因为博客不只有整篇文章的评论,也有跟随具体段落的互动。读者评论的是某个代码示例,编辑后这条评论仍然应该指向那个示例。段落顺序可以变,身份不能随便重新生成。

编辑器里按一次回车,可能把一个段落分成两个。如果两个新节点都继承了原来的 blockId,渲染锚点和评论定位就会发生冲突。当前处理会保留第一个节点的原 ID,为新拆出的节点分配不同 ID,并统一兼容 id、blockId、blockID 这些历史字段。

这也是为什么“重新导入正文”不能只看文字有没有丢。对已有文章,我会同时核对块数量、块身份和评论引用。没有这些约束,一次看似无害的格式整理也可能让旧评论失去位置。

代码块丢了格式,应该先检查哪里?

先检查 Markdown 到正文 JSON 的转换,再检查渲染节点和 CSS。之前导入的 OpenWrt 教程把表格挤成连续文字、多行命令变成普通段落,问题就出在这条链路上;只修改背景和字号无法恢复丢掉的结构。

当前发布流程先把 Markdown 解析成结构化节点,再交给正文模型和渲染器。代码必须保留语言与换行;表格必须保留行列;链接既要有文本,也要有目标地址。渲染器最后才能正确输出 pre、code、table 和 a。

如果中间只留下了纯文本,前端再补一个灰色背景也恢复不了丢失的行列关系。反过来,即使结构保存正确,通用富文本样式覆盖了 white-space,多行命令仍然会显示错。

从原始正文到稳定正文块,再到编辑器、公开页面和评论锚点的处理流程
从原始正文到稳定正文块,再到编辑器、公开页面和评论锚点的处理流程

我现在会把内容验收分成两个动作:先确认 API 里的正文结构,再去浏览器看实际页面。手机上的长命令允许代码容器横向滚动,表格在自己的区域里滚动,整页不应该被撑宽。竖版流程图按真实比例显示,不能沿用封面图的固定横向比例。

这些细节不属于某个框架的自动福利。迁到 Workers 后它们也不会自然变好,还是需要逐条修正。

D1 与 R2 怎样分工保存文章图片?

文章图片单独进入 R2,正文保存对象对应的访问地址。当前公开读取通过 /api/media/object/... 返回对象内容、类型和 ETag;管理端上传与写入则经过登录及管理员检查。

这个分工让文章数据库保持清楚:D1 管“这篇文章引用哪张图”,R2 管“这张图的二进制内容”。修改摘要不用搬运图片,备份正文也不需要把图片编码成一大段文本塞进去。

文件路径也尽量按文章组织。例如这篇文章的配图放在 posts/blog-cloudflare-architecture/ 下。路径不是权限机制,但对排查缺图、替换资源和检查引用很有帮助。

配图发布后,我会重新下载公开地址的内容,与本地源文件对照。上传命令返回成功只说明对象写入完成;读者能否看到它,还取决于读取路由、内容类型和正文引用是否一致。

怎样让搜索引擎直接读到文章正文?

公开路由应在服务端渲染前拿到文章数据,让首次 HTML 响应就包含标题、摘要、正文和结构化数据。这轮 SEO 检查最初发现,页面要等 JavaScript 执行后才显示完整文章;修复的重点就是把这份内容前移到服务端。

修复后,公开路由的 loader 会在服务端等待文章查询。服务端通过一个明确的公开数据入口读取文章、项目和站点公开配置;它不是一个可以任意转发后台接口的代理。

每次服务端请求建立自己的 QueryClient。成功的公开查询被序列化进页面,浏览器接手时恢复这份缓存。这样,首次响应里已经有标题、摘要、正文和结构化数据,客户端也能继续使用同一套路由与查询逻辑。

这里最重要的边界是“公开”。后台设置、登录会话和管理数据不能跟着页面缓存一起暴露出去。当前序列化只包含成功的公开查询,后台与登录等页面也设置了不索引标记。

文章 SEO 信息由同一个函数生成:标题、描述、canonical、Open Graph 与 BlogPosting JSON-LD 都指向 https://blog.douni.one/<slug>。它们各写一份很容易逐渐不一致,集中生成更方便维护。

旧的 /blog/<slug> 地址则通过 308 跳转到现在的短地址。我在真实域名验收时还抓到一个问题:反向代理让 Worker 看到的请求域名是 workers.dev,直接照抄请求 origin 会把读者带到后端地址。修复是从站点配置生成公开域名,而不是把内部入口当成 canonical。

文章多起来之后,列表页也改成了每页 10 篇。分页不是先把所有文章下载下来再切数组,而是由服务端按页查询,返回总数和页码。排序在发布时间之外补上稳定的 ID,避免同一时刻发布的文章在两页之间重复或遗漏。/blog?page=2 可以直接打开,首个 HTML 就包含第二页文章,每页的 canonical 也指向自己的地址;读者从文章返回时仍能回到原来的页码和滚动位置。

sitemap 直接查询已发布文章,不再把列表页的分页限制当作整站文章上限;修改时间来自文章记录。RSS 同样使用正式文章地址。提交给 Search Console 之后,抓取和收录仍然由 Google 异步处理,不能把“提交成功”写成“全部已经收录”。

浏览量为什么放在 D1,而不是继续累加 KV?

D1 让数据库直接执行原子加一,避免多个请求对同一个旧数字分别读、改、写时相互覆盖。页脚的十几万次浏览是继承的历史页面浏览量,不是迁移后的新增访问,也不代表十几万独立用户。

旧版在页脚服务端渲染时调用 Redis 的递增操作。新版本把记录动作放到公开页面访问链路里,进入文章时同时更新站点总量和文章浏览量。后台页面、不存在的文章和未发布草稿不参与计数;常见爬虫与预取请求也会被过滤。

计数最终落在 D1 的 SQL 中。关键是让数据库做加一,而不是先从 KV 读一个数字,在应用里加一,再把它写回去。

sql
INSERT INTO settings (key, value, updated_at)
VALUES (?, '1', CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
  value = CAST(settings.value AS INTEGER) + 1,
  updated_at = CURRENT_TIMESTAMP
RETURNING value;

假设两个请求同时读到 100,再分别写入 101,就丢了一次访问。数据库里的更新避免了这种读改写覆盖;站点与文章两项计数放在同一个 D1 batch 中提交。Cloudflare 在 D1 batch 官方文档 中明确写道:“Batched statements are SQL transactions.” 也就是把这一批语句作为 SQL 事务处理,其中一项失败会中止或回滚整批。针对这个行为,测试会构造多次并发调用,并检查失败时能否一起回滚。

页面展示继续沿用旧版习惯:大数显示“万”或“亿”,悬停能看到带千位分隔符的精确数字。它只是格式变化,底层累计值没有被截断。

这套计数仍然不是专业反作弊系统。浏览器拦截请求会造成遗漏,简单的 User-Agent 过滤也识别不了所有自动访问。我把它当作站内阅读反馈,搜索渠道与访问行为则另外交给 GA4 和 Search Console 来看。

“最近访客来自哪里”,为什么要避开代理入口?

访客地区应来自直接接收访客请求的边缘节点,经过代理后读取的位置可能是机房。旧版依赖 Vercel 地理位置头,迁移时没有继续写入最近访客数据,因此新页面一直显示旧值或空白。

Cloudflare Workers 的 Request.cf 元数据 提供 country、city 等粗略网络位置。但如果请求先经过 Vercel,Worker 看到的可能是代理服务器。把它直接展示成读者位置,就会把机房当成访客。

现在浏览器只向同域 /api/page-views 发送不带登录凭据的统计请求。Cloudflare 的单独路径路由把这个接口直接交给 Worker,避开 Vercel;文章、登录和其他 API 仍走现有入口。这样既不需要浏览器依赖 workers.dev,也保留了直接请求的边缘位置。Worker 不相信客户端正文里传入的 city,地区以 request.cf 为准。

这里保存的只有城市、国家、国旗与访问时间,不保存原始 IP,也不请求浏览器的精确定位权限。未知位置不会覆盖上一次已知位置,较早的访问也不会覆盖时间更新的记录。旧客户端和开发环境没有有效位置时,页脚仍能正常显示计数。

这仍然是网络出口的大致位置。如果读者使用代理或 VPN,显示的可能是其出口地区,而不是实际居住地。它适合做一个轻量的访客小提示,不适合拿来判断个人身份。

后台不只负责输入文字

前台只展示发布结果,后台却要处理大量中间状态:草稿、正文修改、封面、发布时间、评论、订阅和邮件内容。新版本把这些入口放在同一个内容工作台里。

侧栏这次参考了 Kobra 的分组和紧凑布局:文章、项目属于内容;评论、订阅者、邮件属于读者互动;设置单独归组。桌面可以收成图标栏,手机变成抽屉,当前页面只保留一个清楚的选中状态。没有必要让每一个导航项都套一层厚边框。

这些是界面组织方式,真正的权限检查仍在服务端。隐藏一个按钮不等于保护一个接口;Better Auth 的会话和管理员角色需要在写入前核对。匿名访问者可以阅读,留言和评论按当前登录规则处理。

邮件也不是点一个按钮就绕过所有状态直接发送。当前后台可以选择文章、编辑标题和摘要、切换模板并预览;发送流程维护草稿、发送记录和逐个收件人的交付状态。写文章或更新站点本身,不会自动给所有订阅者群发邮件。

发布之后,我最关心的是哪些东西没有丢

这次架构调整没有给我一个“以后再也不用维护”的博客。相反,它让我看清了哪些部分需要在每次变更后认真检查。

文章必须保留原来的 URL 和发布时间;已有评论要同时识别历史文章 ID 与新 ID;段落评论要跟随稳定块身份;图片需要在公开地址可读;页面源代码里应该有真正的正文。统计既要继承旧数据,也要能在新访问发生后继续增长。

本地构建通过之后,还要到正式域名再看一次。代理、缓存、Cookie 和地理位置都属于线上链路的一部分,本地页面正常不能替代这一步。遇到问题时,也要区分是内容已经写入、应用已经部署,还是搜索引擎已经重新抓取;这三件事不会同时完成。

现在这个版本的目标很简单:我能在同一个后台维护内容,读者能稳定打开文章,搜索引擎能读到正文,旧数据不会因为一次改版被悄悄丢掉。至于后续要不要完全移除代理入口、给图片增加更细的缓存策略、把统计做得更完整,可以沿着真实问题继续推进,不必在第一版就把所有平台能力用一遍。

相关实现与资料

如果想看相近的 Cloudflare 实践,也可以继续读 Open Cookie 的记账与同步设计、短剧工作台的 Cloudflare 架构 和 Fluxship 的部署迁移记录。它们解决的业务不同,我在这个博客里保留的是适合内容网站的那一部分。