曝光埋点数据有效性的改进
七崽爱吃小饼干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. 计时策略(最标准的实现)
三段式计时:
- 页面可见 + 用户活跃 → 正常计时
- 页面可见但无操作超过 N 分钟 → 暂停计时
- 页面不可见 → 立即暂停计时
行业通用配置:
- 无操作超时:30 分钟(可配置)
- 最小有效时长:2 秒(低于 2s 丢弃,防止快速刷新)
- 最大单次时长:60 分钟(防止死数据)
三、最终设计
有效停留时长 =
页面处于可见状态
+ 用户处于活跃状态
+ 没有超过无操作超时时间
这段时间的总和
四、实现逻辑
- 页面加载 → 开始会话
- 监听所有活跃事件 → 每次触发更新 lastActiveTime
- 定时器每秒检查:
- 页面是否可见?
- 是否在30分钟内有操作?
- 满足 → 累计时长
- 页面卸载 / 隐藏 / 超时 → 停止 & 上报
- 上报前过滤:<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 全部是这套逻辑,你这样做数据可对比、可分析。