本文介绍如何使用 Cloudflare Workers + KV 为静态网站添加点赞和访问统计功能。基于 hugo-bearneo 的 Upvote 功能扩展而来,支持:
- ✅ 点赞功能:用户对文章点赞,统计点赞数,记录点赞状态
- ✅ 访问统计:自动记录文章访问次数,防刷机制
- ✅ 完全免费:使用 Cloudflare 免费额度
- ✅ 隐私保护:使用哈希保护用户身份
- ✅ 易于部署:无需数据库,几分钟完成部署
部署指南
部署 Worker
注册/登录 Cloudflare 后台,前往 Workers 模块后点击 Create(下图 2 处)。

点击 Create Worker(下图 3 处)。

随便输入一个名称(比如 post-upvote)后点击 Deploy(下图 5 处)。

然后点击 Edit code(下图 6 处)。

删除代码编辑器(下图 7 处)中原有的代码,将本项目 worker.js 中的代码完全复制粘贴到代码编辑器中,点击 Deploy(下图 8 处)。

创建 KV namespace
注册/登录 Cloudflare 后台,前往 KV 模块后点击 Create(下图 10 处)。

随便输入一个名称(比如 upvote-count)后点击 Add(下图 12 处)。

用相同的步骤再创建一个 KV namespace,依然可以随便命名(比如 upvote-record)。
为 Worker 绑定 KV namespace
注册/登录 Cloudflare 后台,前往 Workers 模块后点击进入刚刚创建的 Worker(如本案例中下图 14 处的 post-upvote)。

前往该 Worker 中的 Settings -> Bindings,点击 Add(下图 17 处)。

选择 KV namespace 后输入 Variable name 为 UPVOTE_COUNT,然后选择一个刚刚创建的 KV namespace(比如 upvote-count),随后点击 Save(下图 20 处)。

用相同的步骤再创建一个 Variable name 为 UPVOTE_RECORD,选择刚刚创建的另一个 KV namespace(比如 upvote-record),随后点击 Save。
正确的配置应如下图 21 处,Variable name(即 UPVOTE_COUNT 和 UPVOTE_RECORD)一定不能错。

测试
注册/登录 Cloudflare 后台,前往 Workers 模块后点击进入刚刚创建的 Worker,进入该 Worker 中的 Settings -> Domains & Routes,此处默认启用的 workers.dev 域名后对应的 Value(下图 22 处)即为该 Worker 的域名。
或者直接点击下图 23 处访问该 Worker 的域名。

通过浏览器访问该 Worker 的域名后如果能看到如下图提示即为部署成功。

注意事项
中国境内可能无法顺畅访问 Cloudflare Workers 的 workers.dev 域名,可以通过为该 Worker 添加一个自定义域名解决。

通用集成方法
部署完成后,你可以在任何静态网站中集成这个点赞功能。以下是通用的前端集成方法:
API 接口说明
假设你的 Worker 域名为 https://your-worker.workers.dev,API 提供以下接口:
1. 获取文章点赞数
GET https://your-worker.workers.dev/upvote?postId=your-post-id
2. 点赞/取消点赞
POST https://your-worker.workers.dev/upvote
Content-Type: application/json
{
"postId": "your-post-id"
}
前端实现示例
以下是一个简单的 HTML + JavaScript 实现示例:
<div class="upvote-container">
<button id="upvote-btn" class="upvote-button">
<span class="icon">👍</span>
<span id="upvote-count">0</span>
</button>
</div>
<script>
// 配置你的 Worker 域名
const API_URL = "https://your-worker.workers.dev/upvote";
// 使用文章的唯一标识作为 postId(如 slug、路径等)
const POST_ID = window.location.pathname;
// 获取点赞数
async function getUpvoteCount() {
try {
const response = await fetch(
`${API_URL}?postId=${encodeURIComponent(POST_ID)}`
);
const data = await response.json();
document.getElementById("upvote-count").textContent = data.count || 0;
// 根据用户是否已点赞更新按钮状态
const button = document.getElementById("upvote-btn");
if (data.upvoted) {
button.classList.add("upvoted");
}
} catch (error) {
console.error("获取点赞数失败:", error);
}
}
// 点赞/取消点赞
async function toggleUpvote() {
const button = document.getElementById("upvote-btn");
button.disabled = true;
try {
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ postId: POST_ID }),
});
const data = await response.json();
// 更新点赞数
document.getElementById("upvote-count").textContent = data.count || 0;
// 更新按钮状态
if (data.upvoted) {
button.classList.add("upvoted");
} else {
button.classList.remove("upvoted");
}
} catch (error) {
console.error("点赞操作失败:", error);
} finally {
button.disabled = false;
}
}
// 页面加载时获取点赞数
getUpvoteCount();
// 绑定点击事件
document.getElementById("upvote-btn").addEventListener("click", toggleUpvote);
</script>
<style>
.upvote-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: 2px solid #e5e7eb;
background: white;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
font-size: 16px;
}
.upvote-button:hover {
border-color: #3b82f6;
background: #eff6ff;
}
.upvote-button.upvoted {
border-color: #3b82f6;
background: #3b82f6;
color: white;
}
.upvote-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
框架集成示例
React / Next.js
import { useState, useEffect } from "react";
export default function UpvoteButton({ postId }) {
const [count, setCount] = useState(0);
const [upvoted, setUpvoted] = useState(false);
const [loading, setLoading] = useState(false);
const API_URL = "https://your-worker.workers.dev/upvote";
useEffect(() => {
fetchUpvoteData();
}, [postId]);
async function fetchUpvoteData() {
try {
const response = await fetch(
`${API_URL}?postId=${encodeURIComponent(postId)}`
);
const data = await response.json();
setCount(data.count || 0);
setUpvoted(data.upvoted || false);
} catch (error) {
console.error("获取点赞数失败:", error);
}
}
async function handleUpvote() {
setLoading(true);
try {
const response = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ postId }),
});
const data = await response.json();
setCount(data.count || 0);
setUpvoted(data.upvoted || false);
} catch (error) {
console.error("点赞操作失败:", error);
} finally {
setLoading(false);
}
}
return (
<button
onClick={handleUpvote}
disabled={loading}
className={upvoted ? "upvoted" : ""}
>
<span>👍</span>
<span>{count}</span>
</button>
);
}
Vue / Nuxt
<template>
<button
@click="toggleUpvote"
:disabled="loading"
:class="{ upvoted }"
class="upvote-button"
>
<span>👍</span>
<span>{{ count }}</span>
</button>
</template>
<script setup>
import { ref, onMounted } from "vue";
const props = defineProps({
postId: String,
});
const API_URL = "https://your-worker.workers.dev/upvote";
const count = ref(0);
const upvoted = ref(false);
const loading = ref(false);
async function fetchUpvoteData() {
try {
const response = await fetch(
`${API_URL}?postId=${encodeURIComponent(props.postId)}`
);
const data = await response.json();
count.value = data.count || 0;
upvoted.value = data.upvoted || false;
} catch (error) {
console.error("获取点赞数失败:", error);
}
}
async function toggleUpvote() {
loading.value = true;
try {
const response = await fetch(API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ postId: props.postId }),
});
const data = await response.json();
count.value = data.count || 0;
upvoted.value = data.upvoted || false;
} catch (error) {
console.error("点赞操作失败:", error);
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchUpvoteData();
});
</script>
如何在 hugo 中启用 Upvote 功能?
详见 hugo-bearneo 提供的 使用指南。
自定义域名配置
如果你想使用自定义域名(推荐),可以在 Cloudflare Workers 的 Settings -> Domains & Routes 中添加自定义域名,这样可以避免 workers.dev 域名在国内访问不畅的问题。
扩展功能:添加访问统计
在点赞功能的基础上,我们可以轻松添加访问统计功能,无需创建新的 KV namespace。
更新 Worker 代码
将以下完整代码替换到你的 Worker 中(包含点赞 + 访问统计):
// Cloudflare Worker - 点赞 + 访问统计
addEventListener("fetch", event => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
const url = new URL(request.url);
const path = url.pathname;
// CORS 头
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
// 处理 OPTIONS 请求
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
// 路由处理
if (path === "/like" || path === "/upvote") {
return handleLike(request, corsHeaders);
} else if (path === "/count") {
return handleGetCount(request, corsHeaders);
} else if (path === "/view" || path === "/pageview") {
return handlePageView(request, corsHeaders);
} else if (path === "/stats") {
return handleGetStats(request, corsHeaders);
} else {
return new Response(
JSON.stringify({
code: 0,
message: "API is running",
endpoints: {
like: "POST /like - 点赞/取消点赞",
count: "GET /count?post=xxx - 获取点赞数",
view: "POST /view - 记录访问",
stats: "GET /stats?post=xxx - 获取统计数据(点赞+访问)",
},
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
}
// 处理点赞
async function handleLike(request, corsHeaders) {
if (request.method !== "POST") {
return new Response(
JSON.stringify({ code: 1, message: "Method not allowed" }),
{
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
try {
const body = await request.json();
const postId = body.postId || body.post;
if (!postId) {
return new Response(
JSON.stringify({ code: 1, message: "Missing postId" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
// 获取用户标识(IP + User-Agent)
const ip = request.headers.get("CF-Connecting-IP") || "unknown";
const userAgent = request.headers.get("User-Agent") || "unknown";
const userId = await hashString(`${ip}-${userAgent}`);
// 检查是否已点赞
const recordKey = `like:${postId}:${userId}`;
const hasLiked = await UPVOTE_RECORD.get(recordKey);
let newCount;
if (hasLiked) {
// 取消点赞
await UPVOTE_RECORD.delete(recordKey);
const countKey = `like:count:${postId}`;
const currentCount = parseInt((await UPVOTE_COUNT.get(countKey)) || "0");
newCount = Math.max(0, currentCount - 1);
await UPVOTE_COUNT.put(countKey, newCount.toString());
} else {
// 点赞
await UPVOTE_RECORD.put(recordKey, "1", { expirationTtl: 31536000 });
const countKey = `like:count:${postId}`;
const currentCount = parseInt((await UPVOTE_COUNT.get(countKey)) || "0");
newCount = currentCount + 1;
await UPVOTE_COUNT.put(countKey, newCount.toString());
}
return new Response(
JSON.stringify({
code: 0,
data: {
count: newCount,
hasLiked: !hasLiked,
},
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
} catch (error) {
return new Response(JSON.stringify({ code: 1, message: error.message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
}
// 获取点赞数
async function handleGetCount(request, corsHeaders) {
const url = new URL(request.url);
const postId = url.searchParams.get("post") || url.searchParams.get("postId");
if (!postId) {
return new Response(
JSON.stringify({ code: 1, message: "Missing post parameter" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
try {
const countKey = `like:count:${postId}`;
const count = parseInt((await UPVOTE_COUNT.get(countKey)) || "0");
const ip = request.headers.get("CF-Connecting-IP") || "unknown";
const userAgent = request.headers.get("User-Agent") || "unknown";
const userId = await hashString(`${ip}-${userAgent}`);
const recordKey = `like:${postId}:${userId}`;
const hasLiked = !!(await UPVOTE_RECORD.get(recordKey));
return new Response(
JSON.stringify({
code: 0,
data: { count, hasLiked },
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
} catch (error) {
return new Response(JSON.stringify({ code: 1, message: error.message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
}
// 记录页面访问
async function handlePageView(request, corsHeaders) {
if (request.method !== "POST") {
return new Response(
JSON.stringify({ code: 1, message: "Method not allowed" }),
{
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
try {
const body = await request.json();
const postId = body.postId || body.post;
if (!postId) {
return new Response(
JSON.stringify({ code: 1, message: "Missing postId" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
const ip = request.headers.get("CF-Connecting-IP") || "unknown";
const userAgent = request.headers.get("User-Agent") || "unknown";
const userId = await hashString(`${ip}-${userAgent}`);
// 检查是否在5分钟内重复访问(防刷)
const viewRecordKey = `view:record:${postId}:${userId}`;
const recentView = await UPVOTE_RECORD.get(viewRecordKey);
if (!recentView) {
// 记录访问(5分钟内不重复计数)
await UPVOTE_RECORD.put(viewRecordKey, "1", { expirationTtl: 300 });
// 增加访问计数
const countKey = `view:count:${postId}`;
const currentCount = parseInt((await UPVOTE_COUNT.get(countKey)) || "0");
const newCount = currentCount + 1;
await UPVOTE_COUNT.put(countKey, newCount.toString());
return new Response(
JSON.stringify({
code: 0,
data: { views: newCount, counted: true },
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
} else {
const countKey = `view:count:${postId}`;
const currentCount = parseInt((await UPVOTE_COUNT.get(countKey)) || "0");
return new Response(
JSON.stringify({
code: 0,
data: { views: currentCount, counted: false },
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
} catch (error) {
return new Response(JSON.stringify({ code: 1, message: error.message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
}
// 获取统计数据(点赞 + 访问)
async function handleGetStats(request, corsHeaders) {
const url = new URL(request.url);
const postId = url.searchParams.get("post") || url.searchParams.get("postId");
if (!postId) {
return new Response(
JSON.stringify({ code: 1, message: "Missing post parameter" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
try {
const likeCountKey = `like:count:${postId}`;
const likeCount = parseInt((await UPVOTE_COUNT.get(likeCountKey)) || "0");
const viewCountKey = `view:count:${postId}`;
const viewCount = parseInt((await UPVOTE_COUNT.get(viewCountKey)) || "0");
const ip = request.headers.get("CF-Connecting-IP") || "unknown";
const userAgent = request.headers.get("User-Agent") || "unknown";
const userId = await hashString(`${ip}-${userAgent}`);
const recordKey = `like:${postId}:${userId}`;
const hasLiked = !!(await UPVOTE_RECORD.get(recordKey));
return new Response(
JSON.stringify({
code: 0,
data: { likes: likeCount, views: viewCount, hasLiked },
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
} catch (error) {
return new Response(JSON.stringify({ code: 1, message: error.message }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
}
// 哈希函数
async function hashString(str) {
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("");
}
API 接口说明
更新后的 API 提供以下接口:
1. 点赞/取消点赞
POST /like
Content-Type: application/json
{
"postId": "your-post-id"
}
2. 获取点赞数
GET /count?post=your-post-id
3. 记录页面访问(新增)
POST /view
Content-Type: application/json
{
"postId": "your-post-id"
}
4. 获取完整统计(新增)
GET /stats?post=your-post-id
返回:
{
"code": 0,
"data": {
"likes": 10,
"views": 123,
"hasLiked": false
}
}
数据存储说明
无需创建新的 KV namespace,通过 Key 前缀区分数据类型:
在 UPVOTE_COUNT 中:
like:count:{postId}- 点赞计数view:count:{postId}- 访问计数
在 UPVOTE_RECORD 中:
like:{postId}:{userId}- 点赞记录(1年过期)view:record:{postId}:{userId}- 访问记录(5分钟过期,防刷)
前端集成示例(Astro)
创建访问统计组件 src/components/ViewCount.astro:
---
import { siteConfig } from "../config";
interface Props {
postId: string;
}
const { postId } = Astro.props;
const apiUrl = siteConfig.like.apiUrl;
---
<span class="view-count" id="view-count-container">
<span id="view-count">--</span>
<span>次阅读</span>
</span>
<script is:inline define:vars={{ postId, apiUrl }}>
(function () {
let viewRecorded = false;
function initViewCount() {
fetchViewCount();
if (!viewRecorded) {
setTimeout(() => {
recordPageView();
viewRecorded = true;
}, 2000);
}
}
async function fetchViewCount() {
try {
const response = await fetch(
`${apiUrl}/stats?post=${encodeURIComponent(postId)}`
);
const data = await response.json();
if (data.code === 0) {
document.getElementById("view-count").textContent =
data.data.views || 0;
}
} catch (error) {
console.error("获取访问次数失败:", error);
}
}
async function recordPageView() {
try {
await fetch(`${apiUrl}/view`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ postId }),
});
} catch (error) {
console.error("记录访问失败:", error);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initViewCount);
} else {
initViewCount();
}
document.addEventListener("astro:page-load", () => {
viewRecorded = false;
initViewCount();
});
})();
</script>
在文章页面使用:
---
import ViewCount from "../../components/ViewCount.astro";
---
<div class="article-meta">
<time>{formatDate(post.data.date)}</time>
<span>|</span>
<span>{readingTime} 分钟</span>
<span>|</span>
<ViewCount postId={post.slug} />
</div>
功能特点
访问统计:
- ✅ 自动记录页面访问
- ✅ 5分钟内同一用户不重复计数
- ✅ 使用 SHA-256 哈希保护隐私
- ✅ 页面加载2秒后记录(避免误计数)
性能优化:
- ✅ 异步记录,不阻塞页面
- ✅ 复用现有 KV,零额外成本
- ✅ 支持 Astro View Transitions
测试
测试访问统计:
# 记录访问
curl -X POST https://your-worker.workers.dev/view \
-H "Content-Type: application/json" \
-d '{"postId": "test-post"}'
# 获取统计
curl "https://your-worker.workers.dev/stats?post=test-post"
注意事项
Cloudflare Workers 免费额度:
- 每天 100,000 次请求
- 每天 100,000 次 KV 读取
- 每天 1,000 次 KV 写入
对于个人博客完全够用。如果流量较大,建议:
- 增加防刷时间(如10分钟)
- 使用 Cloudflare Analytics 获取更详细数据
- 升级到付费计划
鸣谢
-
感谢 bearblog 创造了 Bear Blog,感谢 hugo-bearblog 将 Bear Blog 带到了 Hugo,更感谢 Rokcso 丰富了 Hugo Bearblog。
-
感谢 Cloudflare 提供了本项目得以实现的所有功能和资源。