简介:面向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:00+08:00", "reportTime": "2024-11-20T10:03:06+08: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;charset=UTF-8' }); navigator.sendBeacon(this.reportUrl, blob); } else { // 降级方案:用同步的XMLHttpRequest,确保请求能发出去 const xhr = new XMLHttpRequest(); xhr.open('POST', this.reportUrl, false); xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-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浏览器上,用户按下Win+L锁屏,页面会立即触发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上带debug=1时在前端控制台打印每次上报的完整payload。我靠这招排查了无数次线上数据异常,有时候业务方跑过来说“昨天数据怎么少了”,一看就是某个浏览器版本的事件触发时机变化导致的,几分钟就能定位问题。这套统计方案的整体框架搭好之后,后续不管是接公司内部的数据平台,还是扩展成更细粒度的“区块停留时长分析”,都只是顺着这个管道加数据字段的事。
本文还有配套的精品资源,点击获取