前言
在运营博客的过程中,我想添加两个实用的功能:
- 运行时间显示:让访客看到博客已经运行了多长时间,增强博客的”存活感”
- 访问统计:显示总访问量和今日访问量,了解博客的受欢迎程度
对于这两个功能,我调研了几种实现方案:
运行时间:
- ✅ 纯前端 JavaScript 实现(推荐)
- ❌ 后端计算(浪费资源)
访问统计:
- ✅ Cloudflare Web Analytics + 自建计数器(推荐)
- ❌ 传统服务器端统计(部署复杂)
- ❌ 第三方统计服务(有隐私顾虑)
最终,我选择了以下技术方案:
- 运行时间:纯前端 JavaScript,每秒更新
- 访问统计:Cloudflare Workers + D1 数据库(边缘计算,无服务器)
同样,这次的开发工作由 Claude Code 完成。本文将记录整个开发过程,重点关注技术选型、实现细节和部署问题。
需求分析
功能需求
运行时间显示:
- 显示博客运行的总时间(天、时、分、秒)
- 每秒自动更新,实时跳动
- 支持在配置中设置建站日期
访问统计:
- 显示总访问量(累计)
- 显示今日访问量
- 防止重复计数(同一 IP 1 小时内只计一次)
- 数据持久化存储
技术挑战
- 前端组件:如何在 Astro 中集成 Svelte 交互组件
- 边缘计算:如何使用 Cloudflare Workers 实现无服务器 API
- 数据库操作:如何在 D1 中设计表结构和查询逻辑
- 环境变量:如何在 Cloudflare Pages 中配置环境变量
- 类型安全:如何避免 TypeScript 编译错误
技术架构
基于现有 Fuwari 模板:
- Astro 5.x:静态站点生成器
- Svelte 5:响应式 UI 组件(使用
$staterunes) - Cloudflare Workers:边缘计算平台
- Cloudflare D1:SQLite 兼容的边缘数据库
- Wrangler CLI:Cloudflare 开发工具
实现步骤
1. 运行时间组件
首先创建 Svelte 组件实现运行时间功能:
src/components/RunTime.svelte:
<script lang="ts">import { onDestroy, onMount } from "svelte";import { siteConfig } from "@/config";
// 运行时间状态let days = $state(0);let hours = $state(0);let minutes = $state(0);let seconds = $state(0);let timer: number;
function getStartDate(): Date { const dateStr = siteConfig.websiteStartDate || "2025-01-01"; return new Date(dateStr);}
function updateRuntime() { const now = new Date(); const start = getStartDate(); const diff = now.getTime() - start.getTime();
const totalSeconds = Math.floor(diff / 1000); const totalMinutes = Math.floor(totalSeconds / 60); const totalHours = Math.floor(totalMinutes / 60);
days = Math.floor(totalHours / 24); hours = totalHours % 24; minutes = totalMinutes % 60; seconds = totalSeconds % 60;}
onMount(() => { updateRuntime(); timer = window.setInterval(updateRuntime, 1000);});
onDestroy(() => { if (timer) clearInterval(timer);});</script>
<div class="card-base"> <div class="px-4 pb-4 pt-4"> <h3 class="text-sm font-bold mb-3">运行时间</h3> <div class="grid grid-cols-4 gap-1.5"> <div class="text-center"> <div class="bg-gradient-to-br rounded-lg py-2 px-1 mb-1"> <span class="text-lg font-bold tabular-nums">{days}</span> </div> <span class="text-[10px]">天</span> </div> <!-- 时、分、秒类似 --> </div> </div></div>关键点:
- 使用 Svelte 5 的
$staterunes 实现响应式状态 onMount生命周期启动定时器onDestroy清理定时器,避免内存泄漏tabular-numsCSS 属性确保数字等宽,避免跳动
2. 配置建站日期
在 src/config.ts 中添加建站日期配置:
export const siteConfig: SiteConfig = { title: "我的个人博客", subtitle: "记录学习、生活与思考", // ... 其他配置 websiteStartDate: "2026-02-09", // 格式:YYYY-MM-DD};同时更新类型定义 src/types/config.ts:
export type SiteConfig = { title: string; subtitle: string; // ... 其他字段 websiteStartDate?: string; // 新增};重要:必须同时更新类型定义,否则 TypeScript 编译会报错。
3. 访问统计组件
在 RunTime 组件中添加访问统计功能:
<script lang="ts">// ... 运行时间相关代码
// 访问统计状态let totalVisits = $state(0);let todayVisits = $state(0);let visitLoading = $state(true);let visitError = $state(false);
// Worker API 地址const API_URL = import.meta.env.PUBLIC_VISIT_COUNTER_API || "";
function formatNumber(num: number): string { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");}
async function fetchVisitStats() { try { if (!API_URL) { // 本地开发使用 mock 数据 totalVisits = 1234; todayVisits = 56; visitLoading = false; return; }
const response = await fetch(`${API_URL}/api/visit`, { method: "GET", headers: { "Content-Type": "application/json" }, });
if (!response.ok) throw new Error("Failed to fetch");
const data = await response.json(); totalVisits = data.total || 0; todayVisits = data.today || 0; visitLoading = false; } catch (err) { console.error("Error fetching visit stats:", err); visitError = true; visitLoading = false; }}
onMount(() => { // ... 运行时间初始化 fetchVisitStats();});</script>
<div class="card-base"> <!-- 运行时间部分 --> <div class="h-px bg-gradient-to-r my-3"></div>
<!-- 访问统计部分 --> <div> <h3 class="text-sm font-bold mb-3">访问统计</h3> {#if visitLoading} <!-- 加载状态 --> {:else if visitError} <!-- 错误状态 --> {:else} <div class="grid grid-cols-2 gap-2"> <div class="text-center"> <div class="rounded-lg py-2 px-2 mb-1"> <span class="text-base font-bold tabular-nums"> {formatNumber(totalVisits)} </span> </div> <span class="text-[10px]">总访问量</span> </div> <div class="text-center"> <div class="rounded-lg py-2 px-2 mb-1"> <span class="text-base font-bold tabular-nums"> {formatNumber(todayVisits)} </span> </div> <span class="text-[10px]">今日访问</span> </div> </div> {/if} </div></div>关键点:
- 使用
import.meta.env访问环境变量 - 添加加载和错误状态,提升用户体验
- 本地开发时使用 mock 数据(1234/56)
formatNumber函数添加千分位分隔符
4. Cloudflare Worker 创建
创建 Worker 处理访问统计逻辑:
workers/visit-counter/worker.js:
export default { async fetch(request, env, ctx) { const url = new URL(request.url); const path = url.pathname;
// CORS 预检请求 if (request.method === 'OPTIONS') { return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }, }); }
if (path === '/api/visit') { return handleVisit(request, env); } else if (path === '/api/visit/stats') { return handleGetStats(request, env); }
return new Response('Not Found', { status: 404 }); },};
// 处理访问记录async function handleVisit(request, env) { const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
// 检查是否重复访问(1小时内) const isDuplicate = await checkDuplicateVisit(env.DB, ip);
if (!isDuplicate) { await recordVisit(env.DB, ip); }
const stats = await getStats(env.DB);
return new Response(JSON.stringify(stats), { headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store, no-cache, must-revalidate', }, });}
// 检查重复访问async function checkDuplicateVisit(db, ip) { const result = await db.prepare(` SELECT COUNT(*) as count FROM visit_logs WHERE ip_address = ? AND visit_time > datetime('now', '-1 hour') `).bind(ip).first();
return result.count > 0;}
// 记录访问async function recordVisit(db, ip) { await db.batch([ // 更新总访问量 db.prepare(` UPDATE site_stats SET total_visits = total_visits + 1, today_visits = today_visits + 1, last_updated = CURRENT_TIMESTAMP WHERE id = 1 `), // 记录访问日志 db.prepare(` INSERT INTO visit_logs (ip_address, visit_time, path) VALUES (?, CURRENT_TIMESTAMP, '/') `), ]).exec();}
// 获取统计数据async function getStats(db) { const stats = await db.prepare(` SELECT total_visits as total, today_visits as today FROM site_stats WHERE id = 1 `).first();
return { total: stats.total || 0, today: stats.today || 0, };}5. D1 数据库设计
创建数据库表结构:
workers/visit-counter/schema.sql:
-- 站点统计表(总览数据)CREATE TABLE IF NOT EXISTS site_stats ( id INTEGER PRIMARY KEY AUTOINCREMENT, total_visits INTEGER NOT NULL DEFAULT 0, today_visits INTEGER NOT NULL DEFAULT 0, last_updated DATETIME DEFAULT CURRENT_TIMESTAMP);
-- 每日访问统计表CREATE TABLE IF NOT EXISTS daily_visits ( id INTEGER PRIMARY KEY AUTOINCREMENT, visit_date DATE NOT NULL UNIQUE, visit_count INTEGER NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
-- 访问日志表(用于防重复)CREATE TABLE IF NOT EXISTS visit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip_address VARCHAR(45) NOT NULL, visit_time DATETIME DEFAULT CURRENT_TIMESTAMP, path VARCHAR(255) DEFAULT '/');
-- 初始化统计数据INSERT OR IGNORE INTO site_stats (id, total_visits, today_visits)VALUES (1, 0, 0);表结构说明:
site_stats:存储总访问量和今日访问量(单行记录)daily_visits:每日访问统计(按日期分组)visit_logs:访问日志(用于 IP 去重)
6. Wrangler 配置
配置 Worker 和数据库绑定:
wrangler.toml:
name = "blog-visit-counter"main = "workers/visit-counter/worker.js"compatibility_date = "2024-01-01"
[[d1_databases]]binding = "DB"database_name = "blog-visit-counter-db"database_id = "ad141ae5-65b7-43ae-991b-d733e46825cf"7. 本地开发配置
创建 .env 文件配置 Worker API 地址:
# 访问计数器 API 地址PUBLIC_VISIT_COUNTER_API=https://blog-visit-counter.xxxxxxxxx.workers.dev注意:.env 文件只在本地开发环境有效,部署时需要在 Cloudflare Pages 控制台配置环境变量。
8. 组件集成
创建 Astro 包装组件:
src/components/widget/RunTimeWidget.astro:
---import RunTime from "../RunTime.svelte";
interface Props { class?: string; style?: string;}
const className = Astro.props.class;const style = Astro.props.style;---<div class={className} style={style}> {/* @ts-ignore - client:load directive is valid in Astro */} <RunTime client:load /></div>关键点:
client:load指令告诉 Astro 在页面加载时 hydration Svelte 组件@ts-ignore注释告诉 TypeScript 忽略 Astro 特有指令的类型错误
在侧边栏中集成:
src/components/widget/SideBar.astro:
---import RunTimeWidget from "./RunTimeWidget.astro";// ... 其他导入---<div id="sidebar-sticky" class="transition-all duration-700 flex flex-col w-full gap-4 top-4"> <Categories class="onload-animation" style="animation-delay: 150ms"></Categories> <Tag class="onload-animation" style="animation-delay: 200ms"></Tag> <RunTimeWidget class="onload-animation" style="animation-delay: 250ms"></RunTimeWidget></div>9. 部署流程
创建 D1 数据库
# 创建数据库wrangler d1 create blog-visit-counter-db
# 记录返回的 database_id# database_id = "ad141ae5-65b7-43ae-991b-d733e46825cf"初始化数据库表结构
# 执行 schema.sqlwrangler d1 execute blog-visit-counter-db --file=workers/visit-counter/schema.sql部署 Worker
# 部署到 Cloudflare Workerswrangler deploy配置环境变量
在 Cloudflare Pages 控制台配置:
-
进入项目 → Settings → Environment variables
-
添加变量:
- Name:
PUBLIC_VISIT_COUNTER_API - Value:
https://blog-visit-counter.xxxxxxxxxxx.workers.dev - Environments: Production 和 Preview
- Name:
-
保存后触发新部署
遇到的问题与解决方案
问题 1:类型定义缺失导致 TypeScript 编译错误
错误信息:
error ts(2353): Object literal may only specify known properties,and 'websiteStartDate' does not exist in type 'SiteConfig'.原因:在 src/config.ts 中添加了新属性,但忘记更新类型定义。
解决方案:
export type SiteConfig = { // ... 其他字段 websiteStartDate?: string; // 添加到类型定义};经验教训:在添加配置属性时,必须同步更新类型定义。
问题 2:Astro client 指令的类型错误
错误信息:
error ts(2322): Type '{ "client:load": true; }' is not assignable to type 'IntrinsicAttributes'.原因:TypeScript 无法识别 Astro 特有的 client:load 指令。
解决方案:
<div class={className} style={style}> {/* @ts-ignore - client:load directive is valid in Astro */} <RunTime client:load /></div>经验教训:Astro 的客户端指令需要用 @ts-ignore 注释忽略类型检查。
问题 3:导入语句顺序错误
错误信息:
The imports and exports are not sorted原因:Biome 要求按字母顺序排序导入语句。
解决方案:
// ❌ 错误:未排序import { onMount, onDestroy } from "svelte";
// ✅ 正确:按字母顺序import { onDestroy, onMount } from "svelte";经验教训:运行 pnpm format 自动修复导入顺序。
问题 4:部署后显示 mock 数据(1234)
现象:部署后访问统计显示 1234,而不是真实数据。
原因:环境变量 PUBLIC_VISIT_COUNTER_API 未在 Cloudflare Pages 中配置,导致组件使用本地 mock 数据。
解决方案:
- 在 Cloudflare Pages 控制台添加环境变量
- 触发新部署
- 验证配置
经验教训:.env 文件只在本地有效,生产环境需要在部署平台配置环境变量。
问题 5:Worker 部署失败
错误信息:
Error: No such D1 database原因:wrangler.toml 中的 database_id 与实际创建的数据库不匹配。
解决方案:
# 查看已创建的数据库wrangler d1 list
# 更新 wrangler.toml 中的 database_id经验教训:确保 wrangler.toml 配置与 Cloudflare 账号中的资源一致。
最终效果
运行时间显示
- 实时显示博客运行的天、时、分、秒
- 每秒自动更新,数字跳动
- 响应式设计,适配移动端
访问统计功能
- 显示总访问量和今日访问量
- 数字带千分位分隔符(如:1,234)
- 防重复计数(1 小时内同一 IP 只计一次)
- 加载和错误状态处理
UI 设计
- 统一使用主题色
var(--primary) - 顶部渐变装饰条
- LIVE 实时指示器
- 卡片 hover 效果
- 紧凑型设计,与标签卡片大小一致
技术总结
关键技术点
- Svelte 5 Runes:使用
$state实现细粒度响应式 - Cloudflare Workers:边缘计算,全球低延迟
- D1 数据库:SQLite 兼容,支持 SQL 查询
- 环境变量:本地开发与生产环境分离
- 类型安全:TypeScript 严格模式,减少运行时错误
性能优化
- 边缘计算:Worker 部署在全球节点,响应速度快
- 防重复计数:减少无效请求,降低数据库压力
- 客户端缓存:使用
Cache-Control头控制缓存策略 - 按需加载:使用
client:load确保 JS 只在需要时加载
安全考虑
- CORS 配置:限制跨域访问
- IP 匿名化:不记录完整 IP 地址(可改进为哈希)
- SQL 注入防护:使用参数化查询
- 速率限制:通过 IP 去重防止刷量
开发体验
整个开发过程由 Claude Code 完成,流程顺利:
- 组件开发 → Svelte 5 runes 语法简洁
- 数据库设计 → SQL 表结构清晰
- Worker 部署 → Wrangler CLI 易用
- 问题排查 → TypeScript 类型检查提前发现问题
- 环境配置 → Cloudflare 控制台直观
从开发到部署大约 2 小时,大部分时间用于调试环境变量配置。
未来改进
目前的基础功能已经完善,未来可以考虑:
- 数据可视化:添加访问趋势图表
- 地域统计:记录访客国家/城市
- 来源追踪:记录访问来源(referral)
- 页面统计:记录各页面访问量
- 实时更新:使用 WebSocket 实现实时访问监控
- 数据导出:支持导出 CSV/Excel 报表
- 定期重置:每日 0 点自动重置”今日访问”