在前端監(jiān)控用戶在當(dāng)前界面的停留時長(也稱為“頁面停留時間”或“Dwell Time”)是用戶行為分析中非常重要的指標(biāo)。它可以幫助我們了解用戶對某個頁面的興趣程度、內(nèi)容質(zhì)量以及用戶體驗。
停留時長監(jiān)控的挑戰(zhàn)
監(jiān)控停留時長并非簡單地計算進(jìn)入和離開的時間差,因為它需要考慮多種復(fù)雜情況:
- 用戶切換標(biāo)簽頁或最小化瀏覽器: 頁面可能仍在后臺運行,但用戶并未真正“停留”在該界面。
- 瀏覽器關(guān)閉或崩潰: 頁面沒有正常卸載,可能無法觸發(fā)
unload
事件。 - 網(wǎng)絡(luò)問題: 數(shù)據(jù)上報可能失敗。
- 單頁應(yīng)用 (SPA) : 在 SPA 中,頁面切換不會觸發(fā)傳統(tǒng)的頁面加載和卸載事件,需要監(jiān)聽路由變化。
- 長時間停留: 如果用戶停留時間很長,一次性上報可能導(dǎo)致數(shù)據(jù)丟失(例如,瀏覽器或電腦崩潰)。
實現(xiàn)監(jiān)測的思路和方法
我們將結(jié)合多種 Web API 來實現(xiàn)一個健壯的停留時長監(jiān)控方案。
1. 基礎(chǔ)方案:頁面加載與卸載 (適用于傳統(tǒng)多頁應(yīng)用)
這是最基本的方案,通過記錄頁面加載時間和卸載時間來計算停留時長。
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('發(fā)送停留時長失敗:', 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
: 標(biāo)識當(dāng)前頁面,這里簡單地使用了 window.location.pathname
。在實際應(yīng)用中,你可能需要更復(fù)雜的 ID 策略(如路由名稱、頁面 ID 等)。
sendPageDuration(id, duration, isUnload)
: 負(fù)責(zé)將頁面 ID 和停留時長發(fā)送到后端。
navigator.sendBeacon()
: 推薦用于在頁面卸載時發(fā)送數(shù)據(jù)。它不會阻塞頁面卸載,且即使頁面正在關(guān)閉,也能保證數(shù)據(jù)發(fā)送。fetch({ keepalive: true })
: keepalive: true
選項允許 fetch
請求在頁面卸載后繼續(xù)發(fā)送,作為 sendBeacon
的備用方案。
window.addEventListener('load', ...)
: 在頁面完全加載后開始計時。
window.addEventListener('pagehide', ...)
: 當(dāng)用戶離開頁面(切換標(biāo)簽頁、關(guān)閉瀏覽器、導(dǎo)航到其他頁面)時觸發(fā)。這是一個更可靠的事件,尤其是在移動端,因為它在頁面進(jìn)入“后臺”狀態(tài)時觸發(fā)。
window.addEventListener('beforeunload', ...)
: 在頁面即將卸載時觸發(fā)。它比 pagehide
觸發(fā)得更早,但可能會被瀏覽器阻止(例如,如果頁面有未保存的更改)。作為補(bǔ)充使用。
2. 考慮用戶活躍狀態(tài):Visibility API
當(dāng)用戶切換標(biāo)簽頁或最小化瀏覽器時,頁面可能仍在運行,但用戶并未真正“停留”。document.visibilityState
和 visibilitychange
事件可以幫助我們識別這種狀態(tài)。
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('發(fā)送停留時長失敗:', 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} 變?yōu)椴豢梢?,累加活躍時間: ${totalActiveTime}`);
} else {
lastActiveTime = Date.now();
console.log(`頁面 ${pageId} 變?yōu)榭梢姡謴?fù)計時: ${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
: 存儲用戶在頁面可見狀態(tài)下的累計停留時間。
lastActiveTime
: 記錄頁面上次變?yōu)榭梢姷臅r間戳。
document.addEventListener('visibilitychange', ...)
: 監(jiān)聽頁面可見性變化。
- 當(dāng)頁面變?yōu)?nbsp;
hidden
時,將從 lastActiveTime
到當(dāng)前的時間差累加到 totalActiveTime
。 - 當(dāng)頁面變?yōu)?nbsp;
visible
時,更新 lastActiveTime
為當(dāng)前時間,表示重新開始計算活躍時間。
心跳上報: setInterval
每隔一段時間(例如 30 秒)檢查頁面是否可見,如果是,則計算并上報當(dāng)前時間段的活躍時間。這有助于在用戶長時間停留但未觸發(fā) pagehide
或 beforeunload
的情況下(例如瀏覽器崩潰、電腦關(guān)機(jī)),也能獲取到部分停留數(shù)據(jù)。
3. 針對單頁應(yīng)用 (SPA) 的解決方案
SPA 的頁面切換不會觸發(fā)傳統(tǒng)的 load
或 unload
事件。我們需要監(jiān)聽路由變化來模擬頁面的“加載”和“卸載”。
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('發(fā)送停留時長失敗:', 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} 變?yōu)椴豢梢?,累加活躍時間: ${totalActiveTime}`);
} else {
lastActiveTime = Date.now();
console.log(`SPA 頁面 ${currentPageId} 變?yōu)榭梢?,恢?fù)計時: ${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));
代碼講解:
總結(jié)與最佳實踐
- 區(qū)分多頁應(yīng)用和單頁應(yīng)用: 根據(jù)你的應(yīng)用類型選擇合適的監(jiān)聽策略。
- 結(jié)合 Visibility API: 確保只計算用戶真正“活躍”在頁面上的時間。
- 使用
navigator.sendBeacon
: 確保在頁面卸載時數(shù)據(jù)能夠可靠上報。 - 心跳上報: 對于長時間停留的頁面,定期上報數(shù)據(jù),防止數(shù)據(jù)丟失。
- 唯一頁面標(biāo)識: 確保每個頁面都有一個唯一的 ID,以便后端能夠正確聚合數(shù)據(jù)。
- 上下文信息: 上報數(shù)據(jù)時,包含用戶 ID、會話 ID、設(shè)備信息、瀏覽器信息等,以便更深入地分析用戶行為。
- 后端處理: 后端需要接收這些數(shù)據(jù),并進(jìn)行存儲、聚合和分析。例如,可以計算每個頁面的平均停留時間、總停留時間、不同用戶群體的停留時間等。
- 數(shù)據(jù)準(zhǔn)確性: 即使有了這些方案,停留時長仍然是一個近似值,因為總有一些極端情況(如斷網(wǎng)、瀏覽器崩潰)可能導(dǎo)致數(shù)據(jù)丟失。目標(biāo)是盡可能提高數(shù)據(jù)的準(zhǔn)確性和覆蓋率。
轉(zhuǎn)自https://juejin.cn/post/7510803578505134119
該文章在 2025/6/4 11:59:09 編輯過