自己写的网站如何统计访问数据?一套轻量方案搞定 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 个小时。

有了这些数据,咱们才能知道网站到底哪儿做得好,哪儿还得改。不然就是蒙着眼开车,早晚得翻车 🚗💥

🧩 系统架构概览

整个统计系统分成三大块:

  1. 前端识别访客身份:用 FingerprintJS 给访客贴上"身份标签"
  2. 前端记录停留时长 :用 Singleton 模式的 AnalyticsTracker,配合页面可见性 API 精准记录停留时间
  3. 后端处理统计数据:接收并处理前端数据,实现 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 等中间件进一步扩展能力。

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

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

相关推荐
猫和老许几秒前
Vue 3 拖拽排序功能优化实现:从原理到实战应用
前端·javascript·vue.js
YGY_Webgis糕手之路4 分钟前
Leaflet 综合案例 - 路径规划
前端·gis
sq8004 分钟前
ag-grid-vue3 降级,支持低版本浏览器
前端·javascript·vue.js
前端灵派派5 分钟前
cesium 实现轨迹回放
前端·cesium
水纹5 分钟前
继续研究pdfjs保存和还原批注
前端
兔年鸿运Q小Q5 分钟前
html转word下载
javascript·vue.js·word
程序员海军6 分钟前
使用 Kiro AI IDE 3小时实现全栈应用Admin系统
前端·后端·aigc
Conda6 分钟前
basic code
面试
小七mod6 分钟前
【Spring】Spring Boot启动过程源码解析
java·spring boot·spring·面试·ssm·源码
你这个年龄怎么睡得着的9 分钟前
玩转vite性能优化
前端·vue.js·vite