3050 字
15 分钟
为博客添加运行时间和访问统计功能

前言#

在运营博客的过程中,我想添加两个实用的功能:

  1. 运行时间显示:让访客看到博客已经运行了多长时间,增强博客的”存活感”
  2. 访问统计:显示总访问量和今日访问量,了解博客的受欢迎程度

对于这两个功能,我调研了几种实现方案:

运行时间

  • ✅ 纯前端 JavaScript 实现(推荐)
  • ❌ 后端计算(浪费资源)

访问统计

  • ✅ Cloudflare Web Analytics + 自建计数器(推荐)
  • ❌ 传统服务器端统计(部署复杂)
  • ❌ 第三方统计服务(有隐私顾虑)

最终,我选择了以下技术方案:

  • 运行时间:纯前端 JavaScript,每秒更新
  • 访问统计:Cloudflare Workers + D1 数据库(边缘计算,无服务器)

同样,这次的开发工作由 Claude Code 完成。本文将记录整个开发过程,重点关注技术选型、实现细节和部署问题。

需求分析#

功能需求#

运行时间显示

  • 显示博客运行的总时间(天、时、分、秒)
  • 每秒自动更新,实时跳动
  • 支持在配置中设置建站日期

访问统计

  • 显示总访问量(累计)
  • 显示今日访问量
  • 防止重复计数(同一 IP 1 小时内只计一次)
  • 数据持久化存储

技术挑战#

  1. 前端组件:如何在 Astro 中集成 Svelte 交互组件
  2. 边缘计算:如何使用 Cloudflare Workers 实现无服务器 API
  3. 数据库操作:如何在 D1 中设计表结构和查询逻辑
  4. 环境变量:如何在 Cloudflare Pages 中配置环境变量
  5. 类型安全:如何避免 TypeScript 编译错误

技术架构#

基于现有 Fuwari 模板:

  • Astro 5.x:静态站点生成器
  • Svelte 5:响应式 UI 组件(使用 $state runes)
  • 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 的 $state runes 实现响应式状态
  • onMount 生命周期启动定时器
  • onDestroy 清理定时器,避免内存泄漏
  • tabular-nums CSS 属性确保数字等宽,避免跳动

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 地址:

Terminal window
# 访问计数器 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 数据库#

Terminal window
# 创建数据库
wrangler d1 create blog-visit-counter-db
# 记录返回的 database_id
# database_id = "ad141ae5-65b7-43ae-991b-d733e46825cf"

初始化数据库表结构#

Terminal window
# 执行 schema.sql
wrangler d1 execute blog-visit-counter-db --file=workers/visit-counter/schema.sql

部署 Worker#

Terminal window
# 部署到 Cloudflare Workers
wrangler deploy

配置环境变量#

在 Cloudflare Pages 控制台配置:

  1. 进入项目 → Settings → Environment variables

  2. 添加变量:

    • Name: PUBLIC_VISIT_COUNTER_API
    • Value: https://blog-visit-counter.xxxxxxxxxxx.workers.dev
    • Environments: Production 和 Preview
  3. 保存后触发新部署

遇到的问题与解决方案#

问题 1:类型定义缺失导致 TypeScript 编译错误#

错误信息

error ts(2353): Object literal may only specify known properties,
and 'websiteStartDate' does not exist in type 'SiteConfig'.

原因:在 src/config.ts 中添加了新属性,但忘记更新类型定义。

解决方案

src/types/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 数据。

解决方案

  1. 在 Cloudflare Pages 控制台添加环境变量
  2. 触发新部署
  3. 验证配置

经验教训.env 文件只在本地有效,生产环境需要在部署平台配置环境变量。

问题 5:Worker 部署失败#

错误信息

Error: No such D1 database

原因wrangler.toml 中的 database_id 与实际创建的数据库不匹配。

解决方案

Terminal window
# 查看已创建的数据库
wrangler d1 list
# 更新 wrangler.toml 中的 database_id

经验教训:确保 wrangler.toml 配置与 Cloudflare 账号中的资源一致。

最终效果#

运行时间显示#

  • 实时显示博客运行的天、时、分、秒
  • 每秒自动更新,数字跳动
  • 响应式设计,适配移动端

访问统计功能#

  • 显示总访问量和今日访问量
  • 数字带千分位分隔符(如:1,234)
  • 防重复计数(1 小时内同一 IP 只计一次)
  • 加载和错误状态处理

UI 设计#

  • 统一使用主题色 var(--primary)
  • 顶部渐变装饰条
  • LIVE 实时指示器
  • 卡片 hover 效果
  • 紧凑型设计,与标签卡片大小一致

技术总结#

关键技术点#

  1. Svelte 5 Runes:使用 $state 实现细粒度响应式
  2. Cloudflare Workers:边缘计算,全球低延迟
  3. D1 数据库:SQLite 兼容,支持 SQL 查询
  4. 环境变量:本地开发与生产环境分离
  5. 类型安全:TypeScript 严格模式,减少运行时错误

性能优化#

  • 边缘计算:Worker 部署在全球节点,响应速度快
  • 防重复计数:减少无效请求,降低数据库压力
  • 客户端缓存:使用 Cache-Control 头控制缓存策略
  • 按需加载:使用 client:load 确保 JS 只在需要时加载

安全考虑#

  • CORS 配置:限制跨域访问
  • IP 匿名化:不记录完整 IP 地址(可改进为哈希)
  • SQL 注入防护:使用参数化查询
  • 速率限制:通过 IP 去重防止刷量

开发体验#

整个开发过程由 Claude Code 完成,流程顺利:

  1. 组件开发 → Svelte 5 runes 语法简洁
  2. 数据库设计 → SQL 表结构清晰
  3. Worker 部署 → Wrangler CLI 易用
  4. 问题排查 → TypeScript 类型检查提前发现问题
  5. 环境配置 → Cloudflare 控制台直观

从开发到部署大约 2 小时,大部分时间用于调试环境变量配置。

未来改进#

目前的基础功能已经完善,未来可以考虑:

  1. 数据可视化:添加访问趋势图表
  2. 地域统计:记录访客国家/城市
  3. 来源追踪:记录访问来源(referral)
  4. 页面统计:记录各页面访问量
  5. 实时更新:使用 WebSocket 实现实时访问监控
  6. 数据导出:支持导出 CSV/Excel 报表
  7. 定期重置:每日 0 点自动重置”今日访问”

参考资源#

相关文章#

为博客添加运行时间和访问统计功能
https://fuwari.vercel.app/posts/runtime-and-visit-counter/
作者
Croco
发布于
2026-02-19
许可协议
CC BY-NC-SA 4.0