我们的博客是怎么跑起来的
聊架构、抠性能、存文章,以及评论怎么推到我手机上让我审核
不少朋友问过我同一个问题:你这个博客到底跑在哪?是不是自己搞了台服务器?
答案可能会让你意外——没有。整站点就一个 Cloudflare Worker,从渲染、接口到后台全部塞在这一个文件里。这篇文章把它的五脏六腑拆开给你看:整体架构怎么排、性能是怎么一点点抠出来的、文章正文存在哪、评论又是怎么推到我手机上让我审核的。如果你想自己搓一个博客,这篇可以直接当教程抄。
一、整体架构:一个 Worker 扛起全部
先说清楚"没有服务器"是什么意思。传统博客(比如 WordPress)是:你访问 → 打到一台云服务器 → 服务器查数据库 → 拼出 HTML → 返回。这台机器得一直开着,流量大了还得加配置。
我这边的模型是:你访问 → 打到离你最近的 Cloudflare 边缘节点 → 节点上的 Worker 现渲染 HTML → 返回。没有常驻服务器,没有传统数据库,代码跟着请求在边缘跑。
具体来说,全站就一个 worker/index.ts,用 Hono 这个轻量框架写的,做服务端渲染(SSR)。数据落在三处 Cloudflare KV(分布式键值存储,最终一致)和一个图床上:
- POST_META:文章元数据(标题、slug、分类、标签、
mdFilePath指针)、分类表、标签表、友链、站点配置。 - COMMENT_STORE:评论,按
comments:<postId>存。 - FONT_STORE:自托管的字体子集 woff2 + 自动生成的
@font-faceCSS。 - 图床(cfbed 兼容对象存储):文章正文的 markdown 原文。
这里有个关键取舍:正文不放 KV。KV 的读写和容量都挺贵,一篇正文动辄几 KB 到几十 KB,全塞进去纯属浪费。所以 KV 里只存一个 mdFilePath 指针,真正的大段文字在图床上。渲染的时候拿着指针去图床把正文拉回来。这个后面第三节细讲。
静态资源(封面图、文内图)走 Cloudflare 边缘缓存;字体从 KV 读、同源、边缘缓存。所以无论访客在哪儿,首屏的东西基本都是边缘秒回。
二、性能:不是"开了缓存"就完事
很多人以为性能优化就是「加个缓存」,其实差的远。我这边的缓存是带脑子用的,下面挨个说。
1. 边缘缓存 + Cache-Tag 精准清理
HTML 页面返回的缓存头长这样:
Cache-Control: public, max-age=0, s-maxage=31536000, stale-if-error=86400
Cache-Tag: post:foo,list,home
拆开看:
max-age=0:浏览器这边不缓存,每次都来边缘问一下。s-maxage=31536000:但 Cloudflare 边缘缓存一年。- 效果就是——访客的请求打到边缘,命中就秒回,几乎不跑 Worker;偶尔回去重新渲染一次。
关键不在这,在于内容一改怎么让缓存立刻失效。要是傻等一年 TTL,你改了文章用户半年都看不到。所以我给每个路由算了标签(cacheTagsForPath):
if (p === "/") return "home,list";
if (p.startsWith("/post/")) return `post:${p.slice(6).split("/")[0]},list,home`;
if (p === "/archives") return "archives,list";
// ...
保存文章时,直接按标签把相关缓存清掉:
purgeCache(c, [`post:${slug}`, ...CONTENT_TAGS]);
purgeCache 底层调的是 c.executionCtx.cache.purge({ tags }),而且用 waitUntil 包着,意思是「响应先返回,缓存清理后台慢慢做」,不拖慢用户。你改完点保存,全站相关页面下一秒就从边缘重渲染了——标题、正文、列表、RSS、sitemap 一套全清。
2. 正文拉不到时,别把"暂无内容"缓存一年
这个坑我踩过。图床偶尔抖一下,正文拉不到,页面就渲染成「暂无内容」。如果这时候被边缘按一年的 TTL 缓存了,用户得等一年才看得到正文。
所以 renderPostPage 会返回一个 bodyMissing 标记。正文缺失时,这页的缓存头被强行改成 max-age=60, s-maxage=60——只缓 60 秒,过了就重来。图床一恢复,最多一分钟正文就回来了,不会把空页面焊死在边缘。
3. 图片走 /cdn 代理,顺手预加载首图
文内图片如果直接外链第三方图床,国内访问经常慢、还拖 LCP(首屏最大内容绘制)。我做了个 /cdn 代理:把第三方图片经 Cloudflare 边缘回源,缓存头设成 immutable 的一年长缓存。
headers.set("Cache-Control", "public, max-age=31536000, s-maxage=31536000, immutable");
首图更狠,直接在响应头里加 preload:
if (heroImg && c.setHeader) c.setHeader("Link", `<${heroImg}>; rel=preload; as=image; fetchpriority=high`);
浏览器边下 HTML 边把首图也下了,肉眼可见地快。
4. 字体自己托管,同源还能缓存
博客用了思源宋体做标题、无衬线做正文,如果直接引 Google Fonts,国内一卡一卡的,而且会多一轮 DNS + 握手。我把字体子集化成 woff2 塞进 FONT_STORE,提供 /fonts.css 和 /fonts/:key 两个路由:
"Cache-Control": "public, max-age=31536000, immutable"
同源、边缘缓存,读者几乎感知不到字体加载。配合 font-display: swap,文字先以兜底字体显示,子集一到立刻换上,不会白屏。
5. 阅读数别挡着首字节
每打开一次文章,viewCount 就得 +1。要是同步去写 KV,访客得等你写完才看到页面。所以我把它丢进 waitUntil:
c.executionCtx?.waitUntil(
KVStore.upsertPost(c.env, { ...existing, viewCount: (existing.viewCount || 0) + 1 })
);
页面先返回,计数后台慢慢写。访客的体验是「秒开」,统计的精度差个一两秒无所谓。
6. 导航树缓存 60 秒
首页、分类、标签这些页面的导航,每次都要从 list:posts 现算。高频请求下这步挺费。我搞了个进程内的 _navCache(TTL 60 秒)放 isolate 内存:
const NAV_TTL = 60_000;
let _navCache = null;
function invalidateNav() { _navCache = null; }
命中就直接用,过期或内容有变动(新建/编辑/删文章时调 invalidateNav())才重建。一个边缘节点 60 秒内只算一次导航,省掉大量重复活。
7. 上线先预热
部署完新版本,缓存是冷的。我写了个 warm-cache.mjs,读 sitemap 把全站 URL 打一遍,把边缘缓存预热到全 HIT。线上实测命中率能到 100%,新访客打开任意文章都是边缘直出。
三、文章存哪:正文放图床,KV 只留指针
前面提了,KV 寸土寸金,正文不该往里塞。下面是完整链路。
为什么不全塞 KV
KV 按读写字数计费,容量也有限。正文从几 KB 到几十 KB 不等,全站几十上百篇累积起来不是小数。图床(cfbed 这类对象存储)存大文件便宜得多,拿来放 markdown 正文正合适。所以分工是:
- KV 存元数据:标题、slug、分类、标签、发布时间,以及最重要的
mdFilePath(正文在图床的地址)。 - 图床存正文:markdown 原文。
保存一篇文发生了什么
后台用 ToastUI Editor 编辑,支持 Markdown 和富文本(WYSIWYG)两种模式。提交时把编辑器里的最新 markdown 取出来,上传到图床,再把图床返回的 mdFilePath 写进 KV 的文章元数据。
const res = await ib.uploadFile(
new TextEncoder().encode(md).buffer,
`${safe}.md`,
"posts"
);
mdFilePath = res?.filePath || "";
// 然后把 mdFilePath 写进 KV 的 list:posts
这里有个坑值得记一笔:ToastUI 在富文本模式下,getMarkdown() 返回的是进模式前的旧内容。要是直接拿它提交,你改半天正文,存的还是改动前的——表现就是「标题秒生效、正文死活不更新」。修复办法是提交瞬间先 changeMode('markdown') 强制同步再读,确保拿到的是最新正文。
图床上传失败怎么办?按我们的选择,直接阻断保存并提示,不让脏数据落库(图床抖动的草稿由浏览器端保留,不会丢)。
渲染一篇文发生了什么
打开文章页时,renderPostPage 先读 KV 拿到元数据里的 mdFilePath,再去图床把正文拉回来:
let md = "";
if (post.mdFilePath) {
md = await new ImageBedService(c.env).getFileContent(post.mdFilePath);
}
拉到之后 markdown 转 HTML(带目录 TOC、代码高亮),拼进页面。图床拉取带了 6 秒超时和异常兜底,真挂了也不会让页面崩,只是正文那段空 60 秒再重试。
图床返回的地址格式五花八门(裸文件名、/file/xxx、甚至完整 URL),所以封装层统一做了 resolveUrl 规范化,读取和删除时还按路径段编码、保留斜杠,避免 %2F 把请求搞成 404。这些脏活都在 ImageBedService 里封装好了,上层不用管。
四、评论:写完推你手机上
评论系统是我自己觉得最实用的一块,尤其是「新评论推到手机让我审核」这个能力。
数据怎么存
每条评论是一个对象:{ id, postId, parentId, nickname, email, website, content, createdAt, status }。整篇帖子下的所有评论放在一个 KV key 里:comments:<postId>。新评论一进来,status 默认是 pending(待审核)。
async addComment(env, postId, comment) {
const comments = await this.getComments(env, postId);
const newComment = { ...comment, id: uuid(), status: "pending", createdAt: new Date().toISOString() };
comments.push(newComment);
await this.saveComments(env, postId, comments);
}
支持嵌套回复——靠 parentId 指回父评论,渲染时递归排成楼中楼。
提交与审核
访客在前台表单填昵称、邮箱、内容,POST 到 /post/:slug/comment。校验过后入库(默认 pending),页面提示「评论已提交,等待管理员审核后展示」。只有 status === "approved" 的评论才会渲染出来,所以垃圾评论不会直接冒出来。
推送到手机(重点)
这一步才是精髓。评论一提交,后台立刻给我手机发一条通知,我点开就能去后台审核。实现是 sendWechatNotify,同时支持三种渠道,用环境变量 NOTIFY_CHANNEL + 对应 token 决定走哪个:
async function sendWechatNotify(env, title, desp) {
const channel = (env.NOTIFY_CHANNEL || "").toLowerCase();
if (channel === "serverchan" && env.SERVERCHAN_KEY) {
// Server 酱(微信服务号推送)
} else if (channel === "pushplus" && env.PUSHPLUS_TOKEN) {
// PushPlus(微信/钉钉/邮件等)
} else if (channel === "wecom" && env.WECOM_WEBHOOK) {
// 企业微信机器人
}
}
我在用的是 Server 酱,绑定微信后,新评论的标题、昵称、内容、文章链接直接推到微信。你可以按自己习惯换成企业微信机器人或者 PushPlus,代码里三个分支都现成了,改个环境变量就行。
关键工程细节:推送不能拖慢评论提交。所以它是非阻塞的——
const notifyTask = sendWechatNotify(c.env, "新评论待审核", `《${postTitle}》\n昵称:${nickname}\n内容:${content}\n链接:${postUrl}`);
c.executionCtx?.waitUntil(notifyTask);
return c.redirect(`/post/${slug}?submitted=1#comments`);
waitUntil 把推送任务挂到请求生命周期之后,响应先返回给用户,推送在后台慢慢发。访客感知到的就是「提交→秒跳转」,完全不知道背后还跑了个手机推送。
后台怎么管
/admin/comments 是审核台:列出每篇帖子的评论,标出待审核/已通过,一键「通过」或「删除」。侧边栏还有个待审核数量的红点,一眼看到底有多少条在排队。
五、为什么这么搭,以及你能抄什么
把上面串起来,这套架构的卖点就清楚了:
- 省钱:Worker 免费额度够个人博客造,KV 只存元数据,正文扔便宜的图床,没有常驻服务器费用。
- 快:请求在离用户最近的边缘节点现渲染,配 Cache-Tag 精准清理,正常访问几乎都是边缘命中。
- 可控:从渲染、接口、缓存策略到评论审核,全自己写,想加什么能力改一个文件部署就行。
如果你想照着搓,核心就三件事:
- 用 Hono + Cloudflare Worker 起一个 SSR 入口,别上重框架;
- 热数据(列表、配置)扔 KV,大块正文放对象存储,KV 里只留指针;
- 缓存别只会
max-age,用 Cache-Tag + 主动 purge,关键页面再配个bodyMissing短缓存兜底。
至于评论推手机,三十行 fetch + waitUntil 就能让你出门也能秒审垃圾评论,强烈建议加上。
博客开源,详见

暂无评论,来抢沙发吧~