在前端监控用户在当前界面的停留时长(也称为“页面停留时间”或“Dwell Time”)是用户行为分析中非常重要的指标。它可以帮助我们了解用户对某个页面的兴趣程度、内容质量以及用户体验。
停留时长监控的挑战
监控停留时长并非简单地计算进入和离开的时间差,因为它需要考虑多种复杂情况:
- 用户切换标签页或最小化浏览器: 页面可能仍在后台运行,但用户并未真正“停留”在该界面。
- 浏览器关闭或崩溃: 页面没有正常卸载,可能无法触发
unload
事件。 - 网络问题: 数据上报可能失败。
- 单页应用 (SPA) : 在 SPA 中,页面切换不会触发传统的页面加载和卸载事件,需要监听路由变化。
- 长时间停留: 如果用户停留时间很长,一次性上报可能导致数据丢失(例如,浏览器或电脑崩溃)。
实现监测的思路和方法
我们将结合多种 Web API 来实现一个健壮的停留时长监控方案。
1. 基础方案:页面加载与卸载 (适用于传统多页应用)
这是最基本的方案,通过记录页面加载时间和卸载时间来计算停留时长。
// page-duration-tracker.js
let startTime = 0; // 页面加载时间
let pageId = ''; // 页面唯一标识符,例如 URL 路径
/**
* 上报页面停留时长数据
* @param {string} id 页面唯一标识
* @param {number} duration 停留时长 (毫秒)
* @param {boolean} isUnload 是否是页面卸载时上报
*/
function sendPageDuration(id, duration, isUnload = false) {
const data = {
pageId: id,
duration: duration,
timestamp: Date.now(),
eventType: isUnload ? 'page_unload' : 'page_hide',
// 可以添加更多上下文信息,如用户ID、会话ID、浏览器信息等
userAgent: navigator.userAgent,
screenWidth: window.screen.width,
screenHeight: window.screen.height
};
console.log('上报页面停留时长:', data);
// 使用 navigator.sendBeacon 确保在页面卸载时也能发送数据
// sendBeacon 适合发送少量数据,且不会阻塞页面卸载
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/page-duration', JSON.stringify(data));
} else {
// Fallback for older browsers (可能会阻塞页面卸载或失败)
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; // 使用路径作为页面ID
console.log(`页面 ${pageId} 加载,开始计时: ${startTime}`);
});
// 页面卸载时计算并上报时长
// 'beforeunload' 可能会被浏览器阻止,'pagehide' 更可靠,尤其是在移动端
window.addEventListener('pagehide', () => {
if (startTime > 0) {
const duration = Date.now() - startTime;
sendPageDuration(pageId, duration, true);
startTime = 0; // 重置,避免重复上报
}
});
// 对于某些浏览器或场景,'beforeunload' 仍然有用,作为补充
window.addEventListener('beforeunload', () => {
if (startTime > 0) {
const duration = Date.now() - startTime;
sendPageDuration(pageId, duration, true);
startTime = 0;
}
});
// 在你的 HTML 中引入此脚本
// <script src="page-duration-tracker.js"></script>
代码讲解:
-
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
事件可以帮助我们识别这种状态。
// page-duration-tracker-with-visibility.js
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'));
// 考虑长时间停留:定时上报心跳或分段数据
// 例如,每隔 30 秒上报一次当前活跃时间,并重置计数器
let heartbeatInterval;
window.addEventListener('load', () => {
startTracking(); // 确保在 load 事件中启动计时
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); // 每 30 秒
});
// 页面卸载时清除心跳定时器
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
事件。我们需要监听路由变化来模拟页面的“加载”和“卸载”。
// page-duration-tracker-spa.js
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}`);
}
});
// 监听路由变化
// 对于 React Router, Vue Router 等,你需要监听它们提供的路由事件
// 这里以原生的 History API 监听为例
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);
// replaceState 通常不视为页面切换,但如果需要,也可以在这里触发
// startTrackingNewPage(window.location.pathname);
};
// 首次加载时启动追踪
window.addEventListener('load', () => {
startTrackingNewPage(window.location.pathname);
});
// 页面最终卸载时上报(用户关闭浏览器或离开整个 SPA)
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;
}
});
// 心跳上报 (与多页应用相同,确保在 load 事件中启动)
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); // 每 30 秒
});
window.addEventListener('pagehide', () => clearInterval(heartbeatInterval));
window.addEventListener('beforeunload', () => clearInterval(heartbeatInterval));
代码讲解:
-
startTrackingNewPage(newPageId)
: 这是 SPA 方案的核心函数。- 每次路由变化时调用它。
- 它会先计算并上报前一个页面的停留时长。
- 然后重置计时器,开始计算新页面的停留时长。
-
路由监听:
-
window.addEventListener('popstate', ...)
: 监听浏览器前进/后退按钮导致的 URL 变化。 -
history.pushState
和history.replaceState
的劫持: SPA 框架通常通过这些方法来改变 URL 而不触发页面刷新。通过劫持它们,我们可以在路由发生变化时触发startTrackingNewPage
。 -
注意: 如果你使用 React Router, Vue Router 等,它们通常提供了更方便的路由守卫或事件钩子来监听路由变化,你应该优先使用框架提供的 API。例如:
- React Router: 在
useEffect
中监听location.pathname
变化。 - Vue Router: 使用
router.beforeEach
或router.afterEach
导航守卫。
- React Router: 在
-
总结与最佳实践
- 区分多页应用和单页应用: 根据你的应用类型选择合适的监听策略。
- 结合 Visibility API: 确保只计算用户真正“活跃”在页面上的时间。
- 使用
navigator.sendBeacon
: 确保在页面卸载时数据能够可靠上报。 - 心跳上报: 对于长时间停留的页面,定期上报数据,防止数据丢失。
- 唯一页面标识: 确保每个页面都有一个唯一的 ID,以便后端能够正确聚合数据。
- 上下文信息: 上报数据时,包含用户 ID、会话 ID、设备信息、浏览器信息等,以便更深入地分析用户行为。
- 后端处理: 后端需要接收这些数据,并进行存储、聚合和分析。例如,可以计算每个页面的平均停留时间、总停留时间、不同用户群体的停留时间等。
- 数据准确性: 即使有了这些方案,停留时长仍然是一个近似值,因为总有一些极端情况(如断网、浏览器崩溃)可能导致数据丢失。目标是尽可能提高数据的准确性和覆盖率。