用 Cloudflare Pages + KV 给静态博客加上浏览量和点赞

静态站点也能有动态数据。这篇记录怎么用 Pages Functions 和 KV 做一个零成本、边缘读写的计数服务,包括去重、防刷和优雅降级。

用 Cloudflare Pages + KV 给静态博客加上浏览量和点赞

静态博客最尴尬的地方:什么都好,就是「没有后端」。想看个浏览量、收个点赞,就得挂个第三方服务,或者忍受一个慢吞吞的 Serverless 冷启动。

Cloudflare Pages 上其实有个很轻的解法:Pages Functions + KV。不用新开项目,不用管服务器,写完推到 functions/ 目录就生效。

整体思路

浏览器
  └── POST /api/views?slug=/blog/xxx/     ← 前端组件一进页面就调
        └── Pages Function(跑在最近的边缘节点)
              ├── 读 KV:post:/blog/xxx/  → { views, likes }
              ├── 用 KV 的 TTL 做 30 分钟去重
              └── 写回 KV,返回最新计数

KV 是最终一致的键值存储,读写都发生在离用户最近的节点,读延迟通常个位数毫秒。对于「浏览量」这种稍微旧一点也无所谓的场景,简直完美。

第一步:建一个 KV 命名空间

# 需要先安装 wrangler 并登录
pnpm add -D wrangler
pnpm exec wrangler login

# 创建命名空间,记下输出的 id
pnpm exec wrangler kv namespace create BLOG_KV

输出会长这样:

[[kv_namespaces]]
binding = "BLOG_KV"
id = "a1b2c3d4e5f60718293a4b5c6d7e8f90"

把这段贴进项目根目录的 wrangler.toml。如果你是用 Dashboard 部署(连 GitHub 仓库那种),也可以直接在 Settings → Functions → KV namespace bindings 里绑定,变量名写 BLOG_KV。

第二步:写接口

Pages Functions 用文件路径当路由:functions/api/views.js 就对应 /api/views。

export const onRequestPost = async ({ request, env }) => {
	if (!env.BLOG_KV) {
		return new Response(JSON.stringify({ error: 'kv_not_bound' }), { status: 503 });
	}

	const slug = new URL(request.url).searchParams.get('slug') ?? '/';
	const key = `post:${slug}`;

	const stats = { views: 0, likes: 0, ...JSON.parse((await env.BLOG_KV.get(key)) ?? '{}') };

	// 用「IP + 时间片」当去重键,借用 KV 的过期自动清理
	const ip = request.headers.get('cf-connecting-ip') ?? 'unknown';
	const bucket = Math.floor(Date.now() / (30 * 60 * 1000));
	const seenKey = `seen:${slug}:${ip}:${bucket}`;

	if (!(await env.BLOG_KV.get(seenKey))) {
		await env.BLOG_KV.put(seenKey, '1', { expirationTtl: 1860 });
		stats.views += 1;
		await env.BLOG_KV.put(key, JSON.stringify(stats));
	}

	return new Response(JSON.stringify({ slug, ...stats }), {
		headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
	});
};

几个细节值得说一下:

  • env.BLOG_KV 没绑定时返回 503,而不是 500。前端看到 503 就安静隐藏组件,页面完全不受影响。
  • cache-control: no-store 必须加,否则 CDN 会把计数缓存住,所有人都看到同一个数字。
  • 去重键用 TTL 自动过期,不需要额外的定时任务清理。

第三步:前端优雅降级

前端组件最容易被忽略的是「接口挂了怎么办」。我的做法是:默认隐藏,成功才显示。

<div data-views-root data-slug="/blog/xxx/" hidden>
	<span data-views-value>—</span>
</div>
fetch(`/api/views?slug=${encodeURIComponent(slug)}`, { method: 'POST' })
	.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
	.then((data) => {
		root.removeAttribute('hidden');
		value.textContent = new Intl.NumberFormat('zh-CN').format(data.views);
	})
	.catch(() => root.setAttribute('hidden', ''));

这样带来一个额外好处:同一份代码在纯静态托管上也能用。没有 Functions 的环境(比如 GitHub Pages)请求会 404,组件自动隐身,页面依然干净。

顺手加的安全头

Pages 支持用 _headers 文件加响应头,但如果内容需要按请求动态生成,用中间件更灵活:

const SECURITY_HEADERS = {
	'x-content-type-options': 'nosniff',
	'referrer-policy': 'strict-origin-when-cross-origin',
	'x-frame-options': 'SAMEORIGIN',
	'permissions-policy': 'geolocation=(), microphone=(), camera=()',
	'strict-transport-security': 'max-age=31536000; includeSubDomains',
};

export const onRequest = async (context) => {
	const response = await context.next();
	const headers = new Headers(response.headers);
	for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
		if (!headers.has(key)) headers.set(key, value);
	}
	if (new URL(context.request.url).pathname.startsWith('/api/')) {
		headers.set('cache-control', 'no-store');
	}
	return new Response(response.body, { status: response.status, headers });
};

成本

项目免费额度这个博客的用量
Pages 构建500 次/月约 40 次
Functions 请求100,000 次/天每天几百
KV 读取100,000 次/天每天几千
KV 写入1,000 次/天每天几十

结论:在免费额度内,基本看不到天花板。除非你的博客突然火了 —— 那也算是个幸福的烦恼。

小结

  • 静态站点的动态需求,优先考虑 Pages Functions,而不是另起一个服务
  • KV 的 TTL 可以当「带自动清理的去重表」用,很省事
  • 前端一定要能优雅降级,接口挂了页面也得好看
  • cf-connecting-ip 是 Cloudflare 给你的真实访客 IP,比 x-forwarded-for 更可信

下一篇打算写写用 D1 做文章阅读记录,顺便试试 Workers 的定时任务。如果你也在折腾 Cloudflare,欢迎来留言板聊聊。

评论区还没开张 —— 想开启的话,把 src/data/site.ts 里FEATURES.comments 的 enabled 设为 true 并填入 giscus 配置即可。

也许你还想看