2028 字
10 分钟
– 次浏览
– 位访客
转载|从 Giscus 到自建留言板:与我的方案对比
AI 摘要
正在生成摘要…

转载声明:本文主体整理自白咲雫《繁琐小事与留言板的实现教程》(2026-01-27,https://blog.shiro.team/posts/dev-log/message-board-impl/)。原文版权归原作者所有,此处为学习目的作中文整理与二次评注,非逐字翻译;文中的代码片段、接口设计与数值均引自原文。文中「本站 / 留心博客」指笔者自己的部署(Cloudflare Workers + D1 自建评论),与原文作者方案并列对照。


巧的是,笔者自己的博客在 2026-09-12 也刚把评论系统从 Giscus 迁到了自建后端。所以看到这篇教程时,与其说是”学新东西”,不如说是拿到一份现成的对照样本——同样的起点(逃离 Giscus)、同样的栈(Astro + Svelte),却走了不同的存储与部署路线。下面先整理原作者怎么做,再逐项和笔者的方案比。

一、原作者想解决什么#

把博客原有的评论系统从 Giscus 迁到自建留言板,动机很直接:

  • 更轻量:访客无需 GitHub 登录就能留言;
  • 深度集成:能塞进 Astro 主题的视觉语言里;
  • 自己掌控数据:可随意定制样式、未来加邮件通知或后台管理;
  • 技术载体选了 Vercel KV(Redis)。

二、技术栈一览#

层方案说明
前端Astro(output: static 纯静态)+ Svelte + Tailwind和笔者的栈一致
后端Vercel Serverless Function(Astro API 路由,prerender=false)笔者拆成独立 Cloudflare Worker
存储Vercel KV(Redis),单 Key messages 存整数组;无 Redis 时降级本地 JSON笔者用 Cloudflare D1(关系型 SQLite)
部署Vercel笔者用 Cloudflare Pages + Worker
渲染markdown-it + sanitize-html 白名单笔者用纯文本 textContent

三、数据模型与接口#

扁平数组 + slug 区分页面:每条留言是一段对象,靠 slug 字段(默认取 location.pathname)过滤属于哪篇文章;嵌套回复用 parentId 指向父留言。

interface Message {
id: string;
slug?: string;
parentId?: string;
nickname: string;
content: string;
email?: string;
website?: string;
avatar: string;
createdAt: number;
replies?: Message[]; // 前端建树时填充
os?: string; browser?: string; device?: string;
}

接口只有两个:

  • GET /api/messages/?slug=xxx —— 取该页留言,返回 JSON;
  • POST /api/messages/ —— 收 nickname / content / email / website / parentId / slug,后端用 ua-parser-js 解析 User-Agent 入库设备信息,并按邮箱决定头像。

头像逻辑:邮箱匹配 QQ 号(或 @qq.com)→ 用 https://q1.qlogo.cn/g?b=qq&nk=<qq>&s=100;否则用 dicebear 生成 identicon:

const qqMatch = email.match(/^(\d{5,11})(@qq\.com)?$/);
const avatar = qqMatch
? `https://q1.qlogo.cn/g?b=qq&nk=${qqMatch[1]}&s=100`
: `https://api.dicebear.com/7.x/identicon/svg?seed=${nickname}`;

四、防滥用与嵌套回复#

反垃圾只做了三件事:非空校验(后端 400、前端按钮 disabled)、长度上限(昵称 20 / 内容 500 / 邮箱 50 / 网址 100)、以及总量硬砍 2000 条(messages.pop() 踢最旧)。原文没有验证码,也没有频率限制。

嵌套回复:库里扁平存,前端用 Map<id> 建树——遍历时若 parentId 存在就塞进父级的 replies,否则当根留言;根留言按 createdAt 倒序(新在前),回复按正序(对话时间线)。渲染用 <svelte:self> 组件自调用实现无限层级缩进。

五、实时性与几个取舍#

  • 非实时推送:没有 WebSocket / SSE,提交成功后 fetchMessages() 重新拉全量刷新列表;
  • 延迟拉取防 LCP 阻塞:onMount 里 setTimeout(() => fetchMessages(), 100),避免阻塞首屏;
  • Markdown 解析器单例化:若在每条留言的 MessageItem.svelte 里 new MarkdownIt() 会每条建实例、吃内存,于是用 Svelte 的 <script context="module"> 把解析器提升为模块级单例;
  • 本地降级:无 Redis 环境变量时自动改用 data/messages.json(fs.writeFileSync),降低本地起环境的门槛;
  • 存储简化:用单 Key 数组而非 Redis 复杂结构(Sorted Set / Hash),以简化逻辑,再用 2000 条上限防止无限膨胀。

XSS 防护是把 markdown-it 渲染结果过 sanitize-html 白名单(span 仅放行 spoiler class),并支持 ||黑幕|| 转 <span class="spoiler"> 的剧透语法:

<script context="module" lang="ts">
import MarkdownIt from "markdown-it";
import sanitizeHtml from "sanitize-html";
const md = new MarkdownIt({ html: true, breaks: true, linkify: true });
const sanitizeOptions = {
allowedTags: ["p","b","i","em","strong","a","code","pre","span","img","details","summary"],
allowedAttributes: { img: ["src","alt","title"], a: ["href","title","target"], span: ["class"] },
allowedClasses: { span: ["spoiler"] },
};
function renderContent(text: string) {
const withSpoilers = text.replace(/\|\|(.*?)\|\|/g, '<span class="spoiler">$1</span>');
return sanitizeHtml(md.render(withSpoilers), sanitizeOptions);
}
</script>

六、与「留心博客」自建评论的对比#

笔者的方案 2026-09-12 上线,后端是独立 workers/comments Worker + D1,前端 Comments.astro。同一道题,两个答案:

维度白咲雫(原文)留心博客(本站)
存储Vercel KV,单 Key 数组Cloudflare D1,关系表 comments
后端归属Astro API 路由(prerender=false)独立 Cloudflare Worker
部署VercelCF Pages + 自定义域名 comments.142588.xyz
匿名完全匿名(昵称+内容必填)完全匿名
防滥用仅长度上限 + 2000 总量蜜罐 trap + 同 IP 同文 30s 限流 + 长度上限 + 可选审核
XSSmarkdown-it + sanitize-html 白名单纯文本 textContent(结构上无口子)
头像QQ 头像 / dicebear 外链无(纯文字)
嵌套回复前端树形,删父不级联parent_id 校验 + 递归 CTE 级联删除
设备信息解析 OS/Browser/Device 入库未做
输入缓存localStorage 缓存昵称邮箱未做
拉取时机setTimeout(100ms) 延迟拉取提交后刷新(无 100ms 延迟)

逐项说笔者的判断:

  1. 存储架构:本站更”正”。 原文把所有留言塞进一个 Redis Key 的数组,每次读写都要全量序列化;作者自己用 2000 条 pop() 兜底。文章一多、某篇爆火,全量读写会越拖越慢,且无法按 slug 在服务端分页。本站用 D1 可以直接 WHERE post_slug = ? ORDER BY ... LIMIT,天然索引、可分页、还能用递归 CTE 做级联删除——长期更稳。
  2. 后端解耦:本站更干净。 原文把 src/pages/api/messages.ts 声明 prerender=false 混进 Astro 静态站,靠 Vercel 把这条路由变 Serverless;本站把评论彻底拆成独立 Worker,Astro 只 fetch,不污染构建、不受静态输出约束。
  3. 大陆可达性:本站已修坑,原文未讨论。 原文的 blog.shiro.team 看着是 Cloudflare 代理域名,但教程本身没提部署地域。本站刚踩过”*.workers.dev 在国内被 DNS 污染连不上”的坑,已绑自定义域名——这一条本站更稳。
  4. 防垃圾:本站更严。 原文那版没有频率限制 / 验证码,匿名留言板一旦被爬被刷就是裸奔;本站已有蜜罐 + 限流 + 可选审核。
  5. XSS 取舍:各有权衡。 原文支持富文本(||黑幕||、链接、代码块),但要背一条 sanitize 管线;本站走纯文本,牺牲富文本换”结构上不可能 XSS”。不是谁错,是取舍不同。

七、值得借鉴的点(按性价比)#

项价值风险建议
localStorage 缓存昵称/邮箱/网址高,体验立竿见影极低建议抄
延迟 100ms 拉取防 LCP中极低建议抄
UA 解析设备小标签中,评论区更”活”低(UA 不可信,仅展示)可抄
QQ / dicebear 头像中,视觉更丰富中高:外链 SVG,本站刚因图床失效炸过 favicon,对静态资源外链有痛教训想做就走本地生成/缓存,别裸挂外链
`黑幕

八、小结#

这篇教程对笔者的价值,主要在验证路线而非提供新方案:在存储(D1)、后端解耦(独立 Worker)、反垃圾(限流+蜜罐)、大陆可达(自定义域名)四个关键点上,本站 2026-09-12 的那版都优于或等于它。唯一明显不如、且成本低值得补的,是用户输入缓存和拉取延迟优化——两项几乎零风险。

如果你的博客也在用 Giscus 或别的第三方评论,不一定要马上换;但有两件事值得现在确认:评论数据存在哪里、出问题能不能拿回来,以及访客为了留一句话要付出多少成本。这两点,正是我们各自”出走 Giscus”的共同起点。

相关阅读#

AI 参与程度
润色
完全
不使用
转载|从 Giscus 到自建留言板:与我的方案对比
https://blog.142588.xyz/posts/message-board-impl-review/
作者
Watch Your Back
发布于
2026-09-13
许可协议
CC BY-NC-SA 4.0

评论

加载中…