自己写的网站如何统计访问数据?一套轻量方案搞定 PV、UV 和停留时间

自己写的网站如何统计访问数据?一套轻量方案搞定 PV、UV 和停留时间

面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系: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,这种方式更稳,不容易丢。

// 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 的类,它用单例模式保证全局只有一个实例。

// 单例模式确保全局唯一的事件跟踪器

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 事件判断页面有没有被“藏起来”。

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 秒打一次“时间点”,把这几秒钟记进去。

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)离开检测

当用户关闭或刷新页面时,我们就把累计时间发送出去。

private handlePageExit = (): void => {

if (this.heartbeatInterval) {

clearInterval(this.heartbeatInterval);

}

this.sendTrackingData();

};

3. 数据怎么发出去更靠谱?

我们用 navigator.sendBeacon,这个 API 是专门为“页面关闭时也能发请求”设计的。相比 fetch 更保险,特别适合这种统计用途。

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,

});

}

🧠 后端实现

首先我们先贴上后端的完整代码,如下所示:

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> = new Map();

// 记录IP和访客ID的映射关系

const ipVisitorMap: Map = 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(

`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(

`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,接收请求并处理数据:

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

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,防止同一个人反复刷。

🧩 批量写入优化

前端一来一条就写库?那数据库肯定爆。我们先存在内存里,凑够一批再一起写。

visitBuffer.push(record);

if (visitBuffer.length >= BUFFER_SIZE) {

flushVisitBuffer();

}

3. 内存清理机制

防止内存爆了,我们每天清一次“老 IP 数据”:

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 等中间件进一步扩展能力。

如下图所示,因为我的数据库已经过期了,所以会报错的:

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

🌸 相关推荐

Linux安装和配置Nginx服务器
英国正版365官方网站

Linux安装和配置Nginx服务器

📅 08-13 👀 3728
Jira 入门 - 新手综合指南
28365备用网址

Jira 入门 - 新手综合指南

📅 07-12 👀 7259
閾字笔顺
28365备用网址

閾字笔顺

📅 07-14 👀 5984