面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:
yunmz777
。
为什么我们要记录网站的 PV、UV 和页面停留时间?
做网站不是光把内容堆上去就完事了,关键是------到底有没有人看、谁在看、看了多久。想搞明白这些事儿,就得看几个重要的数据指标:PV、UV ,还有页面停留时间。
1. PV 是什么?看得多不多就靠它了
PV,全名是 Page View,也就是"页面浏览量"。
通俗点说,就是你网站里的页面被点了多少次。比如你有个博客,一个用户点开一篇文章,然后又点了一篇,PV 就是 2。如果他把第一篇文章刷了 5 次,也都算数,总共就是 6。
为啥要看 PV?:
-
看网站热不热闹。PV 多,说明有人来看;没人点,说明门可罗雀得像鬼城。
-
能知道哪篇内容受欢迎,哪篇没人搭理。写文章、上产品、搞活动,都得看这个参考参考。
-
比如你写了篇《教你一招搞定早起》的文章,PV 爆了,说明大家起不来床都想看你这招,那下次你就知道继续写这种生活小技巧,用户爱看!
2. UV 是谁来看了?人多不多靠它判断
UV 是 Unique Visitor,翻译过来就是"独立访客"。
简单点说,就是来了多少不同的人。一个人今天点你网站 100 次,也只算 1 个 UV。
为啥要看 UV?:
- 这个能帮你看到底有多少人来过你家门口,不是看谁来几次,是看来了几个人。
- UV 高了,说明你吸引到新用户啦;UV 低但 PV 高,那就可能是那几个老铁在狂刷。
- 比如你做了个小红书推广,推广前每天 UV 50,推广后直接蹦到 500,那这波广告花得值啊!
3. 页面停留时间,说明你内容有没有"留人"
这个很好理解,就是用户点进来之后,到底在你网站上待了多久。
-
如果一进来就走,那就是"秒退";
-
如果能看个一两分钟甚至更久,那就说明你内容挺吸引人。
为啥要看这个?
- 停留时间越长,说明你网站越有料,能留得住人。
- 停留时间太短,可能是页面太丑、加载太慢、内容太无聊、广告太烦......
- 举个例子:你开了个在线课程网站,结果大家进来平均只待 5 秒,那你就得想想,是不是介绍没写清楚,还是视频封面不够吸引人。
总结一下,用吃饭打个比方:
- PV 就是你餐厅被进出多少次,哪怕一个人走进来出去十回,也记十次;
- UV 是来了多少不同的客人,不管他吃几碗饭,都算一个人;
- 页面停留时间嘛,就是人家到底在你店里坐了 5 分钟,还是坐了 1 个小时。
有了这些数据,咱们才能知道网站到底哪儿做得好,哪儿还得改。不然就是蒙着眼开车,早晚得翻车 🚗💥
🧩 系统架构概览
整个统计系统分成三大块:
- 前端识别访客身份:用 FingerprintJS 给访客贴上"身份标签"
- 前端记录停留时长 :用 Singleton 模式的
AnalyticsTracker
,配合页面可见性 API 精准记录停留时间 - 后端处理统计数据:接收并处理前端数据,实现 PV/UV 统计、数据去重和性能优化
🖥️ 前端实现
1. 访客识别和初始化
我们用了 FingerprintJS,它能根据用户的浏览器信息、系统设置、分辨率等生成一个独特的 ID。比起传统依赖 Cookie,这种方式更稳,不容易丢。
ts
// AnalyticsProvider.tsx - 初始化 FingerprintJS
const initFingerprint = async () => {
try {
const fp = await FingerprintJS.load();
const result = await fp.get();
const vid = result.visitorId;
// 设置 visitorId,但不立刻发送数据
track({ visitorId: vid }, true);
} catch (error) {
console.error("Failed to initialize fingerprint:", error);
}
};
这段代码在页面加载时只跑一次,确保我们对这个用户"有印象"。
2. 精准记录停留时间
核心功能是一个叫 AnalyticsTracker
的类,它用单例模式保证全局只有一个实例。
ts
// 单例模式确保全局唯一的事件跟踪器
class AnalyticsTracker {
private static instance: AnalyticsTracker;
private pageEnterTime: number = 0;
private visitorId: string | null = null;
private exitEventRegistered = false;
private heartbeatInterval: NodeJS.Timeout | null = null;
private lastHeartbeat: number = 0;
private accumulatedDuration: number = 0;
private isVisible: boolean = true;
private constructor() {
// 记录页面进入时间
this.pageEnterTime = Date.now();
this.lastHeartbeat = this.pageEnterTime;
// 初始化可见性状态
if (typeof document !== "undefined") {
this.isVisible = document.visibilityState === "visible";
}
// 在客户端环境中注册页面事件
if (typeof window !== "undefined") {
// 注册页面退出事件(只注册一次)
if (!this.exitEventRegistered) {
// 使用多个事件来确保能捕获用户离开
window.addEventListener("beforeunload", this.handlePageExit);
window.addEventListener("unload", this.handlePageExit);
window.addEventListener("pagehide", this.handlePageExit);
// 页面可见性变化时更新时间
document.addEventListener(
"visibilitychange",
this.handleVisibilityChange
);
// 启动心跳计时器,定期更新累计时间
this.startHeartbeat();
this.exitEventRegistered = true;
// 调试信息
console.log("分析跟踪器初始化完成,开始记录页面停留时间");
}
}
}
private startHeartbeat(): void {
// 每5秒更新一次累计时间,更频繁的心跳可以提高准确性
this.heartbeatInterval = setInterval(() => {
if (this.isVisible) {
const now = Date.now();
const increment = now - this.lastHeartbeat;
this.accumulatedDuration += increment;
this.lastHeartbeat = now;
// 调试信息 - 每分钟输出一次
if (this.accumulatedDuration % 60000 < 5000) {
console.log(
`当前累计停留时间: ${Math.round(this.accumulatedDuration / 1000)}秒`
);
}
}
}, 5000);
}
private handleVisibilityChange = (): void => {
const now = Date.now();
if (document.visibilityState === "hidden") {
// 页面隐藏时,累计时间并更新状态
if (this.isVisible) {
const increment = now - this.lastHeartbeat;
this.accumulatedDuration += increment;
this.isVisible = false;
console.log(
`页面隐藏,累计时间增加: ${increment}毫秒,总计: ${this.accumulatedDuration}毫秒`
);
}
} else if (document.visibilityState === "visible") {
// 页面再次可见时,重置最后心跳时间并更新状态
this.lastHeartbeat = now;
this.isVisible = true;
console.log("页面再次可见,重置心跳时间");
}
};
public static getInstance(): AnalyticsTracker {
if (!AnalyticsTracker.instance) {
AnalyticsTracker.instance = new AnalyticsTracker();
}
return AnalyticsTracker.instance;
}
public setVisitorId(id: string): void {
this.visitorId = id;
console.log(`设置访客ID: ${id}`);
}
// 统一的跟踪方法
public track(data: any, setVisitorIdOnly: boolean = false): void {
// 只在客户端环境中执行
if (typeof window === "undefined") {
return;
}
// 如果提供了访客ID,设置到跟踪器中
if (data.visitorId) {
this.setVisitorId(data.visitorId);
}
// 如果只是设置访客ID,不发送请求
if (setVisitorIdOnly) {
return;
}
// 确保数据中包含访客ID
if (!data.visitorId && this.visitorId) {
data.visitorId = this.visitorId;
}
// 添加时间戳(如果没有)
if (!data.timestamp) {
data.timestamp = Date.now();
}
// 如果是手动调用track方法,并且没有指定durationMs,则计算当前的累计时间
if (!data.durationMs && !data.duration) {
const now = Date.now();
let totalDuration = this.accumulatedDuration;
// 如果页面当前可见,加上最后一段时间
if (this.isVisible) {
totalDuration += now - this.lastHeartbeat;
}
data.durationMs = totalDuration;
}
// 使用 sendBeacon 发送数据
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(data)], {
type: "application/json",
});
const success = navigator.sendBeacon("/api/track", blob);
console.log(`数据发送${success ? "成功" : "失败"}: `, data);
} else {
// 浏览器不支持 sendBeacon,使用 fetch
fetch("/api/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
keepalive: true,
})
.then(() => console.log("数据发送成功: ", data))
.catch((err) => console.error("数据发送失败: ", err));
}
}
private sendTrackingData(): void {
if (!this.visitorId) {
console.warn("未设置访客ID,无法发送跟踪数据");
return;
}
// 计算总停留时间(毫秒)
const now = Date.now();
let totalDuration = this.accumulatedDuration;
// 如果页面当前可见,加上最后一段时间
if (this.isVisible) {
totalDuration += now - this.lastHeartbeat;
}
// 使用毫秒作为单位
if (totalDuration > 0) {
const data = {
visitorId: this.visitorId,
referrer: this.getReferrer(),
durationMs: totalDuration,
timestamp: now,
};
this.track(data);
// 输出调试信息
console.log(
`发送停留时间数据: ${totalDuration}毫秒 (${Math.round(
totalDuration / 1000
)}秒)`
);
} else {
console.warn("停留时间为0或负值,不发送数据");
// 调试信息,帮助诊断问题
console.log("调试信息:", {
accumulatedDuration: this.accumulatedDuration,
isVisible: this.isVisible,
lastHeartbeat: this.lastHeartbeat,
now: now,
diff: now - this.lastHeartbeat,
});
}
}
private handlePageExit = (): void => {
// 清除心跳定时器
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
// 发送最终的跟踪数据
this.sendTrackingData();
};
private getReferrer(): string {
try {
return document.referrer || "";
} catch {
return "";
}
}
// 公开方法,用于手动获取当前累计的停留时间(毫秒)
public getCurrentDuration(): number {
const now = Date.now();
let totalDuration = this.accumulatedDuration;
// 如果页面当前可见,加上最后一段时间
if (this.isVisible) {
totalDuration += now - this.lastHeartbeat;
}
return totalDuration;
}
}
// 创建一个安全的获取实例的函数
const getAnalyticsTracker = () => {
// 确保只在客户端环境中创建实例
if (typeof window === "undefined") {
// 返回一个空对象,具有相同的接口但不执行任何操作
return {
setVisitorId: () => {},
track: () => {},
getCurrentDuration: () => 0,
};
}
return AnalyticsTracker.getInstance();
};
// 导出单例实例的方法
const analyticsTracker = getAnalyticsTracker();
// 导出统一的跟踪接口
export const track = (data: any, setVisitorIdOnly: boolean = false): void => {
// 确保只在客户端环境中执行
if (typeof window === "undefined") {
return;
}
// 发送数据
analyticsTracker.track(data, setVisitorIdOnly);
};
// 导出获取当前停留时间的方法
export const getCurrentDuration = (): number => {
if (typeof window === "undefined") {
return 0;
}
return analyticsTracker.getCurrentDuration();
};
这个类里,我们用了三种机制来保证统计的时间更贴近用户真实的阅读行为:
✅ 1)可见性检测
用浏览器的 visibilitychange
事件判断页面有没有被"藏起来"。
ts
private handleVisibilityChange = (): void => {
const now = Date.now();
if (document.visibilityState === 'hidden') {
if (this.isVisible) {
const increment = now - this.lastHeartbeat;
this.accumulatedDuration += increment;
this.isVisible = false;
}
} else if (document.visibilityState === 'visible') {
this.lastHeartbeat = now;
this.isVisible = true;
}
};
也就是说:用户切到别的标签页、最小化窗口,我们就不计时了!
✅ 2)心跳机制
页面长时间打开不动?没关系,我们每 5 秒打一次"时间点",把这几秒钟记进去。
ts
private startHeartbeat(): void {
this.heartbeatInterval = setInterval(() => {
if (this.isVisible) {
const now = Date.now();
const increment = now - this.lastHeartbeat;
this.accumulatedDuration += increment;
this.lastHeartbeat = now;
}
}, 5000);
}
✅ 3)离开检测
当用户关闭或刷新页面时,我们就把累计时间发送出去。
ts
private handlePageExit = (): void => {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
}
this.sendTrackingData();
};
3. 数据怎么发出去更靠谱?
我们用 navigator.sendBeacon
,这个 API 是专门为"页面关闭时也能发请求"设计的。相比 fetch
更保险,特别适合这种统计用途。
ts
if (navigator.sendBeacon) {
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
navigator.sendBeacon("/api/track", blob);
} else {
fetch("/api/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
keepalive: true,
});
}
🧠 后端实现
首先我们先贴上后端的完整代码,如下所示:
ts
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import type { RowDataPacket } from "mysql2";
import { pool } from "@/lib/db";
// 内存缓冲区,用于批量处理访问记录
interface VisitRecord {
visitorId: string;
referrer: string;
ipAddress: string;
timestamp: string;
durationMs: number; // 停留时间(毫秒)
date: string; // 日期,格式:YYYY-MM-DD
}
// 数据库记录接口
interface VisitRow extends RowDataPacket {
id: number;
}
// 是否为开发环境
const isDevelopment = process.env.NODE_ENV === "development";
// 缓冲区大小和刷新间隔
const BUFFER_SIZE = isDevelopment ? 1 : 50; // 开发环境下每条记录都立即写入
const FLUSH_INTERVAL = isDevelopment ? 1000 : 60000; // 开发环境下每秒刷新一次
// 访问记录缓冲区
const visitBuffer: VisitRecord[] = [];
// 已记录的IP(按日期)
const recordedIPs: Map<string, Set<string>> = new Map();
// 记录IP和访客ID的映射关系
const ipVisitorMap: Map<string, { visitorId: string }> = new Map();
// 上次刷新时间
let lastFlushTime = Date.now();
// 获取当前时间的MySQL格式字符串(YYYY-MM-DD HH:MM:SS)
function getCurrentMySQLTimestamp(): string {
const now = new Date();
// 格式化为 YYYY-MM-DD HH:MM:SS
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
// 获取当前日期(YYYY-MM-DD)
function getCurrentDate(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
// 批量保存访问记录到数据库
async function flushVisitBuffer() {
if (visitBuffer.length === 0) return;
const recordsToInsert = [...visitBuffer];
visitBuffer.length = 0; // 清空缓冲区
try {
const connection = await pool.getConnection();
try {
// 使用事务批量插入
await connection.beginTransaction();
for (const record of recordsToInsert) {
// 检查是否已存在该IP的记录
const [rows] = await connection.execute<VisitRow[]>(
`SELECT id FROM visits
WHERE ip_address = ? AND DATE(timestamp) = ?`,
[record.ipAddress, record.date]
);
if (rows.length === 0) {
// 不存在记录,插入新记录
await connection.execute(
`INSERT INTO visits (visitor_id, referrer, ip_address, timestamp, duration_ms)
VALUES (?, ?, ?, ?, ?)`,
[
record.visitorId,
record.referrer,
record.ipAddress,
record.timestamp,
record.durationMs,
]
);
}
}
await connection.commit();
} catch (error) {
console.error("批量保存记录失败:", error);
await connection.rollback();
} finally {
connection.release();
}
} catch (error) {
console.error("获取数据库连接失败:", error);
}
lastFlushTime = Date.now();
}
// 直接保存单条记录到数据库(开发环境使用)
async function saveVisitRecord(record: VisitRecord) {
try {
const connection = await pool.getConnection();
try {
// 检查是否已存在该IP的记录
const [rows] = await connection.execute<VisitRow[]>(
`SELECT id FROM visits
WHERE ip_address = ? AND DATE(timestamp) = ?`,
[record.ipAddress, record.date]
);
if (rows.length === 0) {
// 不存在记录,插入新记录
await connection.execute(
`INSERT INTO visits (visitor_id, referrer, ip_address, timestamp, duration_ms)
VALUES (?, ?, ?, ?, ?)`,
[
record.visitorId,
record.referrer,
record.ipAddress,
record.timestamp,
record.durationMs,
]
);
console.log(
`插入了新的访问记录: ${record.ipAddress}, 停留时间: ${
record.durationMs
}毫秒 (${Math.round(record.durationMs / 1000)}秒)`
);
} else {
console.log(
`跳过已存在的记录: ${record.ipAddress}, 日期: ${record.date}`
);
}
} catch (error) {
console.error("保存访问记录失败:", error);
} finally {
connection.release();
}
} catch (error) {
console.error("获取数据库连接失败:", error);
}
}
// 定期刷新缓冲区
setInterval(() => {
if (Date.now() - lastFlushTime >= FLUSH_INTERVAL && visitBuffer.length > 0) {
flushVisitBuffer();
}
}, FLUSH_INTERVAL / 2);
// 清理过期的IP记录(保留最近7天)
setInterval(() => {
const now = new Date();
const cutoffDate = new Date(now.setDate(now.getDate() - 7))
.toISOString()
.split("T")[0];
// 使用日期作为键来删除过期记录
for (const date of recordedIPs.keys()) {
if (date < cutoffDate) {
recordedIPs.delete(date);
}
}
// 清理过期的IP-访客映射
for (const key of ipVisitorMap.keys()) {
const [, date] = key.split("|");
if (date < cutoffDate) {
ipVisitorMap.delete(key);
}
}
}, 86400000); // 每24小时清理一次
export async function POST(request: NextRequest) {
try {
// 解析请求体
const data = await request.json();
console.log(data, 111111111);
// 使用自定义函数获取当前时间和日期
const timestamp = getCurrentMySQLTimestamp();
const today = getCurrentDate();
// 调试: 打印所有请求头
console.log("所有请求头:", Object.fromEntries(request.headers.entries()));
// 优先使用 X-Real-IP
const ipAddress =
request.headers.get("x-real-ip") ||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
"unknown";
// 调试信息
console.log("IP地址获取:", {
"x-real-ip": request.headers.get("x-real-ip"),
"x-forwarded-for": request.headers.get("x-forwarded-for"),
"final-ip": ipAddress,
});
const referrer = data.referrer || "";
const visitorId = data.visitorId;
// 使用 durationMs 字段,如果不存在则尝试使用 duration 字段(向后兼容)
const durationMs =
data.durationMs !== undefined
? parseInt(data.durationMs)
: parseInt(data.duration) * 1000 || 0;
// 记录调试信息,移除 path 字段
console.log("接收到的请求数据:", {
visitorId,
referrer,
ipAddress,
timestamp,
today,
durationMs: `${durationMs}毫秒 (${Math.round(durationMs / 1000)}秒)`,
});
if (!visitorId) {
// 如果没有访客ID,直接返回204状态码(无内容)
return new NextResponse(null, { status: 204 });
}
// 生成IP和日期的组合键
const ipDateKey = `${ipAddress}|${today}`;
// 检查今天是否已经记录过这个IP
if (!recordedIPs.has(today)) {
recordedIPs.set(today, new Set());
}
const todayIPs = recordedIPs.get(today)!;
// 如果今天已经记录过这个IP,直接忽略
if (todayIPs.has(ipAddress)) {
console.log(`忽略重复访问: ${ipAddress}, 日期: ${today}`);
return new NextResponse(null, { status: 204 });
}
// 标记这个IP今天已经记录过
todayIPs.add(ipAddress);
// 更新IP-访客映射
ipVisitorMap.set(ipDateKey, { visitorId });
const record = {
visitorId,
referrer,
ipAddress,
timestamp,
durationMs,
date: today,
};
if (isDevelopment) {
// 开发环境下直接保存记录
await saveVisitRecord(record);
} else {
// 生产环境下添加到缓冲区
visitBuffer.push(record);
console.log("访问记录已添加到缓冲区:", {
visitorId,
referrer,
ipAddress,
timestamp,
durationMs: `${durationMs}毫秒 (${Math.round(durationMs / 1000)}秒)`,
});
// 如果缓冲区达到阈值,批量保存
if (visitBuffer.length >= BUFFER_SIZE) {
flushVisitBuffer();
}
}
// 无论是否记录数据,都返回204状态码(无内容)
return new NextResponse(null, { status: 204 });
} catch (error) {
console.error("记录访问数据错误:", error);
// 即使出错也返回204,不向前端暴露错误信息
return new NextResponse(null, { status: 204 });
}
}
// 确保进程退出前保存缓冲区中的数据
process.on("SIGTERM", async () => {
await flushVisitBuffer();
process.exit(0);
});
process.on("SIGINT", async () => {
await flushVisitBuffer();
process.exit(0);
});
1. 数据接收和处理(Next.js API)
后端用的是 Next.js 的 API Route,接收请求并处理数据:
ts
export async function POST(request: NextRequest) {
try {
const data = await request.json();
const timestamp = getCurrentMySQLTimestamp();
const today = getCurrentDate();
const ipAddress =
request.headers.get("x-real-ip") ||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
"unknown";
const visitorId = data.visitorId;
const durationMs =
data.durationMs !== undefined
? parseInt(data.durationMs)
: parseInt(data.duration) * 1000 || 0;
// 后面是数据记录逻辑
} catch (error) {
console.error("记录访问数据错误:", error);
return new NextResponse(null, { status: 204 });
}
}
2. UV 去重 & 缓存优化
我们用了几招来保证数据不重复又不拖后腿:
🧩 按 IP + 日期 记录 UV
ts
if (!recordedIPs.has(today)) {
recordedIPs.set(today, new Set());
}
const todayIPs = recordedIPs.get(today)!;
if (todayIPs.has(ipAddress)) {
return new NextResponse(null, { status: 204 });
}
todayIPs.add(ipAddress);
这样每天每个 IP 只算一次 UV,防止同一个人反复刷。
🧩 批量写入优化
前端一来一条就写库?那数据库肯定爆。我们先存在内存里,凑够一批再一起写。
ts
visitBuffer.push(record);
if (visitBuffer.length >= BUFFER_SIZE) {
flushVisitBuffer();
}
3. 内存清理机制
防止内存爆了,我们每天清一次"老 IP 数据":
ts
setInterval(() => {
const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
.toISOString()
.split("T")[0];
for (const date of recordedIPs.keys()) {
if (date < cutoffDate) {
recordedIPs.delete(date);
}
}
}, 86400000); // 每 24 小时清一次
📌 最后总结一句话
这套系统不仅能搞定 PV、UV 和停留时间的准确统计,特别适合用于自建网站、博客或者中小型后台系统。
如果你要支撑更大规模的业务,比如高并发的中大型 Web 应用,那还建议配合 Redis、RabbitMQ 等中间件做缓存和异步处理,保证系统的性能和稳定性。
总结
这篇文章分享了一个前后端配合实现的网站统计系统,能准确记录用户的 PV、UV 和页面停留时间。前端通过 FingerprintJS 和页面可见性 API 精准识别访客行为,后端则通过去重、缓冲和清理机制保障数据的准确性和系统性能。整个方案轻量灵活,适合中小型项目使用。如果你要支持更大规模的并发访问,建议结合 Redis、RabbitMQ 等中间件进一步扩展能力。
如下图所示,因为我的数据库已经过期了,所以会报错的:

当页面离开的时候会触发这个后端接口。