ARTICLE DETAIL

资讯详情

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

纯JavaScript实现用户页面停留时长统计:可见性监听与sendBeacon上报

纯JavaScript实现用户页面停留时长统计:可见性监听与sendBeacon上报 简介面向iOS开发者和产品运营人员这份工程演示了如何在客户端实现用户浏览页面停留时间的采集与统计。核心基于Objective-C编写通过事件监听、时间戳记录、定时轮询与离开事件捕捉等机制覆盖页面加载、滚动、静止与退出等状态的完整计时流程同时内置间隔奖励处理逻辑可灵活扩展红包激励、广告效果评估与用户留存分析等业务场景。压缩包共6个文件包含2个Objective-C类实现文件(.m)、2个对应头文件(.h)以及2张流程图(png)整体仅48KB结构精简、注释清晰适合快速阅读、移植或二次开发。当前已有403人学习对正在做用户行为埋点、停留时长上报或产品体验优化的技术团队具有直接参考价值代码量不大也是一个理解前端计时交互与数据采集流程的不错范本。 做了几年前端接手过好几个内容型项目发现一个很普遍的需求业务方总想搞清楚“用户到底在页面上看了多久”。页面停留时长这个指标听起来简单真正落地的时候细节多到能把人绕晕。我最近正好在一个资讯类站点里从零做了一套用户停留浏览页面的时间统计方案趁热把实现思路、完整代码和踩过的坑整理出来。这套方案不依赖任何第三方统计平台纯原生JavaScript实现拿到任何项目里都能直接用。先说清楚它到底能做什么记录用户真实浏览页面的有效时长区分前台可见浏览和后台挂机支持单页应用路由切换离开页面时可靠上报数据入库后可以用于内容质量评估、用户活跃度分层、广告位定价参考。适合需要在自有站点里做精细化用户行为分析的开发者也适合想自己动手实现、不想被第三方SDK绑死的团队参考。1. 整体设计思路先搞懂“用户停留”到底该怎么定义1.1 为什么单纯记录“进入页面到离开页面”不够用很多人第一反应是在页面加载时记一个开始时间离开时算差值。这个方案在PC端浏览器、用户老老实实只看一个标签页的场景下勉强能用但放到真实环境里漏洞百出。举个例子用户上午10点打开文章看了两分钟切到另一个聊天窗口聊了半小时又切回来看了一分钟最后关掉页面。如果只用进入和离开时间差统计出来是33分钟而用户真正阅读内容的时间只有3分钟。这个数据对于内容运营来说基本没有参考意义——他们想知道的是一篇文章到底能抓住读者多久的注意力而不是用户多久之后才关掉页面。所以第一版设计就确定了一个核心原则只累计页面在前台且可见状态下的时间页面隐藏或浏览器切到后台时暂停计时切回来继续累加。1.2 技术选型有哪些方案哪个更合理做停留时长统计市面上大致有几条路纯后端根据请求日志估算利用接口请求的时间间隔近似估算用户停留时间。优点是省前端事缺点是误差大尤其对静态页面几乎无解用户看了一篇图文不产生任何接口请求后端就完全失明。前端心跳上报每隔几秒或者十几秒向服务器上报一次“我还活着”。实现简单但会产生大量无效请求而且无法区分用户是看的页面还是挂着页面去做别的事了。基于Page Visibility API的可见时间累加监听文档可见性变化只有页面可见时才累计时间。这个方案精准度最高也最接近“有效停留时长”的定义。基于用户交互行为辅助判定比如监听鼠标移动、滚动、键盘事件超时无操作就暂停计时。这种做法可以和Visibility API结合但对于纯阅读型页面来说误伤率较高——用户安安静静读一篇长文可能3分钟不动鼠标。综合考虑精度、实现成本、服务端压力我最终选了第三种方案并在特定场景下叠加了部分交互辅助判断。核心思想就是用户说“我在看”不算数浏览器说“页面在前台”才算数。2. 核心细节解析计时逻辑、上报策略和关键取舍2.1 计时口径用累积可见时长不要用一个连续时间段第一版实现我踩过一个坑只维护了一个全局的开始时间戳和一个结束时间戳想着离开时做差。后来发现用户切换标签页、切换窗口、甚至按了一下系统锁屏快捷键都会触发visibilitychange事件如果只是简单做差切走的这段时间全部被算进去。修正之后的方案是维护三个核心变量visibleStartTime本次页面变为可见状态的时刻。accVisibleTime已经累计的可见时长单位毫秒。lastReportTime上次上报的时刻用于中途主动上报时避免重复。每当页面从hidden变为visible记下当前时间戳作为visibleStartTime从visible变为hidden或者检测到页面即将卸载时把Date.now() - visibleStartTime累加进accVisibleTime然后清空visibleStartTime。这样无论用户怎么切来切去最终得到的accVisibleTime就是真正停留在页面上的有效时间。2.2 上报时机卸载上报要可靠别让数据悄悄丢掉统计结果最终要送到服务端。上报时机的选择直接影响数据完整度我整理出三个候选时机定时上报比如每30秒上报一次累计时长优点是数据实时缺点是请求量大而且如果用户中途离开最后一次数据可能没来得及发出去。页面变为hidden时上报这是最推荐的时机。用户切走标签页、切到别的应用、关闭浏览器标签页都会触发visibilitychange到hidden此时上报能覆盖绝大多数离开场景。beforeunload / pagehide时上报作为兜底覆盖用户直接关闭页面但visibilitychange没来得及触发的情况。最终我采用了“hidden时上报 pagehide兜底”的组合方案。用户切走时浏览器的hidden事件会先触发此时上报一次用户直接关标签页时pagehide兜底再补一刀。经过多轮实测这个组合能覆盖百分之九十八以上的离开场景。2.3 上报通道用sendBeacon别用fetch或XMLHttpRequest既然涉及卸载时上报就绕不开一个技术点页面即将销毁普通的异步请求还能不能发出去答案是不稳定。在beforeunload或pagehide事件里发fetch请求浏览器很可能直接中断连接因为页面上下文销毁了。更稳妥的是navigator.sendBeacon()。它专门为这种场景设计数据量小、可靠性高、浏览器会在后台尽力发送而且不阻塞页面卸载。上报的数据格式我设计成了这样{ pageId: article_882123, channel: homepage_recommend, uvId: 3f0a9c2e-5d24-4b17-9c6f-2e8a1d0f7b23, totalStayMs: 186000, sessionStart: 2024-11-20T10:00:0008:00, reportTime: 2024-11-20T10:03:0608:00, ua: Mozilla/5.0 (iPhone; CPU iPhone OS 17_2... }totalStayMs在示例里是186000毫秒换算过来是3分06秒。这个字段是累计可见时长不是从进入到上报的时间差。另外加了pageId和channel便于服务端做内容维度分析uvId用于去重和识别独立访客。3. 实操过程与核心实现完整可复用的代码方案3.1 基础版页面可见性监听 累计时长计算先写一个不依赖任何框架的基础版本纯JavaScript类直接复制到项目里就能跑。class StayTimeTracker { constructor({ reportUrl, pageId, channel, onReport } {}) { this.reportUrl reportUrl; this.pageId pageId || window.location.pathname; this.channel channel || document.referrer || direct; this.onReport onReport; this.accVisibleTime 0; this.visibleStartTime null; this.isVisible !document.hidden; this._init(); } _init() { document.addEventListener(visibilitychange, () { if (document.hidden) { this._pause(); } else { this._resume(); } }); window.addEventListener(pagehide, () { this._pause(); this._report(); }); window.addEventListener(beforeunload, () { this._pause(); // 不在beforeunload里上报避免重复交给pagehide处理 }); if (this.isVisible) { this.visibleStartTime Date.now(); } } _pause() { if (this.visibleStartTime ! null) { this.accVisibleTime Date.now() - this.visibleStartTime; this.visibleStartTime null; } } _resume() { if (!document.hidden this.visibleStartTime null) { this.visibleStartTime Date.now(); } } getVisibleDuration() { // 如果当前仍处于可见状态需要把正在计时的这一段时间也算进去 if (this.visibleStartTime ! null) { return this.accVisibleTime (Date.now() - this.visibleStartTime); } return this.accVisibleTime; } _report() { const duration this.getVisibleDuration(); if (duration 3000) { // 低于3秒视为无效访问不上报减轻服务端压力 return; } const payload { pageId: this.pageId, channel: this.channel, totalStayMs: duration, reportTime: new Date().toISOString() }; if (this.onReport) { this.onReport(payload); return; } if (navigator.sendBeacon) { const blob new Blob([JSON.stringify(payload)], { type: application/json;charsetUTF-8 }); navigator.sendBeacon(this.reportUrl, blob); } else { // 降级方案用同步的XMLHttpRequest确保请求能发出去 const xhr new XMLHttpRequest(); xhr.open(POST, this.reportUrl, false); xhr.setRequestHeader(Content-Type, application/json;charsetUTF-8); xhr.send(JSON.stringify(payload)); } } }几个值得重点说明的设计初始化时判断document.hidden如果用户打开页面时页面就是隐藏的比如恢复浏览器会话不能直接记开始时间。_pause()里判断visibleStartTime ! null防止同一次隐藏事件触发多次或者resume后立即pause导致重复累加。3秒过滤阈值把停留不足3秒的访问过滤掉。这是我根据实际业务数据调出来的值低于3秒基本是误触打开或跳转太快统计进去会拉低整体均值。3.2 升级场景单页应用路由切换怎么处理如果是SPA比如Vue、React项目情况会复杂一些。SPA里页面不会整体刷新切换路由只是JavaScript层面的视图变化visibilitychange不会触发所以必须额外监听路由变化。处理思路是在路由切换发生时把当前页面的累计时长结算上报然后清零重新累计。以Vue Router为例可以这样接入// 在全局路由守卫中接入 const tracker new StayTimeTracker({ reportUrl: /api/stay-time, pageId: window.location.pathname }); router.afterEach((to, from) { if (to.path from.path) return; // 结算上一个页面的停留时长 tracker._pause(); tracker._report(); // 重置统计开始记录新页面 tracker.accVisibleTime 0; tracker.visibleStartTime null; tracker.pageId to.path; tracker._resume(); });这里有个很容易踩的坑有些同学会在beforeEach里做上报但这时候新路由还没渲染旧页面的DOM可能还在时机不对容易拿错参数。afterEach是路由切换完成之后触发此时页面视图已经更新上报旧页面的数据、初始化新页面的统计时序上是干净的。React项目大同小异在useEffect里监听location.pathname的变化即可。核心规律是路由变化时先暂停计时、上报、清零再启动新一轮计时。3.3 进阶优化用交互行为辅助修正可见性计时纯Visibility方案还有一个盲区用户虽然停留在页面上但人已经走开了——比如浏览器开着页面但人去接电话、去吃饭。这种情况下页面仍然是visible状态计时器会一直在走。要解决这个问题可以在可见性计时基础上叠加一个“无操作暂停”机制。实现逻辑是监听mousemove、keydown、mousedown、touchstart、scroll事件。维护一个lastActivityTime任何一次交互都更新这个时间。设定一个超时阈值比如60秒。每隔10秒做一次检查如果当前时间减去lastActivityTime超过了阈值就暂停计时如果用户又操作了恢复计时。注意这里不能把用户长时间停留在页面顶部但没滚动也算作无操作——对于阅读型页面用户可能几分钟不碰鼠标键盘就盯着屏幕在读。所以无操作暂停机制的阈值要保守一些我建议设在2到5分钟之间只在识别到长时间离开时暂停不要误伤正常阅读的用户。下面是改进版的片段// 在Constructor中追加 this.lastActivityTime Date.now(); this.idlePaused false; [mousemove, mousedown, keydown, touchstart, scroll].forEach(evt { document.addEventListener(evt, this._onActivity, { passive: true, capture: true }); }); // 每10秒检查一次是否有操作 this._idleCheckTimer setInterval(() { if (!document.hidden Date.now() - this.lastActivityTime 120000) { if (!this.idlePaused) { this.idlePaused true; this._pause(); } } else if (this.idlePaused) { // 用户回到操作状态恢复计时 this.idlePaused false; this._resume(); } }, 10000); _onActivity() { this.lastActivityTime Date.now(); if (this.idlePaused) { this.idlePaused false; this._resume(); } }这个版本更贴近真实的“有效停留时长”业务方拿到数据后反馈说终于能区分出“认真阅读”和“挂机不关”的差异了。4. 常见问题与排查技巧实录4.1 为什么用户停留在页面上计时却停止了大部分情况是触发了visibilitychange到hidden原因包括用户切到别的应用、系统弹出锁屏、显示器进入睡眠模式、浏览器窗口最小化。这是符合预期的行为不是bug。如果确认用户确实在看页面但计时停了排查思路有两条检查浏览器的省电策略有些手机浏览器尤其是安卓端在页面静置一段时间后会主动冻结JS执行即使页面在前台也一样。解法是不要在纯JS层对抗把计时逻辑拆到每次事件触发时计算——也就是不要依赖定时器持续累加而是用事件驱动只在可见性变化、路由变化、页面卸载时结算差值。检查代码里是否有多处绑定了visibilitychange而且某个监听器调用了removeEventListener把其他监听器误删了。用console.log在监听器里打印日志逐步排查。4.2 上报数据偶尔缺失或者重复上报数据缺失最常见的原因是用户在页面完全加载之前就切走了。比如网速慢页面还没加载完用户等不及关了标签页。此时pagehide可能不触发因为页面连load都没有完成。解决办法在DOMContentLoaded时先做一次初始化如果此时document.hidden为true说明用户已经切走立即补上报一次。重复上报集中在两种场景一个是visibilitychange和pagehide在同一次离开中叠加触发上报了两次。另一个是SPA场景里用户切换路由触发了上报紧接着关闭页面又触发了pagehide上报。解法是在上报方法里加一个“已上报标记”每次放开新一轮计时时重置_report() { if (this._reported) return; this._reported true; // 上报逻辑... } _reset() { this._reported false; // 其他清理逻辑... }这样每一轮的可见数据只会成功上报一次。我在服务端也加了一层幂等去重用uvId pageId sessionStart作为唯一键就算前端漏网了后端也不会重复入库。4.3 移动端和桌面端行为不一致之前测试时发现一个有趣的现象在PC浏览器上用户按下WinL锁屏页面会立即触发visibilitychange到hidden。但在部分安卓手机上锁屏之后某些浏览器尤其是WebView不会触发这个事件计时器会一直往后跑。我最终的应对方案是在可见状态下定时检查Date.now()与最后交互时间的差值超过阈值就认为是挂机主动暂停计时。这个方案在移动端和桌面端都能兜底也是上文3.3节那个_idleCheckTimer的价值所在。4.4 如何验证统计是否准确开发阶段我推荐两种验证方式。直接在控制台手动触发事件// 模拟切走 Object.defineProperty(document, hidden, { value: true, configurable: true }); document.dispatchEvent(new Event(visibilitychange)); // 再过5秒模拟切回 Object.defineProperty(document, hidden, { value: false, configurable: true }); document.dispatchEvent(new Event(visibilitychange)); // 查看累计时长 tracker.getVisibleDuration();用Chrome DevTools的传感器面板模拟切后台。打开DevTools - More tools - Sensors可以勾选“Override visibility state”把页面状态强制切换为hidden或visible这是模拟真实用户切走切回最方便的方式。配合Network面板看上报请求确认切走的瞬间有一条beacon请求发出返回200数据里的totalStayMs符合预期整套链路就通了。5. 数据入库后的几个分析角度前端上报只是第一公里数据到底有没有价值取决于后端拿到之后怎么用。我在这套系统上线后推荐业务方从三个维度看数据内容维度同一篇文章在不同渠道列表页、搜索、外链进来的读者平均停留时间是不是差很多哪个渠道带来的用户更“精准”。以一篇3000字的图文为例按每分钟400-500字的阅读速度理想停留时长在6到8分钟之间。如果某篇文章平均停留只有40秒大概率是标题党正文质量没有跟上。用户维度把用户按平均停留时长分为深度阅读型、碎片浏览型、误触跳转型再结合历史行为做个性化推荐。碎片浏览型用户适合推短文、快讯深度阅读型用户推长文和专题实测能提升整体人均阅读时长。功能维度改版前后对比新UI是让用户更容易沉浸阅读还是更浮躁地频繁跳走。改版灰度期间同时观察停留时长的中位数和均值比只盯着点击率和转化率更能反应体验变化。我个人在实际操作中比较推荐用中位数而不是均值做周报指标因为停留时长是典型的右偏分布——少数用户挂机几小时会把均值拉得很高而中位数更能代表大多数人的真实体验。最后再分享一个小技巧上线这套统计之后建议给自己留一个内部白名单参数比如URL上带debug1时在前端控制台打印每次上报的完整payload。我靠这招排查了无数次线上数据异常有时候业务方跑过来说“昨天数据怎么少了”一看就是某个浏览器版本的事件触发时机变化导致的几分钟就能定位问题。这套统计方案的整体框架搭好之后后续不管是接公司内部的数据平台还是扩展成更细粒度的“区块停留时长分析”都只是顺着这个管道加数据字段的事。本文还有配套的精品资源点击获取
返回列表