如何精確統(tǒng)計(jì)頁(yè)面停留時(shí)長(zhǎng)

作者:今日頭條技術(shù)
鏈接:https://techblog.toutiao.com/2018/06/05/ru-he-jing-que-tong-ji-ye-mian-ting-liu-shi-chang/
1、背景
頁(yè)面停留時(shí)間(Time on Page)簡(jiǎn)稱 Tp,是網(wǎng)站分析中很常見的一個(gè)指標(biāo),用于反映用戶在某些頁(yè)面上停留時(shí)間的長(zhǎng)短,傳統(tǒng)的Tp統(tǒng)計(jì)方法會(huì)存在一定的統(tǒng)計(jì)盲區(qū),比如無法監(jiān)控單頁(yè)應(yīng)用,沒有考慮用戶切換Tab、最小化窗口等操作場(chǎng)景。基于上述背景,重新調(diào)研和實(shí)現(xiàn)了精確統(tǒng)計(jì)頁(yè)面停留時(shí)長(zhǎng)的方案,需要 兼容單頁(yè)應(yīng)用和多頁(yè)應(yīng)用,并且不耦合或入侵業(yè)務(wù)代碼。
2、分析
我們可以把一個(gè)頁(yè)面生命周期抽象為三個(gè)動(dòng)作:「進(jìn)入」、「活躍狀態(tài)切換」、「離開」

如下圖,計(jì)算頁(yè)面停留時(shí)長(zhǎng)既如何監(jiān)控這三個(gè)動(dòng)作,然后在對(duì)應(yīng)觸發(fā)的事件中記錄時(shí)間戳,比如要統(tǒng)計(jì)活躍停留時(shí)長(zhǎng)就把 active 區(qū)間相加即可,要統(tǒng)計(jì)總時(shí)長(zhǎng)既 tn -t0 。

2.1 如何監(jiān)聽頁(yè)面的進(jìn)入和離開?
對(duì)于常規(guī)頁(yè)面的 首次加載、頁(yè)面關(guān)閉、刷新 等操作都可以通過 window.onload 和 window.onbeforeunload 事件來監(jiān)聽頁(yè)面進(jìn)入和離開,瀏覽器前進(jìn)后退可以通過 pageshow 和 pagehide 處理。
load / beforeunload
pageshow / pagehide
對(duì)于單頁(yè)應(yīng)用內(nèi)部的跳轉(zhuǎn)可以轉(zhuǎn)化為兩個(gè)問題:
監(jiān)聽路由變化
判斷變化的URL是否為不同頁(yè)面 。
2.1.1 監(jiān)聽路由變化
目前主流的單頁(yè)應(yīng)用大部分都是基于 browserHistory (history api) 或者 hashHistory 來做路由處理,我們可以通過監(jiān)聽路由變化來判斷頁(yè)面是否有可能切換。注意是有可能切換,因?yàn)閁RL發(fā)生變化不代表頁(yè)面一定切換,具體的路由配置是由業(yè)務(wù)決定的(既URL和頁(yè)面的匹配規(guī)則)。
browserHistory
路由的變化本質(zhì)都會(huì)調(diào)用 History.pushState() 或 History.replaceState() ,能監(jiān)聽到這兩個(gè)事件就能知道。通過 popstate 事件能解決一半問題,因?yàn)?popstate 只會(huì)在瀏覽器前進(jìn)后退的時(shí)候觸發(fā),當(dāng)調(diào)用 history.pushState() or history.replaceState() 的時(shí)候并不會(huì)觸發(fā)。
The popstate event is fired when the active history entry changes. If the history entry being activated was created by a call to history.pushState() or was affected by a call to history.replaceState(), the popstate event’s state property contains a copy of the history entry’s state object.
Note that just calling history.pushState() or history.replaceState() won’t trigger apopstateevent. The popstate event will be triggered by doing a browser action such as a click on the back or forward button (or calling。history.back() or history.forward() in JavaScript).
這里需要通過猴子補(bǔ)丁(Monkeypatch)解決,運(yùn)行時(shí)重寫 history.pushState 和 history.replaceState 方法:
let?_wr?=??function?(type)?{??
??let?orig?=?window.history[type]
??return??function?()?{
????let?rv?=?orig.apply(this,?arguments)
????let?e?=?new?Event(type.toLowerCase())
????e.arguments?=?arguments
????window.dispatchEvent(e)
????return?rv
??}}
window.history.pushState?=?_wr('pushState')??
window.history.replaceState?=?_wr('replaceState')
window.addEventListener('pushstate',??function?(event)?{})??
window.addEventListener('replacestate',??function?(event)?{})hashHistory
hashHistory 的實(shí)現(xiàn)是基于 hash 的變化,hash 的變化可以通過 hashchange 來監(jiān)聽
2.1.2 判斷URL是否為不同頁(yè)面
方案1: 客戶端定義
通過業(yè)務(wù)方在初始化的時(shí)候配置頁(yè)面規(guī)則,然后JS通過URL匹配不同的規(guī)則來區(qū)分不同的頁(yè)面,這種方案在客戶端數(shù)據(jù)上報(bào)的時(shí)候就已經(jīng)明確了不同的頁(yè)面,偽代碼:
new?Tracer({??
??rules:?[
????{?path:?'/index'?},
????{?path:?'/detail/:id'?},
????{?path:?'/user',?query:?{tab:?'profile'}?}
??])方案2: 數(shù)據(jù)分析平臺(tái)定義
假設(shè)我們最終上報(bào)后有一個(gè)數(shù)據(jù)分析平臺(tái)來展現(xiàn),我們可以在類似數(shù)據(jù)平臺(tái)來配置頁(yè)面規(guī)則,這樣在客戶端實(shí)現(xiàn)的代碼邏輯就不需要區(qū)分頁(yè)面,而是每次URL發(fā)生變化就將數(shù)據(jù)上報(bào),最終通過數(shù)據(jù)平臺(tái)配置的頁(yè)面URL規(guī)則來求和、過濾數(shù)據(jù)等。
當(dāng)數(shù)據(jù)展現(xiàn)平臺(tái)不支持配置URL規(guī)則來區(qū)分頁(yè)面的時(shí)候,可以采用方案1;當(dāng)有數(shù)據(jù)平臺(tái)支持的時(shí)候采用方案2更合理;
2.1.3 對(duì)于頁(yè)面進(jìn)入和離開相關(guān)事件整理

2.2 如何監(jiān)聽頁(yè)面活躍狀態(tài)切換?
可以通過 Page Visibility API 以及在 window 上聲明 onblur/onfocus 事件來處理。
2.2.1 Page Visibility API
一個(gè)網(wǎng)頁(yè)的可見狀態(tài)可以通過 Page Visibility API 獲取,比如當(dāng)用戶 切換瀏覽器Tab、最小化窗口、電腦睡眠 的時(shí)候,系統(tǒng)API會(huì)派發(fā)一個(gè)當(dāng)前頁(yè)面可見狀態(tài)變化的 visibilitychange 事件,然后在事件綁定函數(shù)中通過 document.hidden 或者 document.visibilityState 讀取當(dāng)前狀態(tài)。
document.addEventListener('visibilitychange',??function?(event)?{??
??console.log(document.hidden,?document.visibilityState)})2.2.2 onblur/onfocus
可以通過 Page Visibility API 以及在 window 上聲明 onblur/onfocus 事件來處理。對(duì)于PC端來說,除了監(jiān)聽上述相關(guān)事件外,還可以考慮監(jiān)聽鼠標(biāo)行為,比如當(dāng)一定時(shí)間內(nèi)鼠標(biāo)沒有操作則認(rèn)為用戶處于非活躍狀態(tài)。
2.3 什么時(shí)機(jī)上報(bào)數(shù)據(jù)?
2.3.1 頁(yè)面離開時(shí)上報(bào)
對(duì)于頁(yè)面刷新或者關(guān)閉窗口觸發(fā)的操作可能會(huì)造成數(shù)據(jù)丟失
2.3.2 下次打開頁(yè)面時(shí)上報(bào)
會(huì)丟失歷史訪問記錄中的最后一個(gè)頁(yè)面數(shù)據(jù)
目前采用的方案2,對(duì)于單頁(yè)內(nèi)部跳轉(zhuǎn)是即時(shí)上報(bào),對(duì)于單頁(yè)/多頁(yè)應(yīng)用觸發(fā) window.onbeforeunload 事件的時(shí)候會(huì)把當(dāng)前頁(yè)面數(shù)據(jù)暫存在 localStorage 中,當(dāng)用戶下次進(jìn)入頁(yè)面的時(shí)候會(huì)把暫存數(shù)據(jù)上報(bào)。有個(gè)細(xì)節(jié)問題,如果用戶下次打開頁(yè)面是在第二天,對(duì)于統(tǒng)計(jì)當(dāng)天的活躍時(shí)長(zhǎng)會(huì)有一定的誤差,所以在數(shù)據(jù)上報(bào)的同時(shí)會(huì)把該條數(shù)據(jù)的頁(yè)面進(jìn)入時(shí)間/離開時(shí)間帶上。
3、設(shè)計(jì)
3.1 UML類關(guān)系圖
Tracer
核心類,用來實(shí)例化一個(gè)監(jiān)控,對(duì)原生事件和自定義事件的封裝,監(jiān)聽 enter activechange exit 事件來操作當(dāng)前 Page 實(shí)例。
P.S. 取名來自暴雪旗下游戲守望先鋒英雄獵空(Tracer),直譯為:追蹤者。
Page
頁(yè)面的抽象類,用來實(shí)例化一個(gè)頁(yè)面,封裝了 enter exit active inactive 等操作,內(nèi)部通過 state 屬性來維護(hù)當(dāng)前頁(yè)面狀態(tài)。

3.2 事件派發(fā)關(guān)系圖

4、兼容性
Desktop

Mobile

5、思考
對(duì)于頁(yè)面停留時(shí)長(zhǎng)的定義可能在不同場(chǎng)景會(huì)有差異,比如內(nèi)部業(yè)務(wù)系統(tǒng)或者OA系統(tǒng),產(chǎn)品可能更關(guān)心用戶在頁(yè)面的活躍時(shí)長(zhǎng);而對(duì)于資訊類型的產(chǎn)品,頁(yè)面可見時(shí)長(zhǎng)會(huì)更有價(jià)值。單一的數(shù)據(jù)對(duì)業(yè)務(wù)分析是有限的,所以在具體的代碼實(shí)過程中我們會(huì)把停留時(shí)長(zhǎng)分三個(gè)指標(biāo),這樣能更好的幫助產(chǎn)品/運(yùn)營(yíng)分析。
active 頁(yè)面活躍時(shí)長(zhǎng)
visible 頁(yè)面可見時(shí)長(zhǎng) //僅支持Desktop
duration 頁(yè)面總停留時(shí)長(zhǎng)
6、參考
https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onhashchange
https://developer.mozilla.org/en-US/docs/Web/Events/popstate
https://developer.mozilla.org/en-US/docs/Web/API/PageVisibilityAPI
https://stackoverflow.com/questions/4570093/how-to-get-notified-about-changes-of-the-history-via-history-pushstate
——————END——————
歡迎關(guān)注“Java引導(dǎo)者”,我們分享最有價(jià)值的Java的干貨文章,助力您成為有思想的Java開發(fā)工程師!
