在前端监控用户在当前界面的停留时长(也称为“页面停留时间”或“Dwell Time”)是用户行为分析中非常重要的指标。它可以帮助我们了解用户对某个页面的兴趣程度、内容质量以及用户体验。
停留时长监控的挑战
监控停留时长并非简单地计算进入和离开的时间差,因为它需要考虑多种复杂情况:
- 用户切换标签页或最小化浏览器: 页面可能仍在后台运行,但用户并未真正“停留”在该界面。
- 浏览器关闭或崩溃: 页面没有正常卸载,可能无法触发
unload
事件。 - 网络问题: 数据上报可能失败。
- 单页应用 (SPA) : 在 SPA 中,页面切换不会触发传统的页面加载和卸载事件,需要监听路由变化。
- 长时间停留: 如果用户停留时间很长,一次性上报可能导致数据丢失(例如,浏览器或电脑崩溃)。
实现监测的思路和方法
我们将结合多种 Web API 来实现一个健壮的停留时长监控方案。
1. 基础方案:页面加载与卸载 (适用于传统多页应用)
这是最基本的方案,通过记录页面加载时间和卸载时间来计算停留时长。
let startTime = 0;
let pageId = '';
function sendPageDuration(id, duration, isUnload = false) {
const data = {
pageId: id,
duration: duration,
timestamp: Date.now(),
eventType: isUnload ? 'page_unload' : 'page_hide',
userAgent: navigator.userAgent,
screenWidth: window.screen.width,
screenHeight: window.screen.height
};
console.log('上报页面停留时长:', data);
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/page-duration', JSON.stringify(data));
} else {
fetch('/api/page-duration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
keepalive: true
}).catch(e => console.error('发送停留时长失败:', e));
}
}
window.addEventListener('load', () => {
startTime = Date.now();
pageId = window.location.pathname;
console.log(`页面 ${pageId} 加载,开始计时: ${startTime}`);
});
window.addEventListener('pagehide', () => {
if (startTime > 0) {
const duration = Date.now() - startTime;
sendPageDuration(pageId, duration, true);
startTime = 0;
}
});
window.addEventListener('beforeunload', () => {
if (startTime > 0) {
const duration = Date.now() - startTime;
sendPageDuration(pageId, duration, true);
startTime = 0;
}
});
代码讲解:
startTime
: 记录页面加载时的 Unix 时间戳。
pageId
: 标识当前页面,这里简单地使用了 window.location.pathname
。在实际应用中,你可能需要更复杂的 ID 策略(如路由名称、页面 ID 等)。
sendPageDuration(id, duration, isUnload)
: 负责将页面 ID 和停留时长发送到后端。
navigator.sendBeacon()
: 推荐用于在页面卸载时发送数据。它不会阻塞页面卸载,且即使页面正在关闭,也能保证数据发送。fetch({ keepalive: true })
: keepalive: true
选项允许 fetch
请求在页面卸载后继续发送,作为 sendBeacon
的备用方案。
window.addEventListener('load', ...)
: 在页面完全加载后开始计时。
window.addEventListener('pagehide', ...)
: 当用户离开页面(切换标签页、关闭浏览器、导航到其他页面)时触发。这是一个更可靠的事件,尤其是在移动端,因为它在页面进入“后台”状态时触发。
window.addEventListener('beforeunload', ...)
: 在页面即将卸载时触发。它比 pagehide
触发得更早,但可能会被浏览器阻止(例如,如果页面有未保存的更改)。作为补充使用。
2. 考虑用户活跃状态:Visibility API
当用户切换标签页或最小化浏览器时,页面可能仍在运行,但用户并未真正“停留”。document.visibilityState
和 visibilitychange
事件可以帮助我们识别这种状态。
let startTime = 0;
let totalActiveTime = 0;
let lastActiveTime = 0;
let pageId = '';
function sendPageDuration(id, duration, eventType) {
const data = {
pageId: id,
duration: duration,
timestamp: Date.now(),
eventType: eventType,
};
console.log('上报页面停留时长:', data);
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/page-duration', JSON.stringify(data));
} else {
fetch('/api/page-duration', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), keepalive: true }).catch(e => console.error('发送停留时长失败:', e));
}
}
function startTracking() {
startTime = Date.now();
lastActiveTime = startTime;
totalActiveTime = 0;
pageId = window.location.pathname;
console.log(`页面 ${pageId} 加载,开始计时 (总时长): ${startTime}`);
}
function stopTrackingAndReport(eventType) {
if (startTime > 0) {
if (document.visibilityState === 'visible') {
totalActiveTime += (Date.now() - lastActiveTime);
}
sendPageDuration(pageId, totalActiveTime, eventType);
startTime = 0;
totalActiveTime = 0;
lastActiveTime = 0;
}
}
window.addEventListener('load', startTracking);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
totalActiveTime += (Date.now() - lastActiveTime);
console.log(`页面 ${pageId} 变为不可见,累加活跃时间: ${totalActiveTime}`);
} else {
lastActiveTime = Date.now();
console.log(`页面 ${pageId} 变为可见,恢复计时: ${lastActiveTime}`);
}
});
window.addEventListener('pagehide', () => stopTrackingAndReport('page_hide'));
window.addEventListener('beforeunload', () => stopTrackingAndReport('page_unload'));
let heartbeatInterval;
window.addEventListener('load', () => {
startTracking();
heartbeatInterval = setInterval(() => {
if (document.visibilityState === 'visible' && startTime > 0) {
const currentActiveTime = Date.now() - lastActiveTime;
totalActiveTime += currentActiveTime;
lastActiveTime = Date.now();
console.log(`心跳上报 ${pageId} 活跃时间: ${currentActiveTime}ms, 累计: ${totalActiveTime}ms`);
sendPageDuration(pageId, currentActiveTime, 'heartbeat');
}
}, 30 * 1000);
});
window.addEventListener('pagehide', () => {
clearInterval(heartbeatInterval);
stopTrackingAndReport('page_hide');
});
window.addEventListener('beforeunload', () => {
clearInterval(heartbeatInterval);
stopTrackingAndReport('page_unload');
});
代码讲解:
totalActiveTime
: 存储用户在页面可见状态下的累计停留时间。
lastActiveTime
: 记录页面上次变为可见的时间戳。
document.addEventListener('visibilitychange', ...)
: 监听页面可见性变化。
- 当页面变为
hidden
时,将从 lastActiveTime
到当前的时间差累加到 totalActiveTime
。 - 当页面变为
visible
时,更新 lastActiveTime
为当前时间,表示重新开始计算活跃时间。
心跳上报: setInterval
每隔一段时间(例如 30 秒)检查页面是否可见,如果是,则计算并上报当前时间段的活跃时间。这有助于在用户长时间停留但未触发 pagehide
或 beforeunload
的情况下(例如浏览器崩溃、电脑关机),也能获取到部分停留数据。
3. 针对单页应用 (SPA) 的解决方案
SPA 的页面切换不会触发传统的 load
或 unload
事件。我们需要监听路由变化来模拟页面的“加载”和“卸载”。
let startTime = 0;
let totalActiveTime = 0;
let lastActiveTime = 0;
let currentPageId = '';
function sendPageDuration(id, duration, eventType) {
const data = {
pageId: id,
duration: duration,
timestamp: Date.now(),
eventType: eventType,
};
console.log('上报 SPA 页面停留时长:', data);
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/page-duration', JSON.stringify(data));
} else {
fetch('/api/page-duration', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), keepalive: true }).catch(e => console.error('发送停留时长失败:', e));
}
}
function startTrackingNewPage(newPageId) {
if (currentPageId && startTime > 0) {
if (document.visibilityState === 'visible') {
totalActiveTime += (Date.now() - lastActiveTime);
}
sendPageDuration(currentPageId, totalActiveTime, 'route_change');
}
startTime = Date.now();
lastActiveTime = startTime;
totalActiveTime = 0;
currentPageId = newPageId;
console.log(`SPA 页面 ${currentPageId} 加载,开始计时: ${startTime}`);
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
totalActiveTime += (Date.now() - lastActiveTime);
console.log(`SPA 页面 ${currentPageId} 变为不可见,累加活跃时间: ${totalActiveTime}`);
} else {
lastActiveTime = Date.now();
console.log(`SPA 页面 ${currentPageId} 变为可见,恢复计时: ${lastActiveTime}`);
}
});
window.addEventListener('popstate', () => {
startTrackingNewPage(window.location.pathname);
});
const originalPushState = history.pushState;
history.pushState = function() {
originalPushState.apply(history, arguments);
startTrackingNewPage(window.location.pathname);
};
const originalReplaceState = history.replaceState;
history.replaceState = function() {
originalReplaceState.apply(history, arguments);
};
window.addEventListener('load', () => {
startTrackingNewPage(window.location.pathname);
});
window.addEventListener('pagehide', () => {
if (currentPageId && startTime > 0) {
if (document.visibilityState === 'visible') {
totalActiveTime += (Date.now() - lastActiveTime);
}
sendPageDuration(currentPageId, totalActiveTime, 'app_unload');
currentPageId = '';
startTime = 0;
totalActiveTime = 0;
lastActiveTime = 0;
}
});
window.addEventListener('beforeunload', () => {
if (currentPageId && startTime > 0) {
if (document.visibilityState === 'visible') {
totalActiveTime += (Date.now() - lastActiveTime);
}
sendPageDuration(currentPageId, totalActiveTime, 'app_unload');
currentPageId = '';
startTime = 0;
totalActiveTime = 0;
lastActiveTime = 0;
}
});
let heartbeatInterval;
window.addEventListener('load', () => {
heartbeatInterval = setInterval(() => {
if (document.visibilityState === 'visible' && currentPageId) {
const currentActiveTime = Date.now() - lastActiveTime;
totalActiveTime += currentActiveTime;
lastActiveTime = Date.now();
console.log(`SPA 心跳上报 ${currentPageId} 活跃时间: ${currentActiveTime}ms, 累计: ${totalActiveTime}ms`);
sendPageDuration(currentPageId, currentActiveTime, 'heartbeat');
}
}, 30 * 1000);
});
window.addEventListener('pagehide', () => clearInterval(heartbeatInterval));
window.addEventListener('beforeunload', () => clearInterval(heartbeatInterval));
代码讲解:
总结与最佳实践
- 区分多页应用和单页应用: 根据你的应用类型选择合适的监听策略。
- 结合 Visibility API: 确保只计算用户真正“活跃”在页面上的时间。
- 使用
navigator.sendBeacon
: 确保在页面卸载时数据能够可靠上报。 - 心跳上报: 对于长时间停留的页面,定期上报数据,防止数据丢失。
- 唯一页面标识: 确保每个页面都有一个唯一的 ID,以便后端能够正确聚合数据。
- 上下文信息: 上报数据时,包含用户 ID、会话 ID、设备信息、浏览器信息等,以便更深入地分析用户行为。
- 后端处理: 后端需要接收这些数据,并进行存储、聚合和分析。例如,可以计算每个页面的平均停留时间、总停留时间、不同用户群体的停留时间等。
- 数据准确性: 即使有了这些方案,停留时长仍然是一个近似值,因为总有一些极端情况(如断网、浏览器崩溃)可能导致数据丢失。目标是尽可能提高数据的准确性和覆盖率。
转自https://juejin.cn/post/7510803578505134119
该文章在 2025/6/4 11:59:09 编辑过