2040 字
10 分钟
实践-给 Astro 静态博客加上浏览量和点赞

写在前面#

Astro 静态博客,构建完就是一堆 HTML 文件,没有后端服务器。想要记录浏览量?想要保存点赞?这些都需要数据库啊!

传统方案要么是自己写个后端 API,要么用 Serverless Functions。但根据我咨询 AI 的结果,我们选择第三方方案:Supabase

supabase
/
supabase
Waiting for api.github.com...
00K
0K
0K
Waiting...

技术架构#

先看看整体思路:

用户访问页面
Svelte 组件加载(客户端)
调用 Supabase API
PostgreSQL 数据库
返回统计数据

核心就是:用 Supabase 的数据库存数据,用它的 JavaScript SDK 调接口。页面是静态的,但 JavaScript 可以动态请求数据。

数据库设计#

需要三张表:

  1. page_stats - 统计主表

    • page_path: 页面路径
    • view_count: 浏览量
    • like_count: 点赞数
  2. page_views - 浏览记录(防刷用)

    • page_path: 页面路径
    • fingerprint: 设备指纹
    • viewed_at: 浏览时间
  3. 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;

这样做的好处是逻辑都在数据库层,前端只管调用,不用担心并发。

防刷方案#

生成设备指纹:

src/lib/fingerprint.ts
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 的调用封装一下:

src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
import { supabaseConfig } from '../config';
export const supabase = createClient(
supabaseConfig.url,
supabaseConfig.anonKey
);
src/lib/stats-api.ts
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 组件,保持风格统一):

src/components/stats/PageStats.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>

集成到页面#

最后在文章页面引入:

src/pages/posts/[...slug].astro
---
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 组件在客户端加载。

配置管理#

把配置集中管理:

src/config.ts
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.co
PUBLIC_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 小时内重复访问不会增加计数,基本能反映真实访问量。

后续优化#

目前的方案已经够用,但还有优化空间:

  1. 更强的防刷:可以加入 IP 检测、速率限制
  2. 数据分析:记录访问时间、来源页面等
  3. 热门排行:根据浏览量/点赞生成热门文章列表
  4. 评论联动:把评论数也显示出来

不过暂时没必要搞太复杂~毕竟只是个人博客

总结#

静态博客想要动态功能,用 Supabase 这类 BaaS 服务是个不错的选择。不用管服务器,不用写后端,专注于前端逻辑就好。

⚠️ 注:本文部分内容借助AI辅助生成,请注意自主辨别信息准确性。
实践-给 Astro 静态博客加上浏览量和点赞
https://blog.stivine.fun/posts/practice-add-view-count-and-likes-to-an-astro-static-blog/
作者
藤君
发布于
2026-02-08
许可协议
CC BY-NC-SA 4.0