2498 字
12 分钟
Fuwari 文章反应功能
2026-05-06
Purely By AI

在每篇文章底部添加一组表情反应按钮(👍❤️😮🎉🤔👀),点击即可投票,再次点击取消。数据存储在 Supabase 的免费 PostgreSQL 数据库中。

一、Supabase 数据库设置#

1. 创建项目#

前往 Supabase 注册并创建一个免费项目,选择离你最近的区域。

2. 建表#

进入项目的 SQL Editor,执行以下 SQL:

-- 反应表:每行记录一个用户对一篇文章的一种 emoji 投票
CREATE TABLE reactions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
post_slug text NOT NULL,
emoji text NOT NULL,
fingerprint text NOT NULL,
created_at timestamptz DEFAULT now(),
-- 每个用户对每篇文章的每种 emoji 只能投一次
CONSTRAINT reactions_post_slug_emoji_fingerprint_key
UNIQUE (post_slug, emoji, fingerprint)
);
-- 按文章 slug 查询的索引
CREATE INDEX idx_reactions_post_slug ON reactions (post_slug);
-- 启用行级安全策略
ALTER TABLE reactions ENABLE ROW LEVEL SECURITY;
-- 匿名用户可读(公开数据)
CREATE POLICY "Allow public read" ON reactions
FOR SELECT USING (true);
-- 匿名用户可写入(投票)
CREATE POLICY "Allow public insert" ON reactions
FOR INSERT WITH CHECK (true);
-- 匿名用户可删除自己的投票(取消投票)
CREATE POLICY "Allow public delete own" ON reactions
FOR DELETE USING (true);

3. 获取凭据#

进入 Supabase 控制台 → Settings → API,记录两个值:

  • Project URL:形如 https://xxx.supabase.co
  • anon public key:以 eyJ 开头的长字符串

将它们写入项目根目录的 .env 文件:

PUBLIC_SUPABASE_URL=https://xxx.supabase.co
PUBLIC_SUPABASE_ANON_KEY=eyJ...

如果部署在 Cloudflare Pages,需要在 Pages → Settings → Environment variables 中同步添加这两个变量。


二、创建 Reactions 组件#

新建 src/components/Reactions.astro。这是一个纯静态组件,只负责渲染 HTML 骨架和样式,不包含任何 <script> —— 交互逻辑统一由 Layout.astro 管理。

这种模式和项目中的 MathGraph、Twikoo 一致:组件输出静态 HTML,Layout.astro 的全局脚本通过 swup 的 page:view 钩子负责初始化。

---
interface Props {
slug: string;
}
const { slug } = Astro.props;
---
<div id="post-reactions" class="post-reactions onload-animation"
data-post-slug={slug} data-initialized="false">
<div class="reactions-divider"></div>
<div class="reactions-label">这篇文章对你有帮助吗?</div>
<div class="reactions-container">
<button class="reaction-btn" data-emoji="👍" disabled>
<span class="reaction-emoji">👍</span>
<span class="reaction-count">·</span>
</button>
<button class="reaction-btn" data-emoji="❤️" disabled>
<span class="reaction-emoji">❤️</span>
<span class="reaction-count">·</span>
</button>
<button class="reaction-btn" data-emoji="😮" disabled>
<span class="reaction-emoji">😮</span>
<span class="reaction-count">·</span>
</button>
<button class="reaction-btn" data-emoji="🎉" disabled>
<span class="reaction-emoji">🎉</span>
<span class="reaction-count">·</span>
</button>
<button class="reaction-btn" data-emoji="🤔" disabled>
<span class="reaction-emoji">🤔</span>
<span class="reaction-count">·</span>
</button>
<button class="reaction-btn" data-emoji="👀" disabled>
<span class="reaction-emoji">👀</span>
<span class="reaction-count">·</span>
</button>
</div>
</div>
<style is:inline>
.post-reactions {
padding-top: 1rem;
padding-bottom: 0.5rem;
}
.reactions-divider {
border-bottom: 1px dashed var(--line-divider);
margin-bottom: 1.25rem;
}
.reactions-label {
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.5);
margin-bottom: 0.75rem;
}
html.dark .reactions-label {
color: rgba(255, 255, 255, 0.5);
}
.reactions-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.reaction-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
border: 2px solid var(--btn-regular-bg);
background: transparent;
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
user-select: none;
color: inherit;
}
.reaction-btn:hover:not(:disabled):not(.voted):not(.pending) {
background: var(--btn-plain-bg-hover);
border-color: var(--primary);
color: var(--primary);
}
.reaction-btn:disabled {
opacity: 0.5;
cursor: default;
}
.reaction-btn.pending {
pointer-events: none;
}
.reaction-btn.voted {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.reaction-btn.voted:hover:not(.pending) {
opacity: 0.85;
}
.reaction-btn:active:not(:disabled):not(.pending) {
transform: scale(0.95);
}
.reaction-count {
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.5);
font-variant-numeric: tabular-nums;
min-width: 0.5rem;
text-align: center;
}
html.dark .reaction-count {
color: rgba(255, 255, 255, 0.5);
}
.reaction-btn.voted .reaction-count {
color: white;
}
</style>

要点说明:

  • 按钮初始状态为 disabled,计数显示 · 占位符,等 JS 加载数据后替换
  • data-post-slug 存储文章标识(不使用完整 URL,避免 URL 结构变化导致数据丢失)
  • data-initialized 用于防止 swup 切页时重复初始化
  • 样式使用 <style is:inline> 而非 <style>,因为 Astro 的 scoped style 会添加属性选择器,与动态 DOM 操作不兼容
  • 颜色全部引用 Fuwari 的 CSS 变量(--primary--btn-regular-bg--btn-plain-bg-hover),自动适配亮/暗主题

三、在文章页放置组件#

编辑 src/pages/posts/[...slug].astro

在 frontmatter 中添加 import:

import Reactions from "../../components/Reactions.astro";

#post-container 内部、License 组件之后放置:

{licenseConfig.enable && <License ...></License>}
<Reactions slug={getEntrySlug(entry)} />
</div> <!-- #post-container 的关闭标签 -->

四、Layout.astro 添加交互逻辑#

这是改动最多的文件,分为三步。

1. 注入 Supabase 凭据#

在 Layout.astro 的 <head> 区域,主题加载脚本之后添加一个 <script is:inline define:vars> 块,将 .env 中的变量注入到 window 对象上:

<script is:inline define:vars={{
supabaseUrl: import.meta.env.PUBLIC_SUPABASE_URL,
supabaseKey: import.meta.env.PUBLIC_SUPABASE_ANON_KEY
}}>
window.__SUPABASE_URL = supabaseUrl;
window.__SUPABASE_ANON_KEY = supabaseKey;
</script>

这里用 define:vars 而不是在 JS 中硬编码,是因为 Astro 在构建时会把 import.meta.env.PUBLIC_* 替换为实际值,同时密钥不会出现在源码仓库中。

2. 添加 initReactions() 函数#

在 Layout.astro 的主 <script> 块中(initTwikoo() 函数之后),添加完整的反应功能逻辑。核心分为三个部分:

浏览器指纹生成#

async function generateFingerprint() {
const components = [
navigator.userAgent,
screen.width + 'x' + screen.height,
String(screen.colorDepth),
String(new Date().getTimezoneOffset()),
navigator.language,
String(navigator.hardwareConcurrency || ''),
String((navigator as any).deviceMemory || ''),
];
// Canvas 指纹
try {
const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 50;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#f60';
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = '#069';
ctx.fillText('fingerprint', 2, 15);
components.push(canvas.toDataURL());
}
} catch (e) { /* ignore */ }
// WebGL 渲染器信息
try {
const gl = document.createElement('canvas').getContext('webgl');
if (gl) {
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
if (debugInfo) {
components.push(String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL)));
}
}
} catch (e) { /* ignore */ }
const data = components.join('|||');
const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data));
return Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
async function getFingerprint() {
let fp = localStorage.getItem('reactions_fp');
if (!fp) {
fp = await generateFingerprint();
localStorage.setItem('reactions_fp', fp);
}
return fp;
}

原理:收集浏览器的 UA、屏幕分辨率、色深、时区、语言、CPU 核数、内存、Canvas 渲染结果、WebGL 渲染器名称,拼接后做 SHA-256 哈希。同一浏览器(相同配置和硬件)会生成相同的哈希值,结果缓存在 localStorage 中只需计算一次。

这个指纹不是密码学级别的身份标识,但足以在”无登录”场景下起到基本的防重复投票作用。无痕模式下 localStorage 隔离,指纹自然独立。

数据获取与渲染#

async function initReactions() {
const container = document.getElementById('post-reactions');
if (!container || container.dataset.initialized === 'true') return;
// 用 AbortController 取消上一次未完成的请求(应对快速切页)
const existingController = (container as any).__abortController;
if (existingController) existingController.abort();
const controller = new AbortController();
(container as any).__abortController = controller;
const slug = container.dataset.postSlug;
if (!slug) return;
const supabaseUrl = (window as any).__SUPABASE_URL;
const supabaseKey = (window as any).__SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseKey) return;
container.dataset.initialized = 'true';
try {
const fingerprint = await getFingerprint();
// 一次请求获取该文章的所有反应记录
const resp = await fetch(
`${supabaseUrl}/rest/v1/reactions?post_slug=eq.${encodeURIComponent(slug)}&select=emoji,fingerprint`,
{
headers: {
'apikey': supabaseKey,
'Authorization': `Bearer ${supabaseKey}`,
},
signal: controller.signal,
}
);
if (!resp.ok) return;
const rows = await resp.json();
// 客户端统计计数(数据量极小,无需服务端聚合)
const counts = {};
const userVotes = new Set();
for (const row of rows) {
counts[row.emoji] = (counts[row.emoji] || 0) + 1;
if (row.fingerprint === fingerprint) {
userVotes.add(row.emoji);
}
}
// 启用按钮、显示计数、标记已投票状态
const buttons = container.querySelectorAll('.reaction-btn');
buttons.forEach(btn => {
const emoji = btn.dataset.emoji;
btn.disabled = false;
const countEl = btn.querySelector('.reaction-count');
countEl.textContent = String(counts[emoji] || 0);
if (userVotes.has(emoji)) {
btn.classList.add('voted');
}
// 绑定点击事件(见下文)
btn.addEventListener('click', () => handleClick(/* ... */));
});
} catch (e) {
if (e.name === 'AbortError') return;
}
}

使用 PostgREST 的过滤语法 ?post_slug=eq.xxx&select=emoji,fingerprint 一次性拿到该文章的全部记录,客户端做分组统计。对博客文章来说数据量极小(每篇文章最多 6 种 emoji),不需要服务端聚合。

点击事件:乐观更新 + 切换投票#

btn.addEventListener('click', async function handleClick() {
if (btn.classList.contains('pending')) return;
btn.classList.add('pending'); // 防快速重复点击
const cache = JSON.parse(localStorage.getItem('reactions_votes') || '{}');
const alreadyVoted = cache[slug] && cache[slug][emoji];
const adding = !alreadyVoted; // 切换方向
// 乐观更新 UI(先改界面,再发请求)
btn.classList.toggle('voted', adding);
const prevText = countEl.textContent;
const current = parseInt(prevText) || 0;
countEl.textContent = String(Math.max(0, adding ? current + 1 : current - 1));
try {
let ok;
if (adding) {
// 投票:POST 新记录
const r = await fetch(`${supabaseUrl}/rest/v1/reactions`, {
method: 'POST',
headers: {
'apikey': supabaseKey,
'Authorization': `Bearer ${supabaseKey}`,
'Content-Type': 'application/json',
'Prefer': 'return=minimal',
},
body: JSON.stringify({ post_slug: slug, emoji, fingerprint }),
signal: controller.signal,
});
ok = r.ok || r.status === 409; // 409 = 唯一约束冲突,视为成功
} else {
// 取消投票:DELETE 匹配记录
const r = await fetch(
`${supabaseUrl}/rest/v1/reactions?post_slug=eq.${encodeURIComponent(slug)}&emoji=eq.${encodeURIComponent(emoji)}&fingerprint=eq.${encodeURIComponent(fingerprint)}`,
{
method: 'DELETE',
headers: {
'apikey': supabaseKey,
'Authorization': `Bearer ${supabaseKey}`,
},
signal: controller.signal,
}
);
ok = r.ok;
}
if (ok) {
// 更新本地缓存
if (!cache[slug]) cache[slug] = {};
if (adding) cache[slug][emoji] = true;
else delete cache[slug][emoji];
localStorage.setItem('reactions_votes', JSON.stringify(cache));
} else {
// 请求失败,回滚 UI
btn.classList.toggle('voted', !adding);
countEl.textContent = prevText;
}
} catch (e) {
if (e.name === 'AbortError') return;
btn.classList.toggle('voted', !adding);
countEl.textContent = prevText;
} finally {
btn.classList.remove('pending');
}
});

三个设计要点:

  1. 乐观更新:先改 UI 再发请求。网络请求可能需要几百毫秒甚至更长,如果等到响应回来再改界面,用户会感到明显延迟。
  2. 切换投票:已投票时再点会发送 DELETE 请求删除对应行,同时 UI 回退。localStorage 缓存记录每个 slug+emoji 的投票状态,避免每次都要查数据库。
  3. 防快速点击:请求期间给按钮添加 pending 类(pointer-events: none),阻止重复提交。

3. 注册调用点#

在 Layout.astro 中,initReactions() 需要在两个地方被调用:

// 初始页面加载
function init() {
loadTheme();
loadHue();
setTimeout(() => { initTwikoo() }, 100)
initReactions() // ← 添加
}
// swup 切页后
window.swup.hooks.on('page:view', () => {
// ... 其他初始化 ...
setTimeout(() => { initTwikoo() }, 100)
initReactions() // ← 添加
});

swup 切页时会替换 #swup-container 内的全部 HTML,新页面的 #post-reactions 元素带有 data-initialized="false"initReactions() 会检测到这个状态并重新执行初始化。旧页面的 AbortController 被 abort,防止过期请求更新错误的 DOM。


五、动画延迟#

编辑 src/styles/transition.css,在 #post-container :nth-child(6) 之后添加:

#post-container :nth-child(7) { animation-delay: calc(var(--content-delay) + 400ms) }

Fuwari 的文章内容区通过 nth-child 逐子元素设置递增的入场动画延迟,Reactions 作为第 7 个子元素需要补上对应的延迟,否则会没有渐入效果。


六、防刷策略总结#

本方案采用三层防护,不依赖用户登录:

层级机制作用
客户端localStorage 投票缓存同一浏览器即时阻止重复投票,无需查库
客户端浏览器指纹 SHA-256跨页面、跨 session 识别同一用户
数据库UNIQUE (post_slug, emoji, fingerprint) 约束即使绕过前端,数据库也会拒绝重复插入

客户端指纹不是密码学身份标识,清除 localStorage 或换浏览器就能绕过。对于个人博客来说这个防护等级足够。如果需要更严格的限制,可以在 Supabase Edge Function 中添加基于 IP 的频率限制,但需要额外配置。


七、注意事项#

  • Supabase 的 anon key 设计为公开使用,配合 RLS 策略不会暴露数据。不要使用 service_role key,那个有完全数据库权限。
  • 确保 .env 中的 PUBLIC_SUPABASE_URL 不包含 /rest/v1/ 后缀,代码会自动拼接这个路径。
  • 如果部署在 Cloudflare Pages,需要在 Pages 的环境变量设置中同步添加 Supabase 凭据,部署后重新构建。
  • Supabase 免费项目在 7 天无活动后会自动暂停,首次访问会有一两秒的冷启动延迟。
这篇文章对你有帮助吗?