在前端监控用户在当前界面的停留时长(也称为“页面停留时间”或“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 编辑过