创见博客
曝光埋点数据有效性的改进
七崽爱吃小饼干2026/03/04阅读 2

前端埋点「页面停留时长」设计方案

只靠 visibilitychange 统计的停留时长,会包含大量“用户挂着页面没看、没操作”的无效数据,这也是所有埋点 SDK(神策、友盟、百度统计、Google Analytics)都必须解决的问题。


一、目前的问题

现在的方案:

  • 页面 visible → 开始计时
  • 页面 hidden → 停止计时

缺陷: 用户打开页面放着不动 10 分钟,没有任何操作、没有滚动、没有点击,依然会被算成有效停留 10 分钟,数据失真。

主流埋点库统一思路:

  • 「有效停留时长」≠ 自然停留时长
  • 「有效停留时长」= 用户真正在看 + 有交互行为的时间

二、主流埋点库的标准设计(神策/GA/友盟/百度统计)

它们全部采用 「活跃会话机制」:

1. 核心规则:会话超时机制(Session Timeout)

行业统一标准: 用户连续 30 分钟无任何操作 → 会话结束,停止计时 你再回来 → 算新的一次访问

这是最核心、最有效的净化数据方案。


2. 判定“用户活跃”的行为(必须监听)

主流库会把以下行为定义为活跃:

  • mousemove(鼠标移动)
  • mousedown / click(点击)
  • keydown(键盘输入)
  • touchstart(移动端触摸)
  • scroll(滚动)
  • visibilitychange(页面可见/隐藏)

只要触发任意一个 → 刷新活跃时间


3. 计时策略(最标准的实现)

三段式计时:

  1. 页面可见 + 用户活跃 → 正常计时
  2. 页面可见但无操作超过 N 分钟 → 暂停计时
  3. 页面不可见 → 立即暂停计时

行业通用配置:

  • 无操作超时:30 分钟(可配置)
  • 最小有效时长:2 秒(低于 2s 丢弃,防止快速刷新)
  • 最大单次时长:60 分钟(防止死数据)

三、最终设计

有效停留时长 =
  页面处于可见状态
  + 用户处于活跃状态
  + 没有超过无操作超时时间
  这段时间的总和

四、实现逻辑

  1. 页面加载 → 开始会话
  2. 监听所有活跃事件 → 每次触发更新 lastActiveTime
  3. 定时器每秒检查:
    • 页面是否可见?
    • 是否在30分钟内有操作?
    • 满足 → 累计时长
  4. 页面卸载 / 隐藏 / 超时 → 停止 & 上报
  5. 上报前过滤:<2s 的数据丢弃

四、埋点 hooks 的最终升级代码

ts
import { TrackConfig, TrackType } from "../../types";
import { useTrack } from "./useTrack";
import { useEffect, useRef } from "react";
import { getTrackGlobalConfig } from "../config";

// 默认配置(兜底值,全局配置可覆盖)
const DEFAULT_PAGE_STAY_CONFIG = {
    timeout: 30 * 60 * 1000,      // 30分钟无操作 → 暂停计时
    minDuration: 2000,            // 最小有效时长2秒,低于不上报
    maxDuration: 60 * 60 * 1000,   // 最大单页时长60分钟,防止异常数据
    checkInterval: 1000,          // 每秒检查一次活跃状态
};

/**
 * 页面停留时长埋点 Hook - 自动监听页面/组件的停留时长并上报
 * @param eventName 停留时长事件名称(必填)
 * @param customParams 自定义埋点参数(可选)
 * @param config 埋点配置项(可选)
 */
export const useTrackPageStay = (
    eventName: string,
    customParams: Record<string, any> = {},
    config: TrackConfig = {}
) => {
    // 初始化埋点上报方法
    // 默认的埋点上报
    const { triggerTrack } = useTrack(
        { eventName, type: TrackType.PAGE_STAY, ...customParams },
        config
    );
    // 单独埋点上报
    const { triggerTrack: triggerSingleTrack } = useTrack(
        { eventName, type: TrackType.PAGE_STAY, ...customParams },
        {
            ...config,
            enableBatch: false, // 关闭批量上报
        }
    );

    // ===================== 持久化状态(不受渲染/闭包影响) =====================
    // 当前计时片段的开始时间
    const startTimeRef = useRef<number | null>(null);
    // 用户最后一次活跃时间
    const lastActiveRef = useRef<number>(Date.now());
    // 定时检查器引用
    const timerRef = useRef<number | null>(null);
    // 是否正在计时中
    const isTrackingRef = useRef(false);


    // ===================== 副作用:监听页面状态 & 用户行为 =====================
    useEffect(() => {
        const getLastPageStayConfig = () => {
            // 合并配置:默认 < 全局 < 入参(优先级从低到高)
            const globalConfig = getTrackGlobalConfig();
            return {
                ...DEFAULT_PAGE_STAY_CONFIG,
                ...globalConfig.pageStayConfig,
                ...config.pageStayConfig,
            };
        }
        /**
         * 标记用户活跃
         * 触发条件:鼠标移动/点击/滚动/键盘/触屏
         * 作用:刷新最后活跃时间;若已暂停则恢复计时
         */
        const markUserActive = () => {
            // 用户活跃更新最后活跃时间
            lastActiveRef.current = Date.now();
            // 若当前未计时 + 页面可见 → 重新开始计时
            if (!isTrackingRef.current && document.visibilityState === "visible") {
                console.log('用户重新活跃,重新开始计时')
                startTimeRef.current = Date.now();
                isTrackingRef.current = true;
            }
        };

        /**
         * 上报最终有效停留时长
         * isHidden:是否是页面隐藏/关闭触发,在页面隐藏/关闭时不走批量队列,直接单独上报
         */
        const reportValidStayTime = (isHidden = false) => {
            if (startTimeRef.current === null) return;
            const { minDuration, maxDuration } = getLastPageStayConfig();
            // 上次活跃时间 - 本次计时开始时间 => 用户实际活跃时间
            const operateTime = lastActiveRef.current - startTimeRef.current;
            // 限制最大时长,防止异常数据
            const finalStayTime = Math.min(operateTime, maxDuration);
            if (finalStayTime >= minDuration) {
                if(isHidden){
                    triggerSingleTrack({ stayTime: finalStayTime }) // 页面隐藏/关闭,直接走单独上报
                }else{
                    console.log('组件卸载/用户不活跃触发默认上报逻辑')
                    triggerTrack({ stayTime: finalStayTime }); // 组件卸载/用户不活跃,直接走正常上报逻辑(如果配置了批量上报,就走批量上报)
                }
            }else{ // 过滤无效时长
                console.log('页面停留时长小于minDuration,无效的上报数据')
            }
            // 停止计时,下次活跃/切换进页面重新开始计时
            isTrackingRef.current = false;
        };

        /**
         * 页面可见性变化监听
         * 显示 → 开始/恢复计时
         * 隐藏 → 暂停计时/并进行上报
         */
        const handleVisibilityChange = () => {
            if (document.visibilityState === "visible") {
                console.log('页面可见,重新开始计时')
                // 页面可见:重新开始计时
                startTimeRef.current = Date.now();
                lastActiveRef.current = Date.now();
                isTrackingRef.current = true;
            } else {
                // 页面隐藏:立即暂停
                console.log('页面隐藏/关闭,停止计时,触发上报')
                reportValidStayTime(true); // 页面隐藏,走单独上报的逻辑
            }
        };

        /**
         * 定时检查用户活跃状态
         * 每秒执行一次
         * 超过配置的无操作时间 → 停止计时并上报
         */
        const checkActiveStatus = () => {
            if (!isTrackingRef.current) return;

            const now = Date.now();
            const { timeout } = getLastPageStayConfig();
            // 超过超时时间未操作 → 停止并上报
            if (now - lastActiveRef.current >= timeout) {
                reportValidStayTime(); // 用户不活跃上报,走正常逻辑
            }
        };

        // ===================== 绑定事件监听 =====================
        // 用户活跃行为事件列表
        const activeEvents = ["mousedown", "mousemove", "keydown", "scroll", "touchstart"];
        activeEvents.forEach(evt => window.addEventListener(evt, markUserActive));

        // 页面显隐切换
        document.addEventListener("visibilitychange", handleVisibilityChange);

        // ===================== 初始化计时 =====================
        if (document.visibilityState === "visible") {
            startTimeRef.current = Date.now();
            lastActiveRef.current = Date.now();
            isTrackingRef.current = true;
        }

        // ===================== 启动活跃检查定时器 =====================
        const { checkInterval } = getLastPageStayConfig();
        timerRef.current = setInterval(checkActiveStatus, checkInterval);

        // ===================== 清理副作用(组件卸载/页面离开) =====================
        return () => {
            clearInterval(timerRef.current!);
            activeEvents.forEach(evt => window.removeEventListener(evt, markUserActive));
            document.removeEventListener("visibilitychange", handleVisibilityChange);

            reportValidStayTime(); // 组件卸载进行上报,走默认逻辑
        };

    }, [triggerTrack, triggerSingleTrack, config]);
};

五、最为什么这样设计?

1. 解决“挂页面没操作”问题

30分钟无操作 → 自动停止计时 回来操作 → 重新开始新会话

2. 过滤无效数据

  • 打开就关 <2s → 丢弃
  • 挂着一夜 → 最多统计60分钟

3. 不影响性能

  • 只监听轻量事件
  • 每秒只做一次时间判断

4. 行业通用,数据口径统一

神策、友盟、GA 全部是这套逻辑,你这样做数据可对比、可分析。

评论
0/100