给 Astro 博客加上点赞文章功能

给 Astro 博客加上点赞文章功能
SanXiaoXing前言
在博客评论区已经接入了 Twikoo 评论系统的基础上,我一直觉得缺少一个轻量的”反应”能力——在文章末尾加几个 emoji 按钮,读者可以随手点个 👍,其他人也能看到。
需求很简单:
- 每篇文章下方显示几个 emoji 反应按钮
- 读者点击后高亮,计数 +1
- 所有人都能看到当前计数
- 不需要登录,零摩擦
- 纯静态站点,不引入 serverless 函数
听起来很直接,但真正落地时涉及了组件设计、数据持久化、防刷、SSG/CSR 融合、以及一连串部署环境差异的坑。这篇文章就完整记录整个过程。
第一步:选择技术栈
项目现状是 Astro + React + Tailwind CSS,输出模式为纯静态 SSG。要支持”所有人可见”的计数,最关键的问题只有一个 —— 数据存在哪里?
选项对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| A. 复用 Twikoo 评论 | 零新依赖,几乎不需新代码 | 数据模型错位,污染评论流,聚合困难 |
| B. Supabase / Firebase | 托管 PostgreSQL,SDK 成熟,免费档够用 | 多一个外部服务依赖 |
| C. 自建 Serverless API | 完全可控 | 要从静态站切 hybrid 模式,部署复杂度翻倍 |
| D. 静态 JSON 文件 | 极致简单 | 每次反应都要 build,完全不现实 |
最终选择了 B(Supabase),理由很简单:
- 关系型数据模型天然适合”文章 × emoji 计数”这种场景
- Supabase JS client 只有几 KB,可以直接在 React 组件里用
- RLS 策略可以在不写后端代码的前提下做安全校验
- 免费档够个人博客跑很久
问:要不要让用户登录?
当然最简单的是用 Supabase Auth,走 GitHub 登录。但真实场景是:浏览博客的人大概率不会为了点个 👍 去点登录。直接匿名方案才能保证转化率。
最终的防刷体系是:
1 | localStorage UUID(身份)→ 数据库 UNIQUE(post_slug, visitor_id) 约束 → RLS 策略校验 |
不依赖服务端 —— 所有身份管理在前端完成,Supabase 的 RLS 兜底防止伪造。
第二步:架构设计
数据模型
1 | create table public.reactions ( |
核心约束 UNIQUE(post_slug, visitor_id) —— 每篇文章每个访客只有一行数据。点击不同 emoji 时通过 UPDATE 更新 emoji 字段(不是 Insert + Delete,也不是多行)。
读路径:SSG 与 CSR 的混合
Astro 是纯静态构建,所以采用了 混合策略:
1 | 构建时(Node.js 环境): |
这样搜索引擎能看到数字,用户看到的是实时数据,各取所需。
写路径:乐观更新 + 失败通知
1 | 用户点击 emoji |
Toast 组件用了 sonner,shadcn 生态标配,和 Tailwind 风格统一。
组件结构
最后的组件拆分为两个:
reaction-chip.tsx—— 核心组件,包含MessageWithReactions(外部容器)和ReactionChip(浮动 emoji 选择面板)两个导出sonner.tsx—— 标准 Toaster 包装,挂载在全局 Layout
第三步:具体实现
访客身份管理
在 src/lib/reactions.ts 中实现:
1 | export function getOrCreateVisitorId(): string | null { |
为什么不哈希?因为纯粹前端做加盐哈希没有意义 —— 盐写在前端 bundle 里就等于公开。直接用 cookie/localStorage 的值作为身份标识,数据库 UNIQUE 约束才是真正的防刷防线。
RLS 策略
Supabase 的 Row Level Security 是零服务端方案的安全基石:
1 | -- 任何人都能读 |
前端请求时自动携带 X-Visitor-Id header:
1 | const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { |
Supabase helper 分装
在 src/lib/reactions.ts 中封装了三个场景的查询函数:
| 函数 | 使用场景 | 技术栈 |
|---|---|---|
fetchBuildTimeCounts | Astro 构建时(Node.js) | fetch + REST API |
fetchLiveCounts | 浏览器组件挂载 | @supabase/supabase-js |
fetchMySelection | 浏览器查询当前访客的选择 | @supabase/supabase-js |
最终架构全景
1 | ┌─ 构建时(Node.js)─────────────────────────────┐ |
总结
这个功能本身不复杂,但从需求到上线踩了三个平台/框架版本兼容的坑:
- Vercel 环境差异 —— Node 20 没有 WebSocket,
createClient在构建时直接崩溃 - Node 24 解析差异 —— BOM 字符导致
commitlint.config.js无法加载
最终收获是 零服务端、纯静态 的反应系统,全部代码量不到 300 行,依赖于 @supabase/supabase-js + sonner 两个外部包。pnpm-lock.yaml 里锁定了所有依赖版本,确保本地和 Vercel 构建行为一致。
如果你也在自己的 Astro 博客上做类似的功能,可以从我的 GitHub 仓库 的 src/components/ui/reaction-chip.tsx 开始,祝顺利。





