场景
想在自己网站上加个访问统计,但不想装那些拖慢加载第三方脚本。Google Analytics 太重,免费的又不放心。这时候 Cloudflare Workers 刚好合适—边缘节点执行,数据存在 KV 里,没有服务器成本。
本文做是一个统计 API,功能很简单:
POST /api/track— 记录一次访问GET /api/stats/:page— 查询某页面访问量GET /api/stats— 全站概览
最终效果是可以在任意静态页面用一行代码接入,后端完全不依赖服务器。
为什么选 Workers + KV
传统方案是后端 + 数据库。问题是:服务器要花钱,要维护,要防攻击。
Workers 的好处:
- 免费额度够用:每天 10 万次请求,KV 读写各 10 万次
- 边缘执行:访问记录从访客最近节点写入,延迟低
- KV 天然分布式:不用管数据库并发和一致性,CF 替你搞定
- 没有服务器:不用买 VPS,不用装环境
缺点也要说清楚:KV 写入有延迟(通常毫秒级,偶尔秒级),不适合需要强一致性场景。统计 API 允许秒级延迟,完全没问题。
初始化项目
npm create cloudflare@latest visitor-stats
# Choose: Workers → TypeScript → no to KV namespace (we'll add it manually)
cd visitor-stats
项目结构:
visitor-stats/
├── src/
│ └── index.ts # Workers 入口
├── wrangler.toml # 配置文件
├── tsconfig.json
└── package.json
wrangler.toml 里加上 KV 命名空间:
name = "visitor-stats"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[[kv_namespaces]]
binding = "STATS"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
KV 命名空间 ID 从 Cloudflare Dashboard 创建:Workers & Pages → KV → Create a namespace,名字随意,比如 visitor-stats。
路由设计与数据模型
RESTful 风格,路由用请求路径分发。数据存在 KV 里,Key 的设计决定了查询效率。
Key 设计
stats:page:{path} -> { pv: number, uv: Set<string>, dates: string[] }
stats:total -> { pv: number, uv: Set<string>, dates: string[] }
pv:页面浏览量,每次请求 +1uv:独立访客数,用 Set 存访客 IP 去重(Workers 没有完美 Cookie,可以用 IP + User-Agent 组合近似)dates:访问日期数组,用来画趋势图
这种设计优点是单条记录读写都是 O(1),不用扫描全表。缺点是跨页面统计需要一次 list + 多次 get,但全站统计场景很少,不影响。
完整代码
interface PageStats {
pv: number;
uv: string[];
dates: string[];
}
interface TotalStats {
pv: number;
uv: string[];
dates: string[];
}
const encoder = new TextEncoder();
async function getStats(kv: KVNamespace, key: string): Promise<PageStats> {
const data = await kv.get(key);
if (!data) {
return { pv: 0, uv: [], dates: [] };
}
return JSON.parse(data);
}
async function saveStats(kv: KVNamespace, key: string, stats: PageStats): Promise<void> {
await kv.put(key, JSON.stringify(stats), { expirationTtl: 86400 * 90 });
// 90 天过期,节省 KV 空间
}
function today(): string {
return new Date().toISOString().split('T')[0]; // "2025-05-01"
}
function visitorId(request: Request): string {
const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown';
const ua = request.headers.get('User-Agent') ?? '';
// 简单 hash,不用 crypto,只要分布均匀就行
let hash = 0;
const str = ip + ua;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return String(Math.abs(hash));
}
async function incrementStats(
kv: KVNamespace,
page: string,
visitor: string,
date: string
): Promise<PageStats> {
const key = `stats:page:${page}`;
const stats = await getStats(kv, key);
stats.pv += 1;
if (!stats.uv.includes(visitor)) {
stats.uv.push(visitor);
}
if (!stats.dates.includes(date)) {
stats.dates.push(date);
}
await saveStats(kv, key, stats);
return stats;
}
async function incrementTotal(
kv: KVNamespace,
visitor: string,
date: string
): Promise<TotalStats> {
const stats = await getStats(kv, 'stats:total');
stats.pv += 1;
if (!stats.uv.includes(visitor)) {
stats.uv.push(visitor);
}
if (!stats.dates.includes(date)) {
stats.dates.push(date);
}
await saveStats(kv, 'stats:total', stats);
return stats;
}
async function handleTrack(request: Request, kv: KVNamespace): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
let page: string;
try {
const body = await request.json();
page = body.page ?? '/';
} catch {
// 如果 body 不是 JSON,从 URL 取路径
page = new URL(request.url).pathname.replace('/api/track', '') || '/';
}
// 清理路径:去除末尾斜杠,限制长度
page = '/' + page.replace(/\/$/, '').slice(0, 200);
const visitor = visitorId(request);
const date = today();
const [pageStats, totalStats] = await Promise.all([
incrementStats(kv, page, visitor, date),
incrementTotal(kv, visitor, date),
]);
return Response.json({
ok: true,
page: { pv: pageStats.pv, uv: pageStats.uv.length },
total: { pv: totalStats.pv, uv: totalStats.uv.length },
});
}
async function handlePageStats(request: Request, kv: KVNamespace): Promise<Response> {
const url = new URL(request.url);
// /api/stats/:page → 取 :page 部分
const pathMatch = url.pathname.match(/^\/api\/stats(.+)/);
if (!pathMatch) {
return new Response('Not Found', { status: 404 });
}
let page = pathMatch[1];
if (!page || page === '/stats') {
// 无参数 → 返回全站统计
const total = await getStats(kv, 'stats:total');
return Response.json({ pv: total.pv, uv: total.uv.length, dates: total.dates });
}
page = page.replace(/^\//, '') || '/';
const stats = await getStats(kv, `stats:page:${page}`);
return Response.json({ pv: stats.pv, uv: stats.uv.length, dates: stats.dates });
}
const worker: ExportedHandler = {
async fetch(request: Request, env: { STATS: KVNamespace }): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
if (path.startsWith('/api/track')) {
return handleTrack(request, env.STATS);
}
if (path.startsWith('/api/stats')) {
return handlePageStats(request, env.STATS);
}
// 未匹配路由返回帮助信息
return Response.json({
usage: {
'POST /api/track': '记录访问,需 body: { page: "/path" }',
'GET /api/stats': '全站统计',
'GET /api/stats/:page': '指定页面统计',
}
});
},
};
export default { ...worker };
一个真实坑:KV 的数据类型
KV 只存字符串。Set<string> 这种 JS 原生类型不能直接丢进去,必须序列化成数组。每次写入都要 JSON.parse 读、JSON.stringify 写,这是个容易忘细节。
另一个坑:Workers 冷启动时 KV 读取可能第一次失败。建议加个简单重试:
async function getWithRetry(kv: KVNamespace, key: string, retries = 2): Promise<string | null> {
for (let i = 0; i <= retries; i++) {
try {
return await kv.get(key);
} catch (e) {
if (i === retries) throw e;
await new Promise(r => setTimeout(r, 100 * (i + 1)));
}
}
return null;
}
部署
wrangler deploy
# 输出类似:
# Worker unique URL: https://visitor-stats.your-subdomain.workers.dev
部署完成后:
# 记录一次访问
curl -X POST https://visitor-stats.your-subdomain.workers.dev/api/track \
-H "Content-Type: application/json" \
-d '{"page": "/blog/my-first-post"}'
# 查询
curl https://visitor-stats.your-subdomain.workers.dev/api/stats/my-first-post
# {"pv":1,"uv":1,"dates":["2025-05-01"]}
前端接入
一个不到 1KB 的脚本:
<script
src="https://visitor-stats.your-subdomain.workers.dev/api/track"
data-page="/blog/my-first-post"
defer
></script>
或者用 fetch(更推荐,不阻塞渲染):
<script>
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
fetch('https://visitor-stats.your-subdomain.workers.dev/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page: location.pathname }),
});
});
}
</script>
进阶方向
- 按小时统计:把日期粒度改成
YYYY-MM-DD-HH,方便看小时级趋势 - referrer 统计:读取
document.referrer,统计流量来源 - 绑定自己域名:Cloudflare Dashboard 里 Workers → Add Route,不用改一行代码
- Rate Limit:用 Cloudflare 的 WAF 规则或者 Workers 内置的
CF-IPCountry做访问限制
总结
用 Workers + KV 做统计 API,整个后端不到 150 行代码。没有服务器,没有数据库,每个月在免费额度内随便用。唯一要维护就是一个 KV 命名空间。
核心就三件事:路由分发、KV 读写、TextEncoder 序列化。掌握了这两个 API 的脾气,其他边缘计算需求也差不多能搞定。
相关工具:Cloudflare Workers、Wrangler、KV Namespace
种下你的想法
在花园里留下一条评论,和这篇文章一起生长。