博客迁移到 Astro 和 Cloudflare Pages 后,页面已经足够轻,也能完整静态部署。但一个长期写作的站点还需要回答另外两个问题:文章是否真的有人看,以及搜索引擎能否正确理解这些内容。
最开始我考虑过在项目里内嵌一个内存数据库。这个方案对普通服务器程序或许能用于临时缓存,却不适合静态站点和无服务器运行时:静态文件本身没有常驻进程,Pages Functions 的实例也可能随时创建和回收,内存中的计数既不能持久化,也无法在多个实例之间共享。
最终我使用 Cloudflare Web Analytics + Pages Functions + D1:Web Analytics 负责站点级访问趋势,D1 负责文章阅读量和自定义点击事件;同时把 SEO 元数据、结构化数据和构建检查补齐。这篇文章记录完整的设计、实现、上线和排错过程。
如果想先了解博客的选型、双仓库发布和域名迁移,可以阅读上一篇:从零重写个人博客:Astro + React + MDX + Cloudflare Pages。
最终架构
这套统计并没有把博客改成动态站点。文章、首页、归档和标签仍由 Astro 构建为静态 HTML,只有 /api/analytics 交给 Pages Functions 处理。
flowchart LR
A[访问 lucaslz.com] --> B[Cloudflare CDN]
B --> C[Astro 静态页面]
B --> D[Web Analytics]
C --> E[前端统计脚本]
E --> F[/api/analytics<br/>Pages Functions]
F --> G[(Cloudflare D1)]
H[GitHub Actions] --> I[构建与 SEO 检查]
I --> B
两套统计各自解决不同问题:
| 能力 | Cloudflare Web Analytics | D1 自定义统计 |
|---|---|---|
| 页面访问、访客趋势 | 是 | 可记录页面浏览 |
| 来源、国家和性能指标 | 是 | 不采集 |
| 文章累计阅读量 | 不直接展示到页面 | 是 |
| 标签、搜索、目录点击 | 不适合自定义事件 | 是 |
| 自己控制数据模型 | 否 | 是 |
| 客户端 Cookie | 不需要 | 不需要 |
Web Analytics 用来观察全站,D1 用来回答与内容和交互有关的具体问题。它们不是重复建设,而是互补。
为什么不使用内存数据库
Cloudflare Pages 的主体仍然是静态文件。即使增加 Pages Functions,请求也运行在按需创建的 Worker 隔离环境中,不能假设某个 JavaScript 变量会一直存在。
如果这样记录阅读量:
let views = 0;
export function onRequest() {
views += 1;
return Response.json({ views });
}
它会遇到三个根本问题:
- 实例重启后数据归零。
- 多个实例各自维护一份计数。
- 新部署也会丢失原来的状态。
LocalStorage 只能保存在访客自己的浏览器里,也无法形成全站汇总。因此,需要一个真正的持久化存储。D1 是 Cloudflare 托管的 SQLite 数据库,能够直接绑定到 Pages Functions,部署结构最简单。
用每日聚合控制写入量
统计系统很容易走向“每次访问保存一条明细”的设计,但个人博客通常不需要保存访客级日志。我更关心某天某篇文章有多少次浏览、某个入口被点击多少次。
因此表结构采用每日聚合:
CREATE TABLE IF NOT EXISTS event_daily (
event TEXT NOT NULL,
path TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
stat_date TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0 CHECK (count >= 0),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (event, path, target, stat_date)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_event_daily_date
ON event_daily (stat_date, event);
复合主键由 event + path + target + stat_date 组成。同一天同一种事件只更新一行,不保存 IP、User-Agent、完整 Referer 或用户身份。
这种设计的好处是:
- 数据量随“页面 × 事件 × 天数”增长,而不是随访问次数增长。
- 查询文章累计阅读量只需要对每日计数求和。
- 不需要维护访客画像,隐私边界更清晰。
- 即使访问量增加,存储和查询成本也比较可控。
目前允许的事件包括:
page_view
article_click
article_impression
tag_click
search_click
nav_click
toc_click
cta_click
outbound_click
Pages Functions 统计接口
在项目根目录增加 functions/api/analytics.js 后,Cloudflare Pages 会把它映射为:
GET /api/analytics?event=page_view&path=/article
POST /api/analytics
GET 用于读取某篇文章的累计阅读量。POST 接收事件并使用 Upsert 更新当天计数:
await env.BLOG_ANALYTICS.prepare(
`INSERT INTO event_daily
(event, path, target, stat_date, count, updated_at)
VALUES (?1, ?2, ?3, ?4, 1, datetime('now'))
ON CONFLICT (event, path, target, stat_date)
DO UPDATE SET
count = count + 1,
updated_at = datetime('now')`,
)
.bind(event, path, target, statDate)
.run();
接口还做了几项必要的约束:
- 只接受白名单中的事件。
- 统一清除路径尾部斜杠和查询参数。
- 请求体最大为 2 KB。
- POST 只接受同源请求。
- 外部链接只保存域名和路径,不保存查询参数。
- 所有响应都设置
Cache-Control: no-store。
这些限制不能阻止所有恶意流量,但能避免接口变成任意字符串收集器,也能减少把敏感查询参数写入数据库的风险。
前端如何记录和展示
页面加载后,统计组件会向 /api/analytics 写入一次 page_view。接口在完成写入后直接返回累计值,文章页再显示“多少次阅读”。
const response = await fetch("/api/analytics", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
event: "page_view",
path: window.location.pathname,
}),
credentials: "same-origin",
});
const data = await response.json();
showPageViews(data.total);
为了避免一次会话内刷新页面不断增加计数,前端使用 sessionStorage 对 page_view 和曝光事件去重。这里的阅读量不是严格的独立访客数,而是一个轻量、可解释的会话级阅读计数。
点击类事件使用 navigator.sendBeacon() 发送。访客点击链接并立即离开当前页面时,Beacon 比普通异步请求更适合完成最后一次小数据上报。
所有埋点通过 HTML 的 data-* 属性声明,组件不需要直接依赖统计实现:
<a
href={postHref}
data-analytics-event="article_click"
data-analytics-target={postHref}
>
阅读文章
</a>
在 Cloudflare 创建并绑定 D1
代码完成后,还要在 Cloudflare 控制台配置真实数据库。
1. 创建数据库
进入:
Storage & databases
→ D1 SQL Database
→ Create database
数据库名称使用:
lucaslz-blog-analytics
创建完成后进入 D1 Console,执行项目中的 migrations/0001_analytics.sql,确认表和索引创建成功。
2. 绑定 Pages Functions
进入:
Workers & Pages
→ lucaslz-blog
→ Settings
→ Bindings
→ Add
选择 D1 database,变量名填写:
BLOG_ANALYTICS
再选择刚创建的 lucaslz-blog-analytics 数据库并保存。变量名必须与代码中的 env.BLOG_ANALYTICS 完全一致。
3. 重新部署
这里有一个容易忽略的细节:新绑定只对之后的部署生效。保存绑定后,已有 Deployment 不会自动获得新环境。
绑定完成但没有重新部署时,接口返回:
HTTP/2 503
{"enabled":false}
触发一次 GitHub Actions 部署后,Pages Functions 才能读到 D1 Binding。可以提交正常改动,也可以在没有文件变化时创建一次空提交:
git commit --allow-empty -m "chore: redeploy with D1 analytics"
git push origin main
开启 Cloudflare Web Analytics
D1 不适合重新实现一整套访客来源、地理分布、设备和性能分析。Cloudflare Web Analytics 已经提供这些站点级指标,而且不需要为每个访客创建客户端标识。
在 Cloudflare Dashboard 中进入:
Analytics
→ Web Analytics
→ Add a site
输入 lucaslz.com。由于域名已经属于当前 Cloudflare 账号,可以选择 Automatic setup,不需要手动把 Beacon 代码写入 Astro Layout。
完成后控制台会显示:
Web Analytics are now set up
数据不会立即出现在仪表盘中,首次启用后需要等待 Cloudflare 收集和处理访问数据。官方文档可参考:Cloudflare Web Analytics。
系统补齐 SEO
统计只能告诉我内容是否被访问,SEO 则决定搜索引擎和社交平台如何理解、收录和展示页面。这次没有只增加几个 <meta> 标签,而是统一由 BaseLayout 生成页面语义。
基础元数据
每个页面现在包含:
- 唯一的
<title>和 description。 - 指向
https://lucaslz.com的绝对 Canonical。 robots索引指令。zh-CN和x-defaulthreflang。- RSS 和 Web App Manifest 链接。
- Open Graph 与 Twitter Card。
- 1200 × 630 的绝对分享图片地址。
Canonical 使用当前路径和固定生产域名生成,避免 pages.dev 预览地址被当成另一份内容:
const canonical = new URL(Astro.url.pathname, Astro.site ?? SITE.url);
JSON-LD 结构化数据
普通页面输出 Person 和 WebSite。文章页额外输出:
BlogPostingBreadcrumbList- 发布时间和更新时间
- 作者、标签、关键词和字数
- 主页面与分享图片
JSON-LD 由对象序列化生成,而不是手写 JSON 字符串;序列化后再转义 <,避免文章内容破坏脚本边界。
Sitemap、RSS 与 robots.txt
Astro 在构建阶段生成 Sitemap,文章集合同时生成 RSS。robots.txt 指向生产 Sitemap。这样新增 Markdown 文章时,不需要再手工维护 URL 列表。
把 SEO 变成构建门槛
SEO 最怕“这次修好了,下次新增页面又漏掉”。因此我增加 scripts/check-seo.mjs,在 dist/ 中逐个扫描 HTML,并检查:
- 页面标题与 description。
- Canonical 是否使用正式域名。
- robots 指令。
- 绝对 Open Graph 图片。
- 页面是否只有一个 H1。
- JSON-LD 是否为有效 JSON。
- 文章页是否具有
BlogPosting和BreadcrumbList。
统计接口也有独立测试,覆盖正常写入、读取、无效事件、跨域请求和 OPTIONS 响应。
GitHub Actions 中的发布顺序变为:
Astro check
→ Astro build
→ 站内链接检查
→ SEO 检查
→ Analytics 接口测试
→ 同步 GitHub Pages 镜像
→ 部署 Cloudflare Pages
只要其中一项失败,生产部署就不会继续。
上线验证
部署完成后,先读取一个尚无数据的页面:
curl \
"https://lucaslz.com/api/analytics?event=page_view&path=/howtostudy"
接口正常时返回:
{
"event": "page_view",
"path": "/howtostudy",
"total": 0
}
再打开文章或发送一次同源 POST,响应状态应为 201:
{
"ok": true,
"event": "page_view",
"path": "/howtostudy",
"total": 1
}
刷新文章页后,标题下方会显示:
7 分钟阅读 · 1 次阅读
最后在 D1 Console 中检查聚合结果:
SELECT
event,
path,
target,
SUM(count) AS total
FROM event_daily
GROUP BY event, path, target
ORDER BY total DESC;
常见问题
接口返回 503
{"enabled":false} 表示 Function 已经上线,但当前 Deployment 没有拿到 BLOG_ANALYTICS。检查生产环境 Binding,并在保存后重新部署。
接口返回 500
通常是数据库已经绑定,但迁移尚未执行,导致 event_daily 表不存在。进入 D1 Console 执行迁移,再查看 Function 日志确认 SQL 错误。
页面没有显示阅读量
依次检查:
- 当前域名是否为
lucaslz.com、www.lucaslz.com或pages.dev。 - GET 接口是否返回
200和数字类型的total。 - 文章页是否存在
data-page-views元素。 - 浏览器控制台是否有 CSP、网络或脚本错误。
阅读量和 Web Analytics 不完全一致
这是预期结果。D1 的 page_view 在同一浏览器会话内去重,Web Analytics 有自己的访客、机器人过滤和数据处理规则。两者定义不同,不应该强行追求数字完全相等。
隐私与成本边界
这套实现刻意不保存:
- IP 地址
- User-Agent
- Cookie 标识
- 用户账号
- 完整 Referer
- URL 查询参数
D1 中只有事件、规范化路径、目标、日期和次数。Web Analytics 与自定义事件的职责分开后,也不需要把第三方重型分析 SDK 放进每个页面。
D1 的免费额度和具体限制可能调整,应该以 Cloudflare D1 定价文档 为准。相比记录每次访问的明细,每日聚合可以显著减少行数和读取范围,也更适合个人博客。
最终结果
这次改造后,博客仍然保持静态优先:
Astro 静态内容
├─ Cloudflare Web Analytics:全站趋势与性能
├─ Pages Functions:轻量统计 API
├─ D1:文章阅读量与自定义事件
└─ SEO:元数据、结构化数据与自动检查
最重要的不是多了一个访问数字,而是建立了一套可长期维护的反馈闭环:搜索引擎能更准确地理解文章,我可以看到哪些内容真正被阅读,构建流程则保证后续新页面不会轻易丢失 SEO 基线。
对静态个人博客来说,这已经覆盖了绝大多数实际需求,同时没有引入常驻服务器、内存数据库或复杂的用户追踪系统。