跳转到内容

文章阅读次数统计

使用 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 配置

wrangler.jsonc 中添加 KV 命名空间配置:

{
"kv_namespaces": [
{
"binding": "mao_session",
"id": "your-kv-namespace-id",
"preview_id": "your-preview-kv-namespace-id"
}
]
}

注意:需要先在 Cloudflare Dashboard 创建 KV 命名空间,获取其 ID。

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;

public/scripts/pageviews.js 中添加前端逻辑:

/**
* 文章阅读次数统计组件
* 在文章底部显示阅读次数
*/
// 获取当前文章 slug
function 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.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;
}
方法端点说明
GET/api/pageviews?slug=xxx获取指定文章的阅读次数
POST/api/pageviews增加指定文章的阅读次数
OPTIONS/api/pageviewsCORS 预检请求
  • KV Key 格式pageview:<slug>
  • Value:阅读次数的字符串形式
  • 使用 sessionStorage 记录当前会话已访问的文章
  • 同一会话中对同一篇文章只计数一次
  • 刷新页面或关闭标签页后重新计数
  • 直接显示原始数字,不进行单位转换

使用以下命令进行本地测试:

Terminal window
npx astro build && npx wrangler dev

本地预览地址:http://127.0.0.1:8787

  1. KV 命名空间:需要先在 Cloudflare Dashboard 创建 KV 命名空间
  2. 会话去重:使用 sessionStorage,关闭标签页后重新计数
  3. CORS:API 支持跨域请求,可在其他域名下使用
  4. 移动端适配:响应式设计,自动适配各种屏幕尺寸

使用 satisfies APIRoute 操作符可以提供更好的类型检查和 IntelliSense 支持:

export const GET = (async ({ request }) => {
// ... 你的代码
}) satisfies APIRoute;

这样可以确保你的函数签名符合 Astro 的 APIRoute 类型要求。

  • API 路由:放在 src/pages/api/ 目录
  • 客户端脚本:放在 public/scripts/ 目录,通过 head 配置注入
  • 样式文件:放在 src/styles/ 目录,通过 customCss 配置引用