写在前面
Astro 静态博客,构建完就是一堆 HTML 文件,没有后端服务器。想要记录浏览量?想要保存点赞?这些都需要数据库啊!
传统方案要么是自己写个后端 API,要么用 Serverless Functions。但根据我咨询 AI 的结果,我们选择第三方方案:Supabase。
技术架构
先看看整体思路:
用户访问页面 ↓Svelte 组件加载(客户端) ↓调用 Supabase API ↓PostgreSQL 数据库 ↓返回统计数据核心就是:用 Supabase 的数据库存数据,用它的 JavaScript SDK 调接口。页面是静态的,但 JavaScript 可以动态请求数据。
数据库设计
需要三张表:
-
page_stats - 统计主表
- page_path: 页面路径
- view_count: 浏览量
- like_count: 点赞数
-
page_views - 浏览记录(防刷用)
- page_path: 页面路径
- fingerprint: 设备指纹
- viewed_at: 浏览时间
-
page_likes - 点赞记录(防刷 + 取消点赞)
- page_path: 页面路径
- fingerprint: 设备指纹
- 唯一约束:一个设备只能点赞一次
核心 SQL
创建表的 SQL 不贴完整的了,几个关键点:
-- 统计表,page_path 做唯一索引CREATE TABLE page_stats ( page_path VARCHAR(500) UNIQUE NOT NULL, view_count INTEGER DEFAULT 0, like_count INTEGER DEFAULT 0);
-- 点赞表,防止重复点赞CREATE TABLE page_likes ( page_path VARCHAR(500) NOT NULL, fingerprint VARCHAR(100) NOT NULL, CONSTRAINT unique_like UNIQUE (page_path, fingerprint));重点是用数据库函数做原子操作,避免并发问题:
-- 增加浏览量(1小时内同设备不重复计数)CREATE FUNCTION increment_view_count( p_page_path VARCHAR(500), p_fingerprint VARCHAR(100)) RETURNS BOOLEAN AS $$BEGIN -- 检查最近1小时是否访问过 IF EXISTS( SELECT 1 FROM page_views WHERE page_path = p_page_path AND fingerprint = p_fingerprint AND viewed_at > NOW() - INTERVAL '1 hour' ) THEN RETURN FALSE; END IF;
-- 插入访问记录 INSERT INTO page_views (page_path, fingerprint) VALUES (p_page_path, p_fingerprint);
-- 更新统计(没有记录就创建,有就+1) INSERT INTO page_stats (page_path, view_count) VALUES (p_page_path, 1) ON CONFLICT (page_path) DO UPDATE SET view_count = page_stats.view_count + 1;
RETURN TRUE;END;$$ LANGUAGE plpgsql;点赞的逻辑类似,但要支持取消:
-- 切换点赞状态CREATE FUNCTION toggle_like( p_page_path VARCHAR(500), p_fingerprint VARCHAR(100)) RETURNS JSON AS $$DECLARE like_exists BOOLEAN; new_count INTEGER;BEGIN -- 检查是否已点赞 SELECT EXISTS( SELECT 1 FROM page_likes WHERE page_path = p_page_path AND fingerprint = p_fingerprint ) INTO like_exists;
IF like_exists THEN -- 取消点赞 DELETE FROM page_likes WHERE page_path = p_page_path AND fingerprint = p_fingerprint;
UPDATE page_stats SET like_count = GREATEST(like_count - 1, 0) WHERE page_path = p_page_path RETURNING like_count INTO new_count;
RETURN json_build_object('liked', false, 'like_count', COALESCE(new_count, 0)); ELSE -- 添加点赞 INSERT INTO page_likes (page_path, fingerprint) VALUES (p_page_path, p_fingerprint);
INSERT INTO page_stats (page_path, like_count) VALUES (p_page_path, 1) ON CONFLICT (page_path) DO UPDATE SET like_count = page_stats.like_count + 1 RETURNING like_count INTO new_count;
RETURN json_build_object('liked', true, 'like_count', new_count); END IF;END;$$ LANGUAGE plpgsql;这样做的好处是逻辑都在数据库层,前端只管调用,不用担心并发。
防刷方案
生成设备指纹:
export async function generateFingerprint(): Promise<string> { const components: string[] = [];
// 收集浏览器特征 components.push(`${screen.width}x${screen.height}x${screen.colorDepth}`); components.push(Intl.DateTimeFormat().resolvedOptions().timeZone); components.push(navigator.language); components.push(navigator.platform); components.push(String(navigator.hardwareConcurrency || 0));
// Canvas 指纹 try { const canvas = document.createElement('canvas'); 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('Hello, world!', 2, 15); components.push(canvas.toDataURL().slice(-50)); } } catch (e) { components.push('canvas-error'); }
// 组合并哈希 const fingerprint = components.join('|'); return await hashString(fingerprint);}
async function hashString(str: string): Promise<string> { const encoder = new TextEncoder(); const data = encoder.encode(str); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');}
// 缓存到 localStorage,避免重复计算export async function getFingerprint(): Promise<string> { const cached = localStorage.getItem('device_fingerprint'); if (cached) return cached;
const fingerprint = await generateFingerprint(); localStorage.setItem('device_fingerprint', fingerprint); return fingerprint;}前端实现
API 封装
先把 Supabase 的调用封装一下:
import { createClient } from '@supabase/supabase-js';import { supabaseConfig } from '../config';
export const supabase = createClient( supabaseConfig.url, supabaseConfig.anonKey);import { supabase } from './supabase';import { getFingerprint } from './fingerprint';
export interface PageStats { viewCount: number; likeCount: number; liked: boolean;}
export async function getPageStats(pagePath: string): Promise<PageStats | null> { try { const fingerprint = await getFingerprint(); const { data, error } = await supabase.rpc('get_page_stats', { p_page_path: pagePath, p_fingerprint: fingerprint, });
if (error) throw error;
return { viewCount: data.view_count || 0, likeCount: data.like_count || 0, liked: data.liked || false, }; } catch (error) { console.error('Failed to fetch stats:', error); return null; }}
export async function incrementViewCount(pagePath: string): Promise<boolean> { try { const fingerprint = await getFingerprint(); const { data, error } = await supabase.rpc('increment_view_count', { p_page_path: pagePath, p_fingerprint: fingerprint, p_user_agent: navigator.userAgent, });
if (error) throw error; return data === true; } catch (error) { console.error('Failed to increment view count:', error); return false; }}
export async function toggleLike(pagePath: string) { try { const fingerprint = await getFingerprint(); const { data, error } = await supabase.rpc('toggle_like', { p_page_path: pagePath, p_fingerprint: fingerprint, });
if (error) throw error; return { liked: data.liked, likeCount: data.like_count, }; } catch (error) { console.error('Failed to toggle like:', error); return null; }}Svelte 组件
这里用 Svelte 写组件(因为项目里有其他 Svelte 组件,保持风格统一):
<script lang="ts">import { onMount } from 'svelte';import { getPageStats, incrementViewCount, toggleLike } from '../../lib/stats-api';
export let pagePath: string;
let viewCount = 0;let likeCount = 0;let liked = false;let loading = true;let liking = false;
onMount(async () => { // 加载统计信息 const stats = await getPageStats(pagePath); if (stats) { viewCount = stats.viewCount; likeCount = stats.likeCount; liked = stats.liked; } loading = false;
// 异步增加浏览量(不阻塞渲染) incrementViewCount(pagePath).catch(console.warn);});
async function handleLike() { if (liking) return;
liking = true; const result = await toggleLike(pagePath);
if (result) { liked = result.liked; likeCount = result.likeCount; }
liking = false;}</script>
{#if loading} <!-- 骨架屏 --> <div class="stats-loading"> <div class="skeleton"></div> </div>{:else} <div class="page-stats"> <!-- 浏览量 --> <div class="stat-item"> <div class="stat-icon"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/> <circle cx="12" cy="12" r="3"/> </svg> </div> <span>{viewCount.toLocaleString()}</span> </div>
<!-- 点赞 --> <button class="like-button" class:liked on:click={handleLike} disabled={liking} > <div class="stat-icon" class:liked-icon={liked}> <svg width="16" height="16" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor"> <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/> </svg> </div> <span>{likeCount.toLocaleString()}</span> </button> </div>{/if}
<style> .page-stats { display: flex; gap: 1.25rem; color: rgba(0, 0, 0, 0.3); }
:global(.dark) .page-stats { color: rgba(255, 255, 255, 0.3); }
.stat-item, .like-button { display: flex; align-items: center; gap: 0.5rem; }
.stat-icon { display: flex; align-items: center; justify-content: center; width: 1.5rem; height: 1.5rem; border-radius: 0.375rem; background: rgba(0, 0, 0, 0.05); transition: all 0.2s; }
:global(.dark) .stat-icon { background: rgba(255, 255, 255, 0.1); }
.like-button { border: none; background: transparent; padding: 0; cursor: pointer; color: inherit; }
.like-button:hover { color: rgb(239, 68, 68); }
.like-button:active { transform: scale(0.95); }
.liked { color: rgb(239, 68, 68); }
.liked-icon { background: rgb(239, 68, 68) !important; color: white !important; }
.skeleton { width: 3rem; height: 1rem; background: rgba(0, 0, 0, 0.05); border-radius: 0.25rem; animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }</style>集成到页面
最后在文章页面引入:
---import PageStats from '@components/stats/PageStats.astro';// ... 其他 imports---
<div class="post-meta"> <!-- 原有的字数、阅读时间 --> <div>📝 {words} 字</div> <div>⏱️ {minutes} 分钟</div>
<!-- 新增:浏览量和点赞 --> <PageStats path={`/posts/${entry.slug}`} /></div>注意这里用的是 client:load 指令(在 Astro 包装组件里设置),让 Svelte 组件在客户端加载。
配置管理
把配置集中管理:
export const supabaseConfig = { enable: true, // 总开关 url: import.meta.env.PUBLIC_SUPABASE_URL || '', anonKey: import.meta.env.PUBLIC_SUPABASE_ANON_KEY || '', features: { viewCount: true, // 显示浏览量 likeButton: true, // 显示点赞 }, antiSpam: { viewCooldown: 3600, // 浏览冷却时间(秒) },};环境变量放在 .env:
PUBLIC_SUPABASE_URL=https://xxxxx.supabase.coPUBLIC_SUPABASE_ANON_KEY=你的密钥部署注意事项
静态站点的特殊性
因为 Astro 是在构建时读取环境变量的,所以:
本地开发:创建 .env 文件
CI/CD 部署:在构建平台配置环境变量
- Vercel/Netlify:在后台添加环境变量
- GitHub Actions:添加 Secrets,然后在工作流中注入:
- name: Build run: npm run build env: PUBLIC_SUPABASE_URL: ${{ secrets.PUBLIC_SUPABASE_URL }} PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.PUBLIC_SUPABASE_ANON_KEY }}Supabase 安全配置
记得启用 Row Level Security (RLS):
ALTER TABLE page_stats ENABLE ROW LEVEL SECURITY;
-- 允许所有人读取和写入(通过 anon key)CREATE POLICY "Public access" ON page_stats FOR ALL USING (true);anon key 是设计为可以公开的,真正的权限控制靠 RLS 策略。
效果展示
最终效果就是文章顶部多了两个小图标:
📝 1234 字 ⏱️ 5 分钟 👁️ 42 ❤️ 3点击心形图标可以点赞,再点就取消。刷新页面后状态保持。
浏览量方面,同一设备 1 小时内重复访问不会增加计数,基本能反映真实访问量。
后续优化
目前的方案已经够用,但还有优化空间:
- 更强的防刷:可以加入 IP 检测、速率限制
- 数据分析:记录访问时间、来源页面等
- 热门排行:根据浏览量/点赞生成热门文章列表
- 评论联动:把评论数也显示出来
不过暂时没必要搞太复杂~毕竟只是个人博客
总结
静态博客想要动态功能,用 Supabase 这类 BaaS 服务是个不错的选择。不用管服务器,不用写后端,专注于前端逻辑就好。