ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用户停留浏览页面时间统计:前端埋点从口径到上报的完整实践

用户停留浏览页面时间统计:前端埋点从口径到上报的完整实践 简介面向网站与App开发者、产品及运营人员这是一套用户停留浏览页面时间统计的实现方案覆盖从页面进入、滚动监听、停留时间累计、定时触发激励到离开停止计时并上报数据的完整链路。压缩包共6个文件以Objective-C源文件.m/.h和界面资源图片.png为主体积仅48KB轻量易读。已有403人学习下载。资源中附带了圆环进度显示组件及红包、端点等视觉素材可用于展示倒计时、金币奖励或停留进度源码结构清晰方便理解时间戳记录、间隔奖励、静置补时等关键逻辑。适合希望为产品加入用户行为时长统计、停留激励或留存策略的开发者参考也可作为数据上报与前端埋点联调的入门示例。 我做了几年前端一直觉得数据埋点是最容易被低估的活。尤其是用户停留浏览页面的时间统计这类需求听起来就跟new Date()两个端点做差一样简单实际上踩完一圈坑才发现这个埋点写得好不好直接决定产品看板上的数据能不能信。这篇文章就把我从指标口径、基础实现、SPA 和移动端特殊处理到最后上报字段设计的过程完整过一遍给正在做埋点或数据平台的同学一个可落地的参考也让产品同学看看为什么同一个停留时间能走出完全不同的数。1. 先定指标口径三种时间定义能差出 5 倍1.1 三种常见的时间口径与真实差异很多团队做停留时长统计第一件事就是写代码计时这其实顺序反了。我接到需求后的第一反应是问产品经理用户打开页面看了 10 秒切到微信聊了 30 分钟再切回来看 20 秒后直接关掉这个用户在你页面上的停留时间是多少没有统一答案因为至少有三种合理定义。口径计算逻辑上例数值适合回答的问题会话时长从进入页面到离开页面的绝对时间约 30 分 30 秒用户占据时长、注意力争夺可见时长页面处于浏览器前台可见的时间段之和10 20 30 秒页面真实被看的时间活跃时长可见且发生滚动、点击、打字等交互的时间可能只有 10 秒深度阅读与内容吸引力同一个用户按会话时长算是半小时按可见时长算只剩 30 秒两者差了整整 60 倍。如果前期不对齐口径开发按会话时长做产品按可见时长解读最后复盘时数据一定打架。我遇到的这个需求最终选的是可见时长因为业务方真正想知道的是页面内容有没有被看到而不是用户把标签页挂在后台多久。建议你动手前先和需求方把这条定义写进文档不然上线后改口径历史数据全部作废。1.2 为什么可见性事件才是可靠起点确定口径后关键的技术选型就是怎么判断页面是否可见。有人会用setInterval每 1 秒加一次时长这个方案我劝你放弃浏览器对后台标签页的定时器会大幅节流Chrome 后台页面定时器可能被压到每分钟只能执行一次计时完全失真。也有人想监听focus/blur事件但这两个事件只在窗口级生效用户切到另一个标签页、打开开发者工具、甚至系统弹窗行为都不可靠。可靠的做法是用 Page Visibility API也就是document.visibilityState和visibilitychange事件。这是浏览器原生的页面可见性机制能准确告诉当前页面是visible还是hidden不管是切换标签页、最小化窗口、还是移动端切后台锁屏它都能正确触发。用它做用户停留浏览页面的时间统计才是把地基打稳了。2. 核心实现只累计可见时间段而不是起止时间相减2.1 分段累计的基本思路与代码骨架选定可见时长口径后实现上有个容易搞错的点不能只记进入页面时间和离开页面时间两个值相减。因为用户可能多次切换后台起止时间相减会把中间的所有后台时长也算进去。正确做法是记录每一段可见片段的长度最后加总。const tracker { totalVisibleMs: 0, segmentStartTs: 0, started: false, init() { if (document.visibilityState visible) { this.segmentStartTs Date.now(); this.started true; } document.addEventListener(visibilitychange, () { if (document.visibilityState visible) { this.segmentStartTs Date.now(); this.started true; } else if (this.started) { this.totalVisibleMs Date.now() - this.segmentStartTs; this.started false; } }); }, getDuration() { if (this.started document.visibilityState visible) { return this.totalVisibleMs (Date.now() - this.segmentStartTs); } return this.totalVisibleMs; }, }; window.addEventListener(pagehide, () { if (document.visibilityState visible tracker.started) { tracker.totalVisibleMs Date.now() - tracker.segmentStartTs; tracker.started false; } report(tracker.getDuration()); });代码核心就是一个累计器每次页面变可见时开一个时间分片变不可见时把分片差值累加。注意init()里要先判断当前可见状态如果用户是在后台标签页打开的页面visibilityState可能不是visible这时不能直接开始计时否则一打开就白白累计了一段从未被看到的时间。这个细节不处理的话外链落地页的停留时长会普遍虚高。2.2 卸载上报为什么优先用 sendBeacon页面数据最终要上报很多同学第一反应是用fetch或XMLHttpRequest在卸载事件里发。但我实测下来在unload或移动端的beforeunload里发异步请求非常不可靠浏览器正在销毁页面时请求经常还没发出去就被取消了。更稳妥的方案是navigator.sendBeacon它把请求交给浏览器接管不阻塞页面卸载页面销毁后也能尽力把数据送出去。事件本身我也建议统一用pagehide而不是beforeunload。原因有两个一是移动端 Safari 对beforeunload的支持一直很诡异历史上多次出现不触发的情况二是pagehide是规范层面的替代事件同时能覆盖正常关闭和移动端 Safari 的场景。桌面端实测中pagehide基本不会丢数据和visibilitychange配合能覆盖绝大多数主动关闭路径。3. 单页应用和移动端两个最容易被算错的应用场景3.1 SPA 路由切换页面没刷新但时间该结算了单页应用给这个埋点出的难题是用户从/home切到/detail浏览器根本没有重新加载页面visibilitychange也不会触发。如果不做处理两块不同页面的时间会被混在一个 document 里最后全部记到第一个页面上。解决思路是在路由变化时手动结算重置。最基础的做法是包装history.pushState和history.replaceState再监听popstateconst originalPushState history.pushState; history.pushState function (...args) { flushCurrentPage(); // 结算当前路由的可见时长 originalPushState.apply(this, args); startNewPage(); // 新路由开启新的时间片 }; window.addEventListener(popstate, () { flushCurrentPage(); startNewPage(); });如果你用的是 Vue Router 或 React Router更省事的做法是直接监听路由库提供的afterEach/onRouteChange之类的钩子在切换完成时调用结算函数。这里还有一个容易漏的点路由切换不只是pushState用户还可能通过浏览器前进后退所以popstate必须监听否则浏览器侧导航的数据全丢。结算时如果把当前路由的可见时长和完整 URL 一起上报每次路由切换相当于一次小埋点数据归属才清晰。3.2 移动端切后台、锁屏与 bfcache 恢复移动端的坑比桌面端更多。第一个是切后台和锁屏用户锁屏 10 分钟再回来hidden期间按可见时长口径本来就不该计入这个没问题。但产品有时会问用户只是锁了个屏又马上打开算不算连续停留这种阈值问题不要放到前端拍脑袋前端只需要把 duration 和 hidden 片段信息原样记下来后端可以随时按不同口径重新聚合。我在上报数据里额外带了一个字段hiddenSegments就是为了给后续口径调整留余地。第二个大坑是 bfcache也就是浏览器往返缓存。iOS Safari 上特别明显用户离开页面后再次通过左下角返回手势回到页面页面可能直接从 bfcache 恢复此时visibilitychange不一定会理想地触发。针对这种情况要在pageshow事件里判断event.persistedwindow.addEventListener(pageshow, (event) { if (event.persisted) { // 从 bfcache 恢复重新开启时间片 tracker.segmentStartTs Date.now(); tracker.started true; } });加了这个判断返回缓存页面的时间才能被正确续上否则这类回访用户的时间会大幅偏小。这类边角情况在实际流量里占比不低尤其是有大量内容浏览场景的产品一定要在测试阶段就用真机测一遍 Safari 的返回手势。4. 上报策略与数据字段别让最后一包数据凭空消失4.1 心跳上报而不是只在关闭时上报一次如果只依赖用户关闭页面时上报有一个现实风险用户可能一整天不关页面或者页面直接被系统杀掉最后那次pagehide根本没机会执行数据就全丢了。考虑到用户停留浏览页面的时间统计是连续型指标我用的是心跳 最终上报双保险。具体做法是持续打开的页面每 30 秒上报一次心跳数据每次只需要把当前累计可见时长和页面标识发出去等到pagehide触发时再上报最终精确值。这样即使最后一包丢了后端至少知道这个用户在这个页面上停留了不下 30 秒误差可控。上传的 JSON 尽量精简字段名能短则短sendBeacon在 Chrome 里有 64KB 的上限正常心跳数据只有几百字节远够用但如果要顺带塞 DOM 快照或长列表就需要注意别超限。4.2 关键字段设计与会话标识和后端联调前我整理了一份最小可用字段表避免每次对接都临时加字段。核心建议是上报数据不能只带一个 duration要带足够的上下文。字段名类型说明page_urlstring当前页面 URLSPA 下是路由地址page_titlestring页面标题便于报表直接展示session_idstring会话标识localStorage 生成 UUIDuser_idstring登录用户 ID未登录可为空enter_tsnumber进入页面时间戳leave_tsnumber离开页面时间戳duration_msnumber可见时长累计值hidden_segmentsnumber进入后台的次数用于口径调整sourcestring落地来源如 baidu / direct这里session_id很关键它决定了后续能不能统计一个用户一次会话里连续浏览了多少个页面。我用一个简单函数生成并持久化到localStorage取不到才重新生成。没有会话标识的时间统计只能看单页均值很难做路径分析。enter_ts和leave_ts同时上报是为了后端能校验 duration 和绝对时间的合理性防止某些场景下计时算错还浑然不知。5. 我实际踩过的坑和一份简化可用的完整类5.1 踩坑问题清单代码写出来之后真正花时间的是上线前那轮真机回归。我把踩过的问题整理成了一张自检表你下次遇到类似现象可以直接对照。现象根因解决办法落地页时长虚高后台标签打开页面直接开始计时init 时先判断 visibilityState移动端关闭时数据大量缺失在 beforeunload 里发 fetch改用 pagehide sendBeaconiOS 返回页面后时长不增加从 bfcache 恢复时未识别监听 pageshow 的 persistedSPA 里两个页面时间混在一起路由变化没有结算包装 pushState / 监听 popstate后台挂一晚上计时巨大用了 interval 累计改为事件驱动的可见性累计系统时间被手动修改后时长为负直接依赖 Date.now 做差值上报时间用 Date.now差值计算用 performance.now最后一行值得一提Date.now依赖系统时间用户手动改时间会导致差值计算异常甚至出现负值。稳妥做法是页面初始化时用performance.now()作为时间基准做差值计算上报时刻再用Date.now()生成离开时间戳。这个细节绝大多数文档不会提但真实用户环境里就是会遇到。5.2 一个可直接复制的时间统计类最后贴一个我在生产环境用过的简化版本把上述要点整合到一个类里方便你根据业务再扩展。class StayTimeTracker { constructor({ reportUrl, interval 30000 }) { this.reportUrl reportUrl; this.totalVisibleMs 0; this.segmentStartTs 0; this.started false; this.hiddenSegments 0; this.enterTs Date.now(); this.timer null; this.interval interval; } init() { this.startSegment(); document.addEventListener(visibilitychange, () { if (document.visibilityState visible) { this.startSegment(); } else { this.pauseSegment(); } }); window.addEventListener(pagehide, () { this.pauseSegment(); this.report(); }); window.addEventListener(pageshow, (event) { if (event.persisted) { this.startSegment(); } }); this.timer setInterval(() this.report(), this.interval); } startSegment() { if (!this.started) { this.started true; this.segmentStartTs performance.now(); } } pauseSegment() { if (this.started) { this.totalVisibleMs performance.now() - this.segmentStartTs; this.started false; this.hiddenSegments 1; } } getDuration() { if (this.started) { return Math.round((this.totalVisibleMs performance.now() - this.segmentStartTs) / 1000); } return Math.round(this.totalVisibleMs / 1000); } report() { if (navigator.sendBeacon) { const payload JSON.stringify({ page_url: location.href, page_title: document.title, session_id: this.getSessionId(), user_id: , enter_ts: this.enterTs, leave_ts: Date.now(), duration_ms: this.getDuration() * 1000, hidden_segments: this.hiddenSegments, source: document.referrer, }); navigator.sendBeacon(this.reportUrl, payload); } } getSessionId() { let id localStorage.getItem(session_id); if (!id) { id s- Date.now().toString(36) - Math.random().toString(36).slice(2, 10); localStorage.setItem(session_id, id); } return id; } }如果你是在 SPA 里用把路由切换时调用pauseSegment()、然后重置totalVisibleMs、hiddenSegments、enterTs再startSegment()的逻辑接进去即可。实测这套方案在桌面端和移动端 Safari 上都能稳定出数误差基本控制在秒级别。我在实际使用中还有一个体会埋点上线后不要只看汇总均值最好先在报表里拉一份时长分布直方图看看是不是大量数据集中在 0-2 秒、30 秒心跳点、以及某个整数值附近。这三种异常分别对应误点跳走、心跳兜底、以及计时器被某条业务逻辑卡住。把数据分布看懂很多埋点 bug 一眼就能识破。本文还有配套的精品资源点击获取
返回列表