场景

想在自己网站上加个访问统计,但不想装那些拖慢加载第三方脚本。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:页面浏览量,每次请求 +1
  • uv:独立访客数,用 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

种下你的想法

在花园里留下一条评论,和这篇文章一起生长。

COMMENTS