给 Astro 博客加上点赞文章功能
预计 8 min read
前言
在博客评论区已经接入了 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 登录。但真实场景是:浏览博客的人大概率不会为了点个 👍 去点登录。直接匿名方案才能保证转化率。
最终的防刷体系是:
1localStorage UUID(身份)→ 数据库 UNIQUE(post_slug, visitor_id) 约束 → RLS 策略校验不依赖服务端 —— 所有身份管理在前端完成,Supabase 的 RLS 兜底防止伪造。
第二步:架构设计
数据模型
1create table public.reactions (2 id uuid primary key default gen_random_uuid(),3 post_slug text not null,4 emoji text not null,5 visitor_id text not null,6 created_at timestamptz not null default now(),7 updated_at timestamptz not null default now(),8 unique (post_slug, visitor_id)9);核心约束 UNIQUE(post_slug, visitor_id) —— 每篇文章每个访客只有一行数据。点击不同 emoji 时通过 UPDATE 更新 emoji 字段(不是 Insert + Delete,也不是多行)。
读路径:SSG 与 CSR 的混合
Astro 是纯静态构建,所以采用了 混合策略:
1构建时(Node.js 环境):2 → fetchBuildTimeCounts(slug) ← 用原生 fetch 调 Supabase REST API3 → 把当前计数烤进 HTML4
5客户端(浏览器环境):6 → 组件挂载时再次 fetch7 → 用最新数据覆盖构建时的快照8 → 同时查询 "我当前选了哪个 emoji" 并高亮这样搜索引擎能看到数字,用户看到的是实时数据,各取所需。
写路径:乐观更新 + 失败通知
1用户点击 emoji2 → 立刻更新 UI(选中高亮,计数 ±1)3 → 异步调用 supabase.upsert / delete4 → 成功 → 静默5 → 失败 → toast 报错 + 回滚 UIToast 组件用了 sonner,shadcn 生态标配,和 Tailwind 风格统一。
组件结构
最后的组件拆分为两个:
reaction-chip.tsx—— 核心组件,包含MessageWithReactions(外部容器)和ReactionChip(浮动 emoji 选择面板)两个导出sonner.tsx—— 标准 Toaster 包装,挂载在全局 Layout
第三步:具体实现
访客身份管理
在 src/lib/reactions.ts 中实现:
1export function getOrCreateVisitorId(): string | null {2 if (typeof window === 'undefined') return null3
4 // 尝试读取已有的 UUID5 try {6 const existing = window.localStorage.getItem(VISITOR_ID_KEY)7 if (existing) return existing8 } catch { /* private mode */ }9
10 // 没有则生成一个新的11 const id = crypto.randomUUID()12 try {13 window.localStorage.setItem(VISITOR_ID_KEY, id)14 } catch { /* 写不进去也不影响本次会话 */ }15 return id16}为什么不哈希?因为纯粹前端做加盐哈希没有意义 —— 盐写在前端 bundle 里就等于公开。直接用 cookie/localStorage 的值作为身份标识,数据库 UNIQUE 约束才是真正的防刷防线。
RLS 策略
Supabase 的 Row Level Security 是零服务端方案的安全基石:
1-- 任何人都能读2create policy "reactions_read_all"3 on public.reactions for select4 to anon, authenticated5 using (true);6
7-- 写入时必须声称自己的 visitor_id8-- 前端通过 X-Visitor-Id header 传入9create policy "reactions_write_self"10 on public.reactions for insert11 to anon, authenticated12 with check (13 visitor_id = current_setting('request.headers', true)::json->>'x-visitor-id'14 );前端请求时自动携带 X-Visitor-Id header:
1const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {2 global: {3 headers: { 'x-visitor-id': visitorId }4 }5})Supabase helper 分装
在 src/lib/reactions.ts 中封装了三个场景的查询函数:
| 函数 | 使用场景 | 技术栈 |
|---|---|---|
fetchBuildTimeCounts | Astro 构建时(Node.js) | fetch + REST API |
fetchLiveCounts | 浏览器组件挂载 | @supabase/supabase-js |
fetchMySelection | 浏览器查询当前访客的选择 | @supabase/supabase-js |
最终架构全景
1┌─ 构建时(Node.js)─────────────────────────────┐2│ BlogPost.astro frontmatter │3│ → fetchBuildTimeCounts(slug) │4│ → 返回 { "👍": 3, "❤️": 1 } │5│ → 序列化到 HTML props │6└────────────────────────────────────────────────┘7
8┌─ 浏览器(React 组件)──────────────────────────┐9│ useEffect: │10│ 1. 读 localStorage UUID │11│ 2. fetchLiveCounts (实时覆盖构建快照) │12│ 3. fetchMySelection (高亮选择) │13│ │14│ onClick: │15│ 1. 乐观更新 UI │16│ 2. supabase.upsert / delete │17│ 3. 失败 → toast.error + 回滚 │18└────────────────────────────────────────────────┘19
20┌─ Supabase ─────────────────────────────────────┐21│ Table: reactions │22│ RLS: SELECT 开放 / 写操作需校验 visitor_id │23│ UNIQUE(post_slug, visitor_id) 防刷 │24└────────────────────────────────────────────────┘总结
这个功能本身不复杂,但从需求到上线踩了三个平台/框架版本兼容的坑:
- 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 开始,祝顺利。
觉得这篇文章怎么样?给个反应吧!