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

前言

在博客评论区已经接入了 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),理由很简单:

  1. 关系型数据模型天然适合”文章 × emoji 计数”这种场景
  2. Supabase JS client 只有几 KB,可以直接在 React 组件里用
  3. RLS 策略可以在不写后端代码的前提下做安全校验
  4. 免费档够个人博客跑很久

问:要不要让用户登录?

当然最简单的是用 Supabase Auth,走 GitHub 登录。但真实场景是:浏览博客的人大概率不会为了点个 👍 去点登录。直接匿名方案才能保证转化率。

最终的防刷体系是:

1
localStorage UUID(身份)→ 数据库 UNIQUE(post_slug, visitor_id) 约束 → RLS 策略校验

不依赖服务端 —— 所有身份管理在前端完成,Supabase 的 RLS 兜底防止伪造。


第二步:架构设计

数据模型

1
2
3
4
5
6
7
8
9
create table public.reactions (
id uuid primary key default gen_random_uuid(),
post_slug text not null,
emoji text not null,
visitor_id text not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (post_slug, visitor_id)
);

核心约束 UNIQUE(post_slug, visitor_id) —— 每篇文章每个访客只有一行数据。点击不同 emoji 时通过 UPDATE 更新 emoji 字段(不是 Insert + Delete,也不是多行)。

读路径:SSG 与 CSR 的混合

Astro 是纯静态构建,所以采用了 混合策略

1
2
3
4
5
6
7
8
构建时(Node.js 环境):
→ fetchBuildTimeCounts(slug) ← 用原生 fetch 调 Supabase REST API
→ 把当前计数烤进 HTML

客户端(浏览器环境):
→ 组件挂载时再次 fetch
→ 用最新数据覆盖构建时的快照
→ 同时查询 "我当前选了哪个 emoji" 并高亮

这样搜索引擎能看到数字,用户看到的是实时数据,各取所需。

写路径:乐观更新 + 失败通知

1
2
3
4
5
用户点击 emoji
→ 立刻更新 UI(选中高亮,计数 ±1)
→ 异步调用 supabase.upsert / delete
→ 成功 → 静默
→ 失败 → toast 报错 + 回滚 UI

Toast 组件用了 sonner,shadcn 生态标配,和 Tailwind 风格统一。

组件结构

最后的组件拆分为两个:

  • reaction-chip.tsx —— 核心组件,包含 MessageWithReactions(外部容器)和 ReactionChip(浮动 emoji 选择面板)两个导出
  • sonner.tsx —— 标准 Toaster 包装,挂载在全局 Layout

第三步:具体实现

访客身份管理

src/lib/reactions.ts 中实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export function getOrCreateVisitorId(): string | null {
if (typeof window === 'undefined') return null

// 尝试读取已有的 UUID
try {
const existing = window.localStorage.getItem(VISITOR_ID_KEY)
if (existing) return existing
} catch { /* private mode */ }

// 没有则生成一个新的
const id = crypto.randomUUID()
try {
window.localStorage.setItem(VISITOR_ID_KEY, id)
} catch { /* 写不进去也不影响本次会话 */ }
return id
}

为什么不哈希?因为纯粹前端做加盐哈希没有意义 —— 盐写在前端 bundle 里就等于公开。直接用 cookie/localStorage 的值作为身份标识,数据库 UNIQUE 约束才是真正的防刷防线。

RLS 策略

Supabase 的 Row Level Security 是零服务端方案的安全基石:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 任何人都能读
create policy "reactions_read_all"
on public.reactions for select
to anon, authenticated
using (true);

-- 写入时必须声称自己的 visitor_id
-- 前端通过 X-Visitor-Id header 传入
create policy "reactions_write_self"
on public.reactions for insert
to anon, authenticated
with check (
visitor_id = current_setting('request.headers', true)::json->>'x-visitor-id'
);

前端请求时自动携带 X-Visitor-Id header:

1
2
3
4
5
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: {
headers: { 'x-visitor-id': visitorId }
}
})

Supabase helper 分装

src/lib/reactions.ts 中封装了三个场景的查询函数:

函数使用场景技术栈
fetchBuildTimeCountsAstro 构建时(Node.js)fetch + REST API
fetchLiveCounts浏览器组件挂载@supabase/supabase-js
fetchMySelection浏览器查询当前访客的选择@supabase/supabase-js

最终架构全景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌─ 构建时(Node.js)─────────────────────────────┐
│ BlogPost.astro frontmatter │
│ → fetchBuildTimeCounts(slug) │
│ → 返回 { "👍": 3, "❤️": 1 } │
│ → 序列化到 HTML props │
└────────────────────────────────────────────────┘

┌─ 浏览器(React 组件)──────────────────────────┐
│ useEffect: │
│ 1. 读 localStorage UUID │
│ 2. fetchLiveCounts (实时覆盖构建快照) │
│ 3. fetchMySelection (高亮选择) │
│ │
│ onClick: │
│ 1. 乐观更新 UI │
│ 2. supabase.upsert / delete │
│ 3. 失败 → toast.error + 回滚 │
└────────────────────────────────────────────────┘

┌─ Supabase ─────────────────────────────────────┐
│ Table: reactions │
│ RLS: SELECT 开放 / 写操作需校验 visitor_id │
│ UNIQUE(post_slug, visitor_id) 防刷 │
└────────────────────────────────────────────────┘

总结

这个功能本身不复杂,但从需求到上线踩了三个平台/框架版本兼容的坑:

  1. Vercel 环境差异 —— Node 20 没有 WebSocket,createClient 在构建时直接崩溃
  2. Node 24 解析差异 —— BOM 字符导致 commitlint.config.js 无法加载

最终收获是 零服务端、纯静态 的反应系统,全部代码量不到 300 行,依赖于 @supabase/supabase-js + sonner 两个外部包。pnpm-lock.yaml 里锁定了所有依赖版本,确保本地和 Vercel 构建行为一致。

如果你也在自己的 Astro 博客上做类似的功能,可以从我的 GitHub 仓库src/components/ui/reaction-chip.tsx 开始,祝顺利。