给 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),理由很简单:

  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
create 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 API
3
→ 把当前计数烤进 HTML
4
5
客户端(浏览器环境):
6
→ 组件挂载时再次 fetch
7
→ 用最新数据覆盖构建时的快照
8
→ 同时查询 "我当前选了哪个 emoji" 并高亮

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

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

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

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

组件结构

最后的组件拆分为两个:

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

第三步:具体实现

访客身份管理

src/lib/reactions.ts 中实现:

1
export function getOrCreateVisitorId(): string | null {
2
if (typeof window === 'undefined') return null
3
4
// 尝试读取已有的 UUID
5
try {
6
const existing = window.localStorage.getItem(VISITOR_ID_KEY)
7
if (existing) return existing
8
} catch { /* private mode */ }
9
10
// 没有则生成一个新的
11
const id = crypto.randomUUID()
12
try {
13
window.localStorage.setItem(VISITOR_ID_KEY, id)
14
} catch { /* 写不进去也不影响本次会话 */ }
15
return id
16
}

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

RLS 策略

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

1
-- 任何人都能读
2
create policy "reactions_read_all"
3
on public.reactions for select
4
to anon, authenticated
5
using (true);
6
7
-- 写入时必须声称自己的 visitor_id
8
-- 前端通过 X-Visitor-Id header 传入
9
create policy "reactions_write_self"
10
on public.reactions for insert
11
to anon, authenticated
12
with check (
13
visitor_id = current_setting('request.headers', true)::json->>'x-visitor-id'
14
);

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

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

Supabase helper 分装

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

函数使用场景技术栈
fetchBuildTimeCountsAstro 构建时(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
└────────────────────────────────────────────────┘

总结

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

  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 开始,祝顺利。

觉得这篇文章怎么样?给个反应吧!