阅读量这个功能,很多博客都有,实现起来看上去很简单——不就是一个计数器吗?
写起来才发现,一个计数器会引出好几层问题:数据存哪、本地开发会不会污染线上数据、刷新页面会不会一直涨、接口有没有被滥用。每一层都得想清楚。
先看一张总览图——一条请求进来之后,完整链路长这样:
六个判断节点,五层拦截。下面逐个讲每一层在防什么、怎么实现。
选型
最先想到的方案是 Supabase——这个博客已经有好几篇写 Supabase 的文章,我对它很熟悉,直接加一张 page_views 表就能解决。
但问题是:Supabase 免费计划只允许 2 个项目,我已经用完了。 再创建第三个就要付费,为了一个计数器不值得。
然后看了几个备选方案:
- Vercel Analytics:已经接入,但数据只在 Vercel 控制台可见,无法在页面上展示
- Vercel Edge Config:读优化,不适合高频写入
- Upstash Redis:专门做 Serverless 场景的 Redis,免费计划 10,000 次请求/天,对个人博客完全够用,不需要绑卡。超额只是请求被拒绝,不会自动扣费
Upstash 的接入方式也很干净:一个 REST URL,一个 Token,加一个 @upstash/redis 包,完成。
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
环境变量就这两个,和常见的 Redis 客户端用法一样。后面所有读写操作都通过 redis 这个实例来调。
核心实现
数据结构很简单,每篇文章对应 Redis 里的一个 key:
views:agent-types-overview → 342
views:supabase/00-why-supabase → 1204
views:python-agent-guide → 89
API 分两个接口:
POST /api/views/[slug]:用户打开文章时触发,计数 +1,返回最新数值GET /api/views/[slug]:只读,用于列表页展示,不写入
前端调用也很简单。文章详情页在页面加载时调一次 POST,拿到最新计数后直接渲染:
// 文章详情页:计数 +1 并展示
async function DetailPage({ slug }: { slug: string }) {
const res = await fetch(`/api/views/${slug}`, { method: "POST" });
const { count } = await res.json();
return <span>{formatCount(count)}</span>;
}
列表页只需要展示数字、不需要写入,调 GET 就行:
// 文章列表:只读展示
async function ListPage() {
const res = await fetch("/api/views");
const data = await res.json();
return data.map((post) => (
<span>{formatCount(post.count)}</span>
));
}
列表页的汇总接口用 redis.mget() 一次拿回所有文章的计数,比逐个请求高效。mget 是原子操作,避免了并发数量太多的问题。
本地开发会污染线上数据
接入完成后意识到一个问题:本地 bun dev 和线上 Vercel 部署用的是同一个 Upstash 数据库。每次我本地调试,打开一篇文章,计数就会涨一次,和真实读者的数据混在一起。
解决方法很直接——在开发环境跳过写入:
export async function POST(req, { params }) {
if (process.env.NODE_ENV === "development") {
return Response.json({ count: 0 });
}
// ...
}
本地 POST 直接返回 0,不碰 Redis。列表页的 GET 请求仍然正常读取线上真实数据——所以你本地调试时能看到真实的数字展示效果,只是不会因为你反复刷新而涨数。
刷新页面会一直涨
每次页面加载都触发 POST,意味着用户反复刷新可以无限制地增加计数。
解决方案是用 HttpOnly cookie 记录「已读」状态:
const cookieKey = `viewed:${slugStr}`;
const cookieStore = await cookies();
if (cookieStore.has(cookieKey)) {
// 24 小时内读过,只返回当前数值,不写入
const count = await redis.get(`views:${slugStr}`) ?? 0;
return Response.json({ count });
}
// 首次读,写入并种 cookie
const count = await redis.incr(`views:${slugStr}`);
res.headers.set("Set-Cookie", `${cookieKey}=1; HttpOnly; ...Expires=24h`);
同一浏览器 24 小时内访问同一篇文章只计一次。HttpOnly 保证 cookie 无法被 JavaScript 读取,不存在前端绕过的风险。
这里用 cookie 而不是 localStorage,原因很简单:防刷判断在服务端做的。POST /api/views/[slug] 这个 API route 执行的时候,cookies().has(cookieKey) 直接就能读到。用 localStorage 的话,API 这边完全感知不到——它只存在于浏览器里,请求不会自动带过去。
如果换成前端先判 localStorage、没读过才调 POST,那 POST 接口本身就成了一个"无条件 +1"的入口。任何人用 curl 直接请求就能绕过。HttpOnly cookie 让这一整层判断闭环在服务端,不依赖前端的任何一行代码。
换浏览器或无痕模式会重新计一次——这是合理的,本来就算不同的访问。
Cookie 解决的是同一个真实用户反复刷新的问题。但 cookie 防不了另一类威胁——有人直接拿 curl 对着接口狂轰。这是两件性质不同的事,下面的 Origin 和白名单主要对付这一类。
接口被直接调用的风险
POST /api/views/xxx 是公开接口,任何人都可以用 curl 直接请求:
curl -X POST https://yoursite.com/api/views/agent-types-overview
# 每次 +1,Upstash 免费额度很快耗尽
针对外部调用的风险,第一层防护是 Origin 校验:
浏览器发出请求时会自动携带 Origin header,值是当前域名。curl 默认不带这个 header。在服务端检查它:
function isValidOrigin(req: Request): boolean {
const origin = req.headers.get("origin");
const appUrl = process.env.APP_URL?.replace(/\/$/, "");
return !!origin && !!appUrl && origin === appUrl;
}
Origin 可以被手动伪造,但这已经把绝大多数脚本攻击挡在门外了。
Slug 白名单
即使绕过了 Origin 校验,攻击者还可以构造不存在的文章路径:
curl -X POST https://yoursite.com/api/views/fake/nonexistent
# Redis 里会创建垃圾 key
处理方式是在接受请求前验证 slug 是否对应真实存在的文章:
const validSlugs = getPostSlugs();
if (!validSlugs.includes(slugStr)) {
return Response.json({ error: "Not Found" }, { status: 404 });
}
getPostSlugs() 拿到了所有博客的 slug,这个函数本来就是博客系统的一部分——不需要额外维护一份独立的白名单文件。
这个设计的好处是:每次新增或删除文章、重新部署后,白名单自动更新。 用现有的数据源作为唯一真相,零手动维护。
超过 1000 就显示 xxk
阅读量数字在三个地方展示:列表页卡片徽章、详情页 header、内容概览统计格。统一用一个工具函数处理格式:
export function formatCount(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
return n.toLocaleString();
}
1200 → 1.2k,15000 → 15k,1000 → 1k。.replace(/\.0$/, "") 去掉整数时的 .0 尾巴。
请求进来之后
把所有校验串起来,POST 接口的完整逻辑是:
请求进来
→ 开发环境?→ 直接返回 0
→ Origin 不匹配?→ 403
→ slug 不在文章列表?→ 404
→ 24h 内访过?→ 只读返回当前数值
→ 全部通过 → Redis +1,种 cookie,返回最新数值
每一层拦截都有明确的职责,不会互相耦合。
这套方案上线之后,开发环境的误刷、同一个用户的重复刷新、外部脚本的 curl 请求,三层拦截下来已经挡住了绝大多数无效计数。更深层的东西——代理 IP 池、分布式刷量——不是现在该操心的事。
暂时想到的就这么多了。
这个功能核心代码 150 行左右,分布在 API routes、工具函数和页面组件里。真正花时间的是把每个坑想清楚了再动手。