多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

页面停留时长统计:从可见时长到心跳上报的完整埋点实践

页面停留时长统计:从可见时长到心跳上报的完整埋点实践 简介面向移动端开发、产品运营及数据分析人员这份资源围绕“用户停留浏览页面的时间统计”场景提供一套完整且可直接落地的原生iOS实现方案覆盖事件监听、时间戳记录、间隔奖励与超时累积等关键逻辑可帮助开发者快速理解、复用并扩展页面停留时长统计功能。压缩包共6个文件其中包含Objective-C实现代码、对应头文件以及PNG界面图片整体体积仅48KB非常轻量易用。资源已有403人学习适合需要掌握用户行为数据采集、进行页面活跃度分析或设计留存激励策略的iOS开发者参考。通过学习示例代码可掌握页面加载、滚动、离开等事件的计时处理以及定时器、数据上报等配套思路为优化用户体验和评估广告效果提供数据支撑。 做前端埋点的人应该都有体会页面停留时长是产品运营最爱看、也是技术最容易埋错的指标之一。运营同学拿着一份导出的停留时长报表随口来一句“怎么用户只待了10秒就走了”而你心里清楚那个10秒很可能只是用户切到别的Tab看了会儿微信页面一直开着没动过。这篇文章我就从统计口径、实现方案、上报策略到各种边角case把“用户停留浏览页面的时间统计”这件事完整拆一遍包含完整可用的代码和我在真实项目里踩过的坑适合做数据埋点、用户行为分析、增长分析的同学参考。先亮个观点不要一上来就写代码先把统计口径定清楚。同一批用户按“页面打开总时长”算和按“页面真正可见时长”算结果能差出30%以上。业务要什么口径决定了后面所有代码怎么写这一步想不明白后面做得再精细都可能白搭。1. 先定义清楚你要统计的到底是哪个时间1.1 三种统计口径与适用场景先说一个最容易忽略的事实用户在浏览器里打开一个页面这个页面处于“存在”的完整时间段和用户“正在看”的时间段很少完全重合。最常见的场景就是浏览器开了十几个Tab用户来回切换某个页面其实一直开着但用户可能一分钟都没看过它。页面驻留时长从用户打开页面到关闭页面、跳转离开或关闭浏览器按时间戳差值计算的完整时长。这个口径实现最简单但它把切后台、切Tab、锁屏这些“页面还开着但你没在看”的时间全部算进去了。如果你只是想给运营看一个大致的行为数据这个口径勉强能用。页面可见时长只在页面处于可见状态时累加时间。一旦用户切到别的Tab、最小化窗口或锁屏计时暂停等用户切回来继续累加。这个口径能比较真实地反映“用户停留在页面内容上的时间”也是目前业界做内容产品、广告平台时普遍采用的口径。深度互动时长在可见时长基础上进一步要求页面产生了用户交互才算时间比如滚动、点击、键盘输入。连续N秒没有任何交互这段时间会被剔除。这是精细化运营最想要的口径但实现复杂度和误判概率都会上升。三种口径和业务场景的匹配关系我用表格总结一下统计口径计时段适合场景实现难度页面驻留时长打开到关闭基础流量分析、漏斗粗粒度低页面可见时长仅可见时段内容阅读、广告计费、停留质量分析中深度互动时长可见且有交互游戏、教程类、富交互产品高1.2 口径选错的典型后果我见过一个内容平台的案例最初用的是页面驻留时长结果技术团队优化后接入可见时长平均停留时长直接掉了40%多。产品经理差点以为是新版本做坏了后来一查原因是用户大量多开Tab、后台挂机的时长被旧口径虚提了。如果你做的是资讯或短视频类产品用户经常打开页面后扔一边去干别的事你用驻留时长去评估内容质量得到的就是严重失真数据。反过来如果你做的是后台管理系统用户开着页面去开会、去回复消息是常态你用可见时长去考核功能使用情况也会得出不公正的结论。所以我建议第一步永远是把口径和业务目标对齐再往下写代码。2. 从零实现一套可用的统计逻辑2.1 基础版进入时间剪离开时间先看最朴素的实现用一个全局时间戳在页面加载时记录在卸载或关闭时相减。// 基础版页面驻留时长 const startTime Date.now(); window.addEventListener(beforeunload, () { const duration Math.round((Date.now() - startTime) / 1000); report({ page: location.pathname, duration }); }); function report(data) { const payload JSON.stringify(data); if (navigator.sendBeacon) { navigator.sendBeacon(/api/stay, payload); } else { fetch(/api/stay, { method: POST, body: payload, keepalive: true, headers: { Content-Type: application/json } }); } }这里面有两个明显问题。第一个beforeunload只在页面卸载时触发但移动端浏览器切后台后页面进程可能被系统直接回收beforeunload根本不会执行数据就丢了。第二个也是最关键的驻留时长口径没有过滤掉用户不在页面的时间。基础版的另一个问题是现代浏览器对Tab切到后台后的JS执行做了严格的定时器节流如果你在页面里还挂了requestAnimationFrame或高频setInterval做其他统计后台时会完全停摆导致所有依赖定时器的逻辑全部失真。2.2 进阶版用Visibility API过滤掉隐藏时间Page Visibility API是解决“用户是否真的在看这个页面”的最基础工具。核心就是document.visibilityState和visibilitychange事件。实现思路很简单页面可见时计时开始页面隐藏时把这段可见时间累加等页面再次可见时重新开始计时。// 进阶版页面可见时长 let visibleStartTime Date.now(); let totalVisibleTime 0; document.addEventListener(visibilitychange, () { if (document.visibilityState visible) { // 切回可见开始计时 visibleStartTime Date.now(); } else { // 页面被隐藏把这一段可见时间累加 totalVisibleTime Date.now() - visibleStartTime; } }); // 兜底页面直接关闭或跳转时把最后一次可见片段补上 window.addEventListener(beforeunload, () { if (document.visibilityState visible) { totalVisibleTime Date.now() - visibleStartTime; } report({ page: location.pathname, visibleDuration: totalVisibleTime }); });这个方案的效果已经好了很多能比较准确地统计“用户在页面上真实浏览”的时间。有几个细节值得注意。visibilitychange事件在页面初次加载时也会触发需要在初始化时把visibleStartTime设置好否则会把加载前的一小段时间误算进去。另一个常见坑是在iOS Safari上用户下拉关闭页面的交互行为可能让beforeunload和pagehide的触发顺序和桌面端不一致导致最后一段可见时间没有被累加。我建议在pagehide事件里也做一次相同的兜底累加页面即使走的是pagehide而不是beforeunload也不会漏算最后一小段时间。2.3 完整版心跳上报防丢失也防冻结进阶版仍然有一个问题如果用户页面可见但网络不稳定或者浏览器进程被系统直接回收关闭时那一次上报依然可能失败。所以生产环境我推荐“心跳上报关闭兜底”双通道。// 完整版心跳上报 关闭兜底 const HEARTBEAT_INTERVAL 5000; let currentDuration 0; let lastCheckTs Date.now(); let timer null; function startTimer() { if (timer) return; timer setInterval(() { const now Date.now(); if (document.visibilityState visible) { currentDuration (now - lastCheckTs) / 1000; report({ page: location.pathname, visibleDuration: currentDuration, ts: now }); } lastCheckTs now; }, HEARTBEAT_INTERVAL); } window.addEventListener(load, startTimer); window.addEventListener(pagehide, () { const now Date.now(); if (document.visibilityState visible) { currentDuration (now - lastCheckTs) / 1000; } report({ page: location.pathname, visibleDuration: currentDuration, final: true }); clearInterval(timer); });心跳机制的核心思路是每5秒检查一次页面是否可见可见就用当前时间减去上次检查的时间把差值累加并立刻上报一次。这样即使最后关闭页面的上报失败服务端已经收到了前面的心跳数据最多丢失最后几秒的精度。为什么用累加而不用记录开始时间和结束时间因为心跳上报天然是“增量”的服务端按session维度取最大值就能还原整个会话的时长。如果按时间戳上传服务端还需要额外做一次时间区间合并在跨设备、跨网络的情况下很容易算出错误值。3. 上报环节关闭页面的最后一秒也要保住数据3.1 为什么不能用普通fetch或XHR做离开上报页面在关闭或跳转的一瞬间浏览器会中止当前页面里所有尚未完成的网络请求。普通fetch和XHR请求在这时候发出去大概率直接被cancel后端连请求日志都看不到。很多团队踩过这个坑统计代码写在beforeunload里用普通axios发请求本地调试怎么测都正常真机上数据就是丢一大片。原因就是浏览器对“页面卸载期间”的请求做了特殊处理普通异步请求根本来不及送达。3.2 sendBeacon与fetch keepalive的搭配方案navigator.sendBeacon是专门为这种场景设计的API它在页面卸载时依然能够把数据发给服务端而且不会阻塞页面关闭流程。它的缺点是默认用POST发送、Content-Type固定为text/plain有些后端直接收JSON的地方需要做一层兼容。function report(payload) { const data JSON.stringify(payload); try { if (navigator.sendBeacon) { navigator.sendBeacon(/api/stay, new Blob([data], { type: application/json })); return; } const controller new AbortController(); setTimeout(() controller.abort(), 1000); fetch(/api/stay, { method: POST, body: data, keepalive: true, signal: controller.signal, headers: { Content-Type: application/json } }); } catch (e) { // 静默失败埋点不要影响业务 } }这里用Blob包装一下可以把Content-Type调成application/json后端解析更省事。fetch加了keepalive是sendBeacon的不错降级方案keepalive支持请求体但要注意单次请求体大小有限制别在关闭上报时塞一大包历史数据。另外要提醒一下sendBeacon发送的数据量不宜过大Chrome等浏览器对单次beacon请求有体积限制一般几KB以内没问题但别把整个用户行为明细塞进去。我一般只传累计时长、页面标识、sessionId和时间戳四个字段剩下的细节由服务端按session去关联。重要提示埋点上报代码永远不要放在阻塞主流程的位置也不要做失败重试丢了就丢了补不回来只会让你的页面卡顿得不偿失。4. 真实环境里的坑每一个都是数据不准的来源4.1 后台标签页定时器被冻结前面提到过浏览器为了省电会大幅降低后台Tab的定时器频率Chrome对后台页面的最小定时器间隔可能会被提升到1分钟甚至更长。如果你只是记录时间戳影响不大因为Date.now()不受定时器调度影响。但如果你的累计逻辑依赖setInterval的“每次回调加固定值”后台时就会漏加一大段时间。解决办法是不要在定时器里“推算”时间而是每次回调时用Date.now()去和上次记录的时间戳做差值。换句话说定时器只负责触发检查时间计算永远以真实时钟为准。let lastCheck Date.now(); setInterval(() { const now Date.now(); if (document.visibilityState visible) { currentDuration (now - lastCheck) / 1000; } lastCheck now; report({ page: location.pathname, visibleDuration: currentDuration }); }, 5000);这样即使定时器被浏览器冻结了很久下次回调时仍然能通过时间戳差值把这段时间补上不会漏。4.2 SPA路由切换与页面进入/离开的判定单页应用SPA的特殊性在于页面“卸载”不是真实发生的只是路由变了DOM被替换。如果还在用beforeunload判断页面关闭SPA内所有路由切换都不会触发停留时长就会算成整个会话从进入到最后关闭的总和结果是完全失效的。处理方式有两种。一种是在路由框架里手动埋点比如Vue Router的afterEach、React的useEffect或路由监听器里记录进入时间切走时结算。另一种是统一封装一个页面生命周期管理器把路由变化视为“页面离开”处理上报完成后再开启一个新的计时段。// 以 Vue Router 为例 router.afterEach((to, from) { if (from.name) { const duration calculateDuration(); report({ page: from.path, visibleDuration: duration }); } resetTimer(); });这种处理方式也能自然支持页面间的会话串联不过要注意SPA里如果用户停留在同一条路由但参数变化了是否要算新页面需要和业务对齐。4.3 多标签页同时打开同一个页面只要用户开了两个Tab访问同一个页面两个Tab就会各自计时各自上报如果服务端只按session或userId去聚合同一时间段就会被重复记录两次。处理思路是给每个页面实例生成一个唯一的sessionId上传时带上。服务端聚合时先按sessionId去重再按userId汇总而不是直接sum。这样即使多Tab存在也能准确区分。4.4 移动端锁屏、切后台与进程回收移动端的坑比桌面端更多。iOS在内存紧张时会直接杀掉后台页面Android厂商的省电策略五花八门很多情况下beforeunload和pagehide都来不及触发。所以移动端必须要靠心跳上报兜底把上报频率控制在合理范围5到10秒比较稳妥心跳间隔太短会带来额外流量开销太长会造成数据丢失明显。5. 从“能用”到“好用”的进阶建议5.1 上报数据的合理性校验收到数据后先别急着喂给报表要做几次基础校验。比如时长明显超过24小时的数据要能过滤掉单条Session里多次上报时长递减正常应该递增的要能识别跨设备的时间戳错乱会带来负数差值也要清洗。我一般在服务端做三层校验第一层过滤大于最大阈值的异常值第二层校验时间戳是否在合理范围内第三层按sessionId做单调性检查。这些逻辑写起来不难但能有效保证下游报表的可信度比用画图工具做各种可视化重要得多。5.2 从“停留时长”延伸到更宽的分析指标最后一个建议把停留时长作为基础事件围绕它继续扩展。比如加上页面滚动深度可以分析用户是否真的把内容看完了加上页面元素曝光可以判断哪一块内容最吸引人结合页面切换事件能还原用户在站内的完整浏览路径。我曾经在一个内容项目里把“可见时长”和“滚动深度”联合起来看发现很多用户的可见时长很长、但滚动深度很低原因是页面悬浮播放器一直在播放视频用户开着页面的声音听压根没往下翻。这种情况下单纯优化停留时长指标方向就会跑偏。所以做埋点统计的目标不是把数字做漂亮而是把它变成可以指导产品和运营决策的依据。每次加一个指标都要回到那个最初的问题它到底在度量哪个行为用户做了什么样的动作才会让这个数字变大或变小以上是我在多个项目里把“用户停留浏览页面的时间统计”从简单到完整搭建的真实过程希望能帮你在做数据埋点时少走点弯路。最后再分享一个小工具建议开发阶段在控制台里把visibilitychange和上报日志都打印出来手动切Tab、锁屏、开关浏览器各测一遍比写好代码就直接上线要靠谱得多。本文还有配套的精品资源点击获取
返回列表