useTrackPageStay 的实现原理与设计:统计真实有效的页面活跃停留时长
在前端埋点中,页面停留时长是一个很常见但并不简单的指标。很多系统会用“进入页面时间”和“离开页面时间”的差值来计算停留时长,但这个做法很容易高估用户真实行为:用户可能打开页面后切到别的标签页,也可能长时间不操作,甚至把浏览器挂在后台。
react-track-hooks 中的 useTrackPageStay 试图解决这个问题。它不是简单统计组件存在了多久,而是结合页面显隐、用户活跃事件、无操作超时和组件卸载,计算更接近真实阅读或操作行为的“有效活跃停留时长”。
源码实现
useTrackPageStay 位于 src/track/hooks/useTrackPageStay.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,
minDuration: 2000,
maxDuration: 60 * 60 * 1000,
checkInterval: 1000,
};
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<ReturnType<typeof setInterval> | 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') {
startTimeRef.current = Date.now();
isTrackingRef.current = true;
}
};
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 {
triggerTrack({ stayTime: finalStayTime });
}
}
isTrackingRef.current = false;
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
startTimeRef.current = Date.now();
lastActiveRef.current = Date.now();
isTrackingRef.current = true;
} else {
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 () => {
if (timerRef.current) clearInterval(timerRef.current);
activeEvents.forEach((evt) => window.removeEventListener(evt, markUserActive));
document.removeEventListener('visibilitychange', handleVisibilityChange);
reportValidStayTime();
};
}, [triggerTrack, triggerSingleTrack, config]);
};
这段代码的目标可以概括为一句话:只统计用户在页面可见且仍然活跃时产生的有效停留片段,并在合适的时机上报。
为什么页面停留时长不能简单用 Date.now 相减
最粗糙的页面停留统计通常是这样实现的:组件挂载时记录一个开始时间,组件卸载时用当前时间减去开始时间。
这种方式实现简单,但数据质量通常不高。
第一,页面可能不可见。用户打开文章后切到其他标签页,组件仍然存在,但用户没有继续阅读。
第二,用户可能长时间不操作。页面停留在前台,不代表用户真的在看,可能只是离开了电脑。
第三,页面关闭时不一定触发完整的 React 卸载流程。只依赖组件卸载,可能错过页面隐藏或关闭前的上报时机。
第四,异常长时间停留会污染数据。比如电脑休眠、页面挂起、调试环境长时间打开页面,都可能产生非常大的停留时长。
useTrackPageStay 的设计,就是为了在这些边界上做修正。
核心状态:用 ref 保存计时上下文
Hook 内部维护了四个 ref:
const startTimeRef = useRef<number | null>(null);
const lastActiveRef = useRef<number>(Date.now());
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isTrackingRef = useRef(false);
它们分别表示:
startTimeRef:当前计时片段的开始时间。lastActiveRef:用户最后一次活跃行为发生的时间。timerRef:周期性检查用户是否超时不活跃的定时器。isTrackingRef:当前是否处于计时状态。
这里使用 useRef 而不是 useState 是合理的。停留时长统计不需要触发 UI 更新,如果用 state,每次用户移动鼠标、滚动页面或键盘输入都可能导致组件重新渲染。useRef 可以保存跨渲染的可变状态,又不会引发重新渲染,适合这类埋点计时场景。
配置合并:默认值、全局配置和局部配置
页面停留统计有四个关键配置:
timeout:用户多久不操作后认为不再活跃。minDuration:最短有效停留时间,低于这个值不上报。maxDuration:最长单次停留时间,用于限制异常值。checkInterval:多久检查一次用户是否超时不活跃。
Hook 内部有一份默认配置:
const DEFAULT_PAGE_STAY_CONFIG = {
timeout: 30 * 60 * 1000,
minDuration: 2000,
maxDuration: 60 * 60 * 1000,
checkInterval: 1000,
};
实际运行时,通过 getLastPageStayConfig 进行三层合并:
return {
...DEFAULT_PAGE_STAY_CONFIG,
...globalConfig.pageStayConfig,
...config.pageStayConfig,
};
优先级是:默认值 < 全局配置 < 单个 Hook 配置。
这意味着项目可以在全局设置一套页面停留策略,也可以在某些关键页面单独覆盖。比如普通页面最短停留 2 秒才上报,长阅读页面可以改成 5 秒,或者某些短流程页面可以把 timeout 调小。
用户活跃监听:定义“还在操作”
useTrackPageStay 把以下事件视为用户活跃行为:
const activeEvents = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart'];
这些事件覆盖了鼠标点击、鼠标移动、键盘输入、页面滚动和触屏操作。它们被绑定到 window:
activeEvents.forEach((evt) => window.addEventListener(evt, markUserActive));
每当用户触发这些事件时,markUserActive 会刷新最后活跃时间:
lastActiveRef.current = Date.now();
如果当前已经停止计时,但页面仍然可见,则重新开始一个新的计时片段:
if (!isTrackingRef.current && document.visibilityState === 'visible') {
startTimeRef.current = Date.now();
isTrackingRef.current = true;
}
这意味着停留时长不是一次从进入页面累计到离开页面,而是可以被拆成多个活跃片段。用户不活跃后停止计时,再次操作时重新开始计时。
页面显隐监听:隐藏时立即结算
页面可见性由 document.visibilityState 判断,并通过 visibilitychange 监听:
document.addEventListener('visibilitychange', handleVisibilityChange);
当页面变为可见时,Hook 会重新开始计时:
startTimeRef.current = Date.now();
lastActiveRef.current = Date.now();
isTrackingRef.current = true;
当页面变为隐藏时,Hook 会立即结算当前片段:
reportValidStayTime(true);
这个设计很重要。页面隐藏通常意味着用户切走了标签页、最小化浏览器、切换应用,或者即将关闭页面。如果继续计时,就会把不可见时间也算进页面停留,导致数据偏大。
定时检查:处理长时间不操作
除了页面隐藏,还需要处理“页面可见但用户不操作”的场景。Hook 使用定时器周期性检查:
timerRef.current = setInterval(checkActiveStatus, checkInterval);
检查逻辑是:
if (now - lastActiveRef.current >= timeout) {
reportValidStayTime();
}
也就是说,如果用户超过 timeout 毫秒没有任何活跃行为,就停止本次计时并尝试上报。
这个机制可以避免把“页面放着不动”的时间全部算作有效停留。比如用户打开文章后去开会,页面一直在前台,但 30 分钟没有任何操作,那么系统只会统计到最后一次活跃行为为止。
停留时长如何计算
核心结算函数是 reportValidStayTime。
它首先判断是否有开始时间:
if (startTimeRef.current === null) return;
然后计算用户实际操作时间:
const operateTime = lastActiveRef.current - startTimeRef.current;
这里不是用 Date.now() - startTimeRef.current,而是用 lastActiveRef.current - startTimeRef.current。这个细节非常关键。
如果用户已经 30 分钟没有操作,Date.now() 会把这 30 分钟也算进去;而 lastActiveRef.current 表示最后一次活跃时间,因此它更接近用户真实参与的时间。
接着用 maxDuration 限制异常值:
const finalStayTime = Math.min(operateTime, maxDuration);
然后用 minDuration 过滤过短停留:
if (finalStayTime >= minDuration) {
triggerTrack({ stayTime: finalStayTime });
}
最后停止当前计时:
isTrackingRef.current = false;
这套计算方式使停留时长具备三个特征:只统计活跃片段,过滤过短噪声,限制异常长值。
为什么有两个 triggerTrack
useTrackPageStay 内部调用了两次 useTrack:
const { triggerTrack } = useTrack(
{ eventName, type: TrackType.PAGE_STAY, ...customParams },
config,
);
const { triggerTrack: triggerSingleTrack } = useTrack(
{ eventName, type: TrackType.PAGE_STAY, ...customParams },
{
...config,
enableBatch: false,
},
);
第一个 triggerTrack 使用正常配置。如果项目开启了批量上报,它会进入批量队列。
第二个 triggerSingleTrack 强制关闭批量上报,用于页面隐藏或关闭场景:
if (isHidden) {
triggerSingleTrack({ stayTime: finalStayTime });
} else {
triggerTrack({ stayTime: finalStayTime });
}
这个设计的原因是:页面隐藏时,继续把数据放进批量队列可能来不及发送。此时更适合直接单条上报,尽量提高页面离开前发送成功的概率。
不过这里也有一个取舍。单条上报可以避免数据滞留在批量队列,但页面退出阶段的请求仍然不一定百分百成功。当前底层 sendTrack 使用 fetch 并设置 keepalive: true,这是对页面退出场景的一种兼容处理。
组件卸载时上报
useEffect 的 cleanup 中会执行:
reportValidStayTime();
这表示组件卸载时也会结算一次停留时长。比如用户从文章详情页切到首页,文章页面组件卸载,就会触发一次上报。
同时 cleanup 还会清理定时器和事件监听:
if (timerRef.current) clearInterval(timerRef.current);
activeEvents.forEach((evt) => window.removeEventListener(evt, markUserActive));
document.removeEventListener('visibilitychange', handleVisibilityChange);
这符合 React 副作用设计原则:Hook 创建的监听器和定时器,必须在卸载时清理,避免重复监听和内存泄漏。
初始化流程
当 Hook 第一次运行时,如果页面当前可见,会立即开始计时:
if (document.visibilityState === 'visible') {
startTimeRef.current = Date.now();
lastActiveRef.current = Date.now();
isTrackingRef.current = true;
}
这代表页面进入时就开启一个活跃片段。后续用户的活跃事件会不断刷新 lastActiveRef,页面隐藏、用户超时不活跃或组件卸载会结束这个片段。
整体流程可以概括为:
组件调用 useTrackPageStay
-> 创建 page_stay 类型的 triggerTrack
-> 创建强制单条上报的 triggerSingleTrack
-> 页面可见时初始化 startTime 和 lastActiveTime
-> 监听用户活跃事件,持续刷新 lastActiveTime
-> 定时检查用户是否超过 timeout 未操作
-> 页面隐藏时立即结算并单条上报
-> 用户不活跃超时时结算并按正常配置上报
-> 组件卸载时结算并清理监听器和定时器
测试体现的行为边界
项目测试中覆盖了 useTrackPageStay 的几个核心行为。
第一,页面隐藏时会上报活跃停留时长。测试中推进 3 秒后触发 mousedown 和 visibilitychange,最终上报 stayTime: 3000。
第二,低于 minDuration 的停留不会上报。比如只停留 500ms,而最短有效时长是 1000ms,则不会调用 fetch。
第三,超过 maxDuration 的停留会被截断。比如实际计算 10000ms,但 maxDuration 是 5000ms,最终上报 stayTime: 5000。
第四,用户超过 timeout 不活跃时会自动上报。测试中用户最后活跃在 1000ms,随后 2000ms 没有操作,最终结算的停留时长是 1000ms。
第五,组件卸载时也会上报。这覆盖了路由切换、页面组件销毁等常见场景。
这些测试说明,useTrackPageStay 关注的不是“页面开着多久”,而是“用户有效参与了多久”。
设计亮点
useTrackPageStay 的第一个亮点是把停留时长从简单生命周期统计升级为活跃行为统计。它结合用户行为事件和页面显隐状态,能得到更接近真实使用的数据。
第二个亮点是使用 lastActiveRef 作为结算终点。这个设计避免把用户不活跃后的空闲时间计入停留时长。
第三个亮点是配置完整。timeout、minDuration、maxDuration 和 checkInterval 分别处理不活跃、噪声过滤、异常值和检测频率。
第四个亮点是页面隐藏时强制单条上报。它避免页面即将离开时数据还停留在批量队列里。
第五个亮点是和统一上报链路复用。Hook 本身只负责计算 stayTime,最终仍交给 useTrack 和 sendTrack 处理发送、批量和失败缓存。
可以继续优化的方向
当前实现已经覆盖了页面停留统计的主要场景,但仍有一些可以继续优化的点。
第一,事件监听可以全局单例化。现在每个 useTrackPageStay 实例都会注册一组 window 和 document 监听器。如果多个组件同时使用,就会产生重复监听。可以抽象一个 PageStayManager,全局只监听一次用户活跃和页面显隐,再把状态分发给多个计时任务。
第二,mousemove 事件频率较高。当前每次鼠标移动都会更新 lastActiveRef。虽然更新 ref 不触发渲染,但仍然可能很频繁。可以增加节流,比如每 500ms 或 1000ms 最多更新一次活跃时间。
第三,页面隐藏上报可以考虑混合使用 sendBeacon。普通运行时仍用 fetch 保留失败回调和缓存能力,页面隐藏阶段可以优先尝试 sendBeacon,如果浏览器拒绝入队再写入失败缓存或回退到 fetch keepalive。
第四,当前每次 effect 依赖的 config 变化都会重新注册监听器。如果调用方每次 render 都传入新的对象,可能导致重复清理和重建。可以用 ref 保存最新配置,减少对外部 config 引用稳定性的依赖。
第五,可以区分页面级和组件级停留。当前 Hook 既可用于页面,也可用于组件,但命名和逻辑更偏页面级。如果未来支持多个区域的停留统计,可以增加区域 ID、可见性联动或 IntersectionObserver 辅助判断。
总结
useTrackPageStay 是 react-track-hooks 中比较有代表性的复杂场景 Hook。它不像点击埋点那样只需要响应一次用户事件,也不像曝光埋点那样只判断元素是否进入视口,而是需要持续维护一段时间内的用户活跃状态。
它的核心设计是:用 ref 保存计时状态,用用户活跃事件刷新最后活跃时间,用页面显隐和不活跃超时决定何时结算,用最短和最长时长控制数据质量,用统一的 useTrack 链路完成上报。
如果用一句话概括:useTrackPageStay 把页面停留时长从粗糙的“页面打开多久”,修正为更有分析价值的“用户活跃停留多久”。