为什么要改
原来的博客很简单:Astro 5 + Vue 3,内容从 Notion API 拉取,构建成静态页面部署到 Cloudflare Pages。跑了一段时间后发现几个痛点:
- **Notion 作为 CMS 太重了。** 每次构建都要调 Notion API,速度慢不说,偶尔还会超时。Notion 的 Block 结构转 HTML 也是一堆边界情况。
- **没有后台管理界面。** 改个错别字都要去 Notion 改,等构建,再部署。
- **内容和代码耦合。** Markdown 文件直接放在仓库里,想加个"草稿"功能都得改构建逻辑。
- **没法做定时发布。** 静态站点没有运行时,想定时发一篇文章只能手动操作。
核心需求其实就一句话:我需要一个轻量级的、部署在 Cloudflare 上的 Headless CMS,同时保留 Astro 作为前端渲染层。
最终架构
改造后变成三个包的 Monorepo:
blog-cf/
├── apps/web/ # Astro 前端(SSG,Cloudflare Pages)
├── apps/console/ # React 管理后台(Vite SPA)
└── packages/api/ # Hono API(Cloudflare Worker)
数据流向很清晰:
- **写入方向**:Console → API → D1 数据库 / R2 存储
- **读取方向**:Astro 构建时调 API 拿文章 → 生成静态 HTML → 部署到 Pages
- **开发时**:Astro dev server 通过 Vite proxy 调本地 Worker API,实现热更新
技术选型
| 组件 | 选型 | 理由 |
|---|---|---|
| API 框架 | Hono | 专为 Edge Runtime 设计,体积小,中间件生态好 |
| 数据库 | Cloudflare D1 | SQLite 方言,和 Worker 原生集成,免费额度够用 |
| 文件存储 | Cloudflare R2 | S3 兼容,无出口流量费 |
| 管理后台 | React + Vite | 纯 SPA,构建后直接嵌入 Pages |
| 认证 | Cloudflare Access | 零信任方案,不用自己写登录逻辑 |
| 输入校验 | Zod | 后端 schema 校验,类型安全 |
整套方案全部跑在 Cloudflare 上,没有传统服务器,冷启动快,成本接近零。
数据库设计
D1 就两张核心表加一张配置表:
articles 表 — 文章内容。content_json 存编辑器的 ProseMirror 文档结构(用于回填编辑器),content_html 存渲染后的 HTML(前端直接用)。这种双存储的好处是前端不需要在运行时解析 JSON。
categories 表 — 分类元数据。之前分类的图标和描述是硬编码在前端常量里的,现在存到数据库,可以在后台直接管理。
settings 表 — Key-Value 结构的运行时配置,比如站点标题、默认作者、阅读速度、评论系统类型等。不需要重新部署就能改。
一个设计决策:为什么不用外键
文章的 categories 字段存的是 JSON 字符串数组(如 '["技术折腾","前端"]'),而不是用关联表做多对多。原因是:
- D1 的 SQLite 不支持 `JSON_EACH` 等高级 JSON 函数,关联查询会很难写
- 分类数量很少(不到 20 个),不需要关系型的那套范式
- 读多写少的场景,反范式化反而更简单
API 设计的几个要点
字段按需加载
API 不是一股脑把所有字段都返回。列表接口只返回元数据(标题、标签、字数等),content_json 和 content_html 只在编辑和详情接口才返回。这对前端性能影响很大,特别是文章多了之后。
const LIST_COLUMNS = `id, slug, title, description, cover_image,
tags, categories, author, publish_date, word_count, reading_time,
status, type, created_at, updated_at`;
配置集中读取
一开始每个需要读设置的地方都单独查一次 DB,后来改成了 loadSettings(db) 一次性把所有设置读出来缓存到一个 Map 里。一个请求内多处使用同一个 Settings 对象就行了。
export async function loadSettings(db: D1Database): Promise<Settings> {
const { results } = await db.prepare('SELECT key, value FROM settings').all();
const map = new Map();
for (const row of results) map.set(row.key, row.value);
return {
get(key, fallback) { return map.get(key) ?? fallback ?? DEFAULTS[key] ?? '' },
getNum(key, fallback) { /* ... */ },
};
}
Slug 处理
踩了一个坑:一开始 slug 校验强制小写(sanitizeSlug 里有 .toLowerCase()),导致导入旧文章时把 personal-email-on-ESL-class 变成了 personal-email-on-esl-class,URL 就对不上了。后来改成保留原始大小写,只做安全字符过滤。
管理后台
Console 是一个 React SPA,UI 风格参考了 PlayStation 的设计语言——深色背景、圆角卡片、克制的配色。没有用任何 UI 框架,全部手写 CSS。
主要功能:
- **概览面板**:文章总数、字数统计、分类分布、最近动态
- **文章管理**:列表、搜索(调 API + 300ms 防抖)、按状态筛选、新建/编辑/发布
- **分类管理**:CRUD,包括图标和描述
- **媒体库**:R2 文件浏览、上传、按月分组
- **设置**:站点信息、内容配置、评论系统、上传限制、部署触发
搜索是后端全文搜索,匹配标题、描述、标签、分类、作者五个字段。前端做了防抖,不会每按一个键就发请求。
遇到的坑
CORS 和 Cross-Origin-Resource-Policy
开发时 Console 跑在 5007 端口,API 跑在 8007 端口。编辑器里的图片加载不出来,控制台报 Cross-Origin-Resource-Policy: same-origin 错误。
这是 Hono 的 secureHeaders 中间件默认行为。一开始想直接关掉这个策略,后来想想不对——生产环境是同源的(都在 ropean.org 下),问题只出在开发环境的跨端口场景。
最终方案:用 Vite 的 proxy 把 /api 代理到 Worker,这样开发时也是同源请求,和生产环境行为一致。
// vite.config.ts
server: {
proxy: {
'/api': { target: 'http://localhost:8007' },
}
}
D1 的 wrangler dev 怪癖
用 wrangler dev 上传的文件,R2 对象的 httpMetadata.contentType 有时候会变成 application/octet-stream,即使上传时明确指定了 image/png。这导致媒体库里图片数量统计不对。
解决方法是写了一个 inferContentType 函数,如果存储的 MIME 类型不可靠,就根据文件扩展名推断:
function inferContentType(key: string, stored: string | undefined): string {
if (stored && stored !== 'application/octet-stream') return stored;
const ext = key.split('.').pop()?.toLowerCase() ?? '';
return EXT_TO_MIME[ext] ?? stored ?? 'application/octet-stream';
}
Astro 开发模式 vs 生产模式
Astro 在生产模式(SSG)下用 getStaticPaths 预生成所有文章页面,但在开发模式下是动态渲染。改了 API 的返回结构后(列表不再包含 content_html),开发模式下文章详情页变成了空白——因为 getAllArticles() 返回的列表数据没有内容字段了。
修复方式是让开发模式下的 [...slug].astro 始终单独调用 fetchArticleBySlugFromD1(slug) 获取完整数据,而不是复用列表数据。
Deploy Hook:连接 Worker 和 Pages 的关键
整个架构里最关键的胶水是 Cloudflare Pages Deploy Hook。
静态站点有个本质问题:数据库里文章改了,但 Pages 上的 HTML 还是旧的。解决办法就是 Deploy Hook——Cloudflare Pages 提供一个 Webhook URL,POST 一下就会触发重新构建。
在系统里,Deploy Hook 出现在两个地方:
1. 手动触发
Console 的设置页有一个 Deploy Hook URL 配置项。API 提供了一个专门的触发端点:
// POST /api/admin/deploy — 触发 CF Pages Deploy Hook
deploy.post('/admin/deploy', async (c) => {
if (!c.env.DEPLOY_HOOK_URL) {
return c.json({ error: 'Deploy hook not configured' }, 503);
}
const res = await fetch(c.env.DEPLOY_HOOK_URL, { method: 'POST' });
if (!res.ok) {
return c.json({ error: 'Deploy hook failed', status: res.status }, 502);
}
return c.json({ success: true, message: '部署已触发' });
});
在后台发布文章后,手动点一下"部署"就行了。Pages 构建通常 1-2 分钟完成。
2. 定时发布自动触发
Worker 注册了一个 scheduled handler(Cron Trigger,默认每 15 分钟执行一次),用来处理计划发布的文章:
async function handleScheduled(env: Env) {
const now = new Date().toISOString();
const { results } = await env.DB.prepare(
`SELECT id FROM articles
WHERE status = 'scheduled' AND scheduled_at <= ?`
).bind(now).all();
if (results.length === 0) return;
// 批量把到期文章改为 published
for (const { id } of results) {
await env.DB.prepare(
UPDATE articles SET status = 'published', ...
).bind(...).run();
}
// 有文章状态变了,触发 Pages 重新构建
if (env.DEPLOY_HOOK_URL) {
await fetch(env.DEPLOY_HOOK_URL, { method: 'POST' }).catch(() => {});
}
}
`
这样就实现了定时发布:写好文章设一个 scheduled_at 时间,到点了 Cron 自动改状态并触发构建,不需要人工干预。
为什么这个设计有效
传统做法是用 SSR,请求到了现查数据库。但 SSR 意味着 Worker 每次请求都要查 D1、渲染模板——成本和延迟都上去了。
用 Deploy Hook 的方案是:写入时触发构建,读取时走 CDN 静态文件。 写入频率很低(一天最多几篇文章),但读取频率可以很高——全部走 Cloudflare 的边缘缓存,响应速度极快,成本几乎为零。
部署流程
# 本地开发:四个服务并行启动
pnpm dev
# 部署 API(Worker) pnpm ship:api
# 部署前端(Console 构建 → 嵌入 Web → 部署 Pages)
pnpm ship
`
pnpm ship 做的事情:先构建 Console SPA,把产物复制到 Astro 的 public/console 目录,再构建 Astro,最后 wrangler pages deploy 整个 dist。这样 Console 就作为 Pages 的一个子路径(/console/)部署了,认证由 Cloudflare Access 统一处理。
DEPLOY_HOOK_URL 作为 Worker Secret 配置(wrangler secret put DEPLOY_HOOK_URL),不硬编码在代码里。Settings 表里也存了一份,方便在后台界面查看和管理。
回头看
这次改造最大的收获不是技术方案本身,而是一个认知:Cloudflare 的免费套餐已经足够撑起一个完整的全栈应用。 D1、R2、Workers、Pages、Access——全部免费额度内搞定,不需要任何传统云服务器。
如果再做一次,可能会考虑的改进:
- **编辑器**:目前用的是比较基础的 Textarea + Markdown 预览,如果投入更多可以换成 TipTap 或类似的富文本编辑器
- **版本历史**:目前文章修改是直接覆盖的,没有历史版本。可以加一个简单的版本表
- **多语言**:设置里虽然有语言选项(中文/英文),但前端还没有做 i18n 支持
- **搜索优化**:D1 的 LIKE 查询在数据量大了之后可能会慢,可以考虑接入 Cloudflare 的 Vectorize 做语义搜索
总的来说,这是一个"刚好够用"的方案。不追求大而全,但每个部分都是实际需要的。