文章阅读次数统计
使用 Cloudflare KV 实现文章阅读次数统计
Section titled “使用 Cloudflare KV 实现文章阅读次数统计”本教程将介绍如何使用 Cloudflare KV 为 Astro Starlight 文档站点添加文章阅读次数统计功能。实现效果包括:
- 自动统计:页面加载时自动记录阅读次数
- 会话去重:同一会话只计数一次
- 持久化存储:使用 Cloudflare KV 存储数据
需要创建/修改以下文件:
├── public/│ └── scripts/│ └── pageviews.js # 前端统计脚本├── src/│ └── pages/│ └── api/│ └── pageviews.ts # API 后端└── wrangler.jsonc # Cloudflare 配置步骤一:配置 Cloudflare KV
Section titled “步骤一:配置 Cloudflare KV”在 wrangler.jsonc 中添加 KV 命名空间配置:
{ "kv_namespaces": [ { "binding": "mao_session", "id": "your-kv-namespace-id", "preview_id": "your-preview-kv-namespace-id" } ]}注意:需要先在 Cloudflare Dashboard 创建 KV 命名空间,获取其 ID。
步骤二:创建 API 后端
Section titled “步骤二:创建 API 后端”在 src/pages/api/pageviews.ts 中创建 API 端点:
// 禁用预渲染,让 API 路由在服务器端动态执行export const prerender = false;
import type { APIRoute } from 'astro';import { env } from 'cloudflare:workers';
// CORS 响应头const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,POST,OPTIONS", "Access-Control-Max-Age": "86400", "Content-Type": "application/json;charset=UTF-8",};
/** * OPTIONS /api/pageviews * 处理 CORS 预检请求 */export const OPTIONS = (async () => { return new Response(null, { headers: corsHeaders });}) satisfies APIRoute;
/** * GET /api/pageviews * 获取指定文章的阅读次数 * 查询参数:slug (文章路径) */export const GET = (async ({ request }) => { const url = new URL(request.url); const slug = url.searchParams.get('slug');
if (!slug) { return new Response(JSON.stringify({ error: 'Missing slug parameter' }), { status: 400, headers: corsHeaders, }); }
// KV key 格式:pageview:<slug> const key = `pageview:${slug}`;
// 使用 Cloudflare Workers env API 获取 KV binding const kv = env.mao_session; if (!kv) { return new Response(JSON.stringify({ error: 'KV binding not available' }), { status: 500, headers: corsHeaders, }); }
// 使用 KV API 读取数据 const value = await kv.get(key); const count = value ? parseInt(value, 10) : 0;
return new Response(JSON.stringify({ count }), { status: 200, headers: corsHeaders, });}) satisfies APIRoute;
/** * POST /api/pageviews * 增加指定文章的阅读次数 * 请求体:{ slug: string } */export const POST = (async ({ request }) => { try { const body = await request.json(); const { slug } = body;
if (!slug) { return new Response(JSON.stringify({ error: 'Missing slug parameter' }), { status: 400, headers: corsHeaders, }); }
// KV key 格式:pageview:<slug> const key = `pageview:${slug}`;
// 使用 Cloudflare Workers env API 获取 KV binding const kv = env.mao_session; if (!kv) { return new Response(JSON.stringify({ error: 'KV binding not available' }), { status: 500, headers: corsHeaders, }); }
// 使用 KV API 读取当前值 const currentValue = await kv.get(key); const currentCount = currentValue ? parseInt(currentValue, 10) : 0; const newCount = currentCount + 1;
// 使用 KV API 写入新值 await kv.put(key, newCount.toString());
return new Response(JSON.stringify({ count: newCount }), { status: 200, headers: corsHeaders, }); } catch (error) { return new Response(JSON.stringify({ error: 'Invalid JSON body' }), { status: 400, headers: corsHeaders, }); }}) satisfies APIRoute;步骤三:创建前端脚本
Section titled “步骤三:创建前端脚本”在 public/scripts/pageviews.js 中添加前端逻辑:
/** * 文章阅读次数统计组件 * 在文章底部显示阅读次数 */
// 获取当前文章 slugfunction getCurrentSlug() { return window.location.pathname;}
// 从 API 获取阅读次数async function fetchPageViews(slug) { try { const response = await fetch(`/api/pageviews?slug=${encodeURIComponent(slug)}`); if (!response.ok) throw new Error('Failed to fetch page views'); const data = await response.json(); return data.count || 0; } catch (error) { console.error('Error fetching page views:', error); return 0; }}
// 增加阅读次数async function incrementPageViews(slug) { try { const response = await fetch('/api/pageviews', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug }), });
if (!response.ok) throw new Error('Failed to increment page views'); const data = await response.json(); return data.count || 0; } catch (error) { console.error('Error incrementing page views:', error); return null; }}
// 检查是否已经统计过本次会话的阅读function hasViewedInSession(slug) { const key = `pageview_session:${slug}`; return sessionStorage.getItem(key) === 'true';}
// 标记本次会话已阅读function markViewedInSession(slug) { const key = `pageview_session:${slug}`; sessionStorage.setItem(key, 'true');}
// 创建阅读次数显示元素function createPageViewElement(count) { const span = document.createElement('span'); span.className = 'page-view-count'; span.textContent = `已阅:${count}`; return span;}
// 更新阅读次数显示function updatePageViewDisplay(element, count) { if (element) { element.textContent = `已阅:${count}`; }}
// 初始化阅读次数显示async function initPageViews() { const slug = getCurrentSlug();
// 排除首页 if (slug === '/' || slug === '/index.html' || slug === '/index') { return; }
// 等待 DOM 完全加载 await new Promise(resolve => setTimeout(resolve, 100));
// 查找页脚中的 .meta 容器 const footer = document.querySelector('main footer, .sl-markdown-content + footer'); if (!footer) { console.log('PageViews: Footer not found'); return; }
const metaContainer = footer.querySelector('.meta'); if (!metaContainer) { console.log('PageViews: .meta container not found'); return; }
// 查找"最近更新"元素 const lastUpdatedElement = metaContainer.querySelector('p'); if (!lastUpdatedElement) { console.log('PageViews: Last updated <p> element not found'); return; }
// 检查是否已存在阅读次数显示 let pageViewElement = metaContainer.querySelector('.page-view-count');
if (!pageViewElement) { // 创建阅读次数元素 pageViewElement = createPageViewElement(0);
// 在"最近更新"元素之前插入阅读次数 metaContainer.insertBefore(pageViewElement, lastUpdatedElement); }
// 设置 meta 容器样式 if (!metaContainer.classList.contains('page-view-wrapper')) { metaContainer.classList.add('page-view-wrapper'); }
// 获取当前阅读次数 const currentCount = await fetchPageViews(slug); updatePageViewDisplay(pageViewElement, currentCount);
// 如果是新会话,增加阅读次数 if (!hasViewedInSession(slug)) { const newCount = await incrementPageViews(slug); if (newCount !== null) { updatePageViewDisplay(pageViewElement, newCount); } markViewedInSession(slug); }}
// 当 DOM 加载完成后初始化if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initPageViews);} else { initPageViews();}
// 监听 SPA 导航(如果适用)window.addEventListener('popstate', () => { setTimeout(initPageViews, 100);});
// 监听 Astro 页面加载事件(用于 SPA 导航)document.addEventListener('astro:page-load', () => { setTimeout(initPageViews, 100);});步骤四:配置 Astro 加载脚本
Section titled “步骤四:配置 Astro 加载脚本”在 astro.config.mjs 中,将脚本注入到页面头部:
import starlight from '@astrojs/starlight';import { defineConfig } from 'astro/config';
export default defineConfig({ integrations: [ starlight({ title: 'My Docs', // 注入自定义脚本 head: [ { tag: 'script', attrs: { src: '/scripts/pageviews.js' }, }, ], }), ],});步骤五:添加自定义样式(可选)
Section titled “步骤五:添加自定义样式(可选)”在 src/styles/custom.css 中添加样式:
/* 阅读次数显示样式 */.page-view-count { color: var(--color-sidebarItemText); font-size: 0.875rem; margin-right: 1rem;}
.meta { display: flex; align-items: center; flex-wrap: wrap; gap: 0.5rem;}API 端点
Section titled “API 端点”| 方法 | 端点 | 说明 |
|---|---|---|
| GET | /api/pageviews?slug=xxx | 获取指定文章的阅读次数 |
| POST | /api/pageviews | 增加指定文章的阅读次数 |
| OPTIONS | /api/pageviews | CORS 预检请求 |
- KV Key 格式:
pageview:<slug> - Value:阅读次数的字符串形式
- 使用
sessionStorage记录当前会话已访问的文章 - 同一会话中对同一篇文章只计数一次
- 刷新页面或关闭标签页后重新计数
- 直接显示原始数字,不进行单位转换
使用以下命令进行本地测试:
npx astro build && npx wrangler dev本地预览地址:http://127.0.0.1:8787
- KV 命名空间:需要先在 Cloudflare Dashboard 创建 KV 命名空间
- 会话去重:使用 sessionStorage,关闭标签页后重新计数
- CORS:API 支持跨域请求,可在其他域名下使用
- 移动端适配:响应式设计,自动适配各种屏幕尺寸
使用 satisfies APIRoute 操作符可以提供更好的类型检查和 IntelliSense 支持:
export const GET = (async ({ request }) => { // ... 你的代码}) satisfies APIRoute;这样可以确保你的函数签名符合 Astro 的 APIRoute 类型要求。
- API 路由:放在
src/pages/api/目录 - 客户端脚本:放在
public/scripts/目录,通过head配置注入 - 样式文件:放在
src/styles/目录,通过customCss配置引用