自己写的网站如何统计访问数据?一套轻量方案搞定 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 等中间件进一步扩展能力。

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

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

相关推荐
桂月二二27 分钟前
Vue3服务端渲染深度实战:SSR架构优化与企业级应用
前端·vue.js·架构
萌萌哒草头将军28 分钟前
🚀🚀🚀 这六个事半功倍的 Pinia 库,你一定要知道!
前端·javascript·vue.js
_一条咸鱼_29 分钟前
深入剖析 Vue 状态管理模块原理(七)
前端·javascript·面试
rocky19139 分钟前
谷歌浏览器插件 录制动态 DOM 元素
前端
谁还不是一个打工人42 分钟前
css解决边框四个角有颜色
前端·css
uhakadotcom1 小时前
一文读懂DSP(需求方平台):程序化广告投放的核心基础与实战案例
后端·面试·github
uhakadotcom1 小时前
拟牛顿算法入门:用简单方法快速找到函数最优解
算法·面试·github
海晨忆1 小时前
【Vue】v-if和v-show的区别
前端·javascript·vue.js·v-show·v-if
小黑屋的黑小子2 小时前
【数据结构】反射、枚举以及lambda表达式
数据结构·面试·枚举·lambda表达式·反射机制
JiangJiang2 小时前
🚀 Vue人看React useRef:它不只是替代 ref
javascript·react.js·面试