設計和封裝一個前端埋點上報指令碼, 並逐步思考優化這個過程。
主要內容:
- 請求的方式:簡潔(fetch) | 高效(head) | 通用(post)
- 批量打包上報
- 無網路延時上報
- 更好的pv: visibilitychange
- 更好的pv: 單頁應用hash監聽
作用:
- 統計平臺服務端若只提供上報介面,對於前端如何封裝資料上報可以借鑑
- 使用第三方分析平臺的api的話,可以思考能否優化和封裝
- 不是規範,側重想法
final code:analytics.js
請求的方式:簡潔|高效|通用
我們先用最直接的方式來實現這個埋點上報指令碼。
建立檔案並命名為 analytics.js, 在指令碼里面新增一個請求,稍微包一下:
export default function analytics (action = 'pageview') {
var xhr = new XMLHttpRequest()
let uploadUrl = `https://xxx/test_upload?action=${action}×tamp=${Date.now()}`
xhr.open('GET', uploadUrl, true)
xhr.send()
}
複製程式碼
這樣子就能通過呼叫analytics()
,往我們的統計服務端提交一條訊息,並指明一個行為型別。
如果我們需要上報的資料確實不多,如只需要‘行為/事件’,‘時間’,‘使用者(id)’,‘平臺環境’等,並且資料量在瀏覽器支援的url長度限制內,那我們可以用簡化下這個請求:
// 簡潔的方式
export default function analytics (action = 'pageview') {
(new Image()).src = `https://xxx/test_upload?action=${action}×tamp=${Date.now()}`
}
複製程式碼
用img傳送請求的方法英文術語叫:image beacon
主要應用於只需要向伺服器傳送日誌資料的場合,且無需伺服器有訊息體迴應。比如收集訪問者的統計資訊。
這樣做和ajax請求的區別在於:
1.只能是get請求,因此可傳送的資料量有限。
2.只關心資料是否傳送到伺服器,伺服器不需要做出訊息體響應。並且一般客戶端也不需要做出響應。
3.實現了跨域
或者我們直接用新標準fetch
方式上傳
// 簡潔的方式
export default function analytics (action = 'pageview') {
fetch(`https://www.baidu.com?action=${action}×tamp=${Date.now()}`, {method: 'get'})
}
複製程式碼
考慮到上報資料過程我們並不關心返回值,只需要知道上報成功與否,我們可以用Head請求來更高效地實現我們的上報過程:
// 高效的方式
export default function analytics (action = 'pageview') {
fetch(`https://www.baidu.com?action=${action}×tamp=${Date.now()}`, {method: 'head'})
}
複製程式碼
head
請求方式和引數傳遞方式與get
請求一致,也會受限於瀏覽器,但因為其不需要返回響應實體,其效率要比get方式高得多。單上述示例的簡單請求在chrome下表現大概就有20ms的優化。
如果要上傳的資料確實比較多,拼接引數後的url長度超出了瀏覽器的限制,導致請求失敗。則我們採取post的方式:
// 通用的方式 (可以採用fetch, 但fetch預設不帶cookie, 可能有認證問題)
export default function analytics (action = 'pageview', params) {
let xhr = new XMLHttpRequest()
let data = new FormData()
data.append('action', action)
for (let obj in params) {
data.append(obj, params[obj])
}
xhr.open('POST', 'https://xxx/test_upload')
xhr.send(data)
}
複製程式碼
批量打包上報
無論單個埋點的資料量多少,現在假設頁面為了做使用者行為分析,有多處埋點,頻繁上報可能對使用者正常功能的訪問有一定影響。
解決這個問題最直接思路就是減少上報的請求數。因此我們來實現一個批量上傳的feature,一個簡單的思路是每收集完10條資料就打包上報:
// 每10條資料資料進行打包
let logs = []
/**
* @params {array} 日誌陣列
*/
function upload (logs) {
console.log('send logs', logs)
let xhr = new XMLHttpRequest()
let data = new FormData()
data.append('logs', logs)
xhr.open('POST', this.url)
xhr.send(data)
}
export default function analytics (action = 'pageview', params) {
logs.push(Object.assign({
action,
timeStamp: Date.now()
}, params))
if (logs.length >= 10) {
upload(logs)
logs = []
}
}
複製程式碼
在埋點的位置,我們先執行個幾十次看看
import analy from '@/vendor/analytics1.js'
for (let i = 33; i--;) {
analy1('pv')
}
複製程式碼
ok, 正常的話應該上報成功了,並且每條請求都包含了10個資料。
但問題很快也暴露了,這種湊夠N條資料再統一傳送的行為會出現斷層,如果在沒有湊夠N條資料的時候使用者就關掉頁面,或者是超過N倍數但湊不到N的那部分,如果不處理的話這部分資料就丟失了。
一種直接的解決方案是監聽頁面beforeunload
事件,在頁面離開前把剩餘不足N條的log全部上傳。因此,我們新增一個beforeunload事件,順便整理下程式碼,將其封裝成一個類:
export default class eventTrack {
constructor (option) {
this.option = Object.assign({
url: 'https://www.baidu.com',
maxLogNum: 10
}, option)
this.url = this.option.url
this.maxLogNum = this.option.maxLogNum
this.logs = []
// 監聽unload事件,
window.addEventListener('beforeunload', this.uploadLog.bind(this), false)
}
/**
* 收集日誌,集滿 maxLogNum 後上傳
* @param {string} 埋點行為
* @param {object} 埋點附帶資料
*/
analytics (action = 'pageview', params) {
this.logs.push(Object.assign({
action,
timeStamp: Date.now()
}, params))
if (this.logs.length >= this.maxLogNum) {
this.send(this.logs)
this.logs = []
}
}
// 上報一個日誌陣列
send (logs, sync) {
let xhr = new XMLHttpRequest()
let data = new FormData()
for (var i = logs.length; i--;) {
data.append('logs', JSON.stringify(logs[i]))
}
xhr.open('POST', this.url, !sync)
xhr.send(data)
}
// 使用同步的xhr請求
uploadLog () {
this.send(this.logs, true)
}
}
複製程式碼
目前為止我們初步實現了功能,在進一步新增feature前,先繼續優化下當前程式碼,結合前面的過程,我們可以考慮優化這幾點:
- 上報請求方式應可選:呼叫形式如
analytics.head
(單條上報),analytics.post
(預設) - 頁面unload時候,採用更好的sendBeacon方式,並向下相容
關於sendBeacon
, 該方法可以將少量資料非同步傳輸到Web伺服器。在上述程式碼的uploadLog
方法中,我們使用了同步的xhr請求,這樣做是為了防止頁面因關閉或者切換,指令碼來不及執行導致最後的日誌無法上報。
beforeunload的場景下,同步xhr
和sendBeacon
的特點
- 同步xhr: 離開頁面時阻塞一會指令碼,確保日誌發出
- sendBeacon: 離開頁面時發起非同步請求,不阻塞並確保日誌發出。有瀏覽器相容問題
值得一提的是,單頁應用中,路由的切換並不會對漏報造成太大影響,只要確保上報指令碼是掛載到全域性,並處理好頁面關閉和跳轉到其他域名的情況就好。
總之,根據這兩點優化,我們在增加新功能前再完善下程式碼:
export default class eventTrack {
constructor (option) {
this.option = Object.assign({
url: 'https://www.baidu.com',
maxLogNum: 10
}, option)
this.url = this.option.url
this.maxLogNum = this.option.maxLogNum
this.logs = []
// 擴充analytics,允許單個上報
this.analytics['head'] = (action, params) => {
return this.sendByHead(action, params)
}
this.analytics['post'] = (action, params) => {
return this.sendByPost(action, params)
}
// 監聽unload事件,
window.addEventListener('beforeunload', this.unloadHandler.bind(this), false)
}
/**
* 收集日誌,集滿 maxLogNum 後上傳
* @param {string} 埋點行為
* @param {object} 埋點附帶資料
*/
analytics (action = 'pageview', params) {
this.logs.push(JSON.stringify(Object.assign({
action,
timeStamp: Date.now()
}, params)))
if (this.logs.length >= this.maxLogNum) {
this.sendInPack(this.logs)
this.logs = []
}
}
/**
* 批量上報一個日誌陣列
* @param {array} logs 日誌陣列
* @param {boolean} sync 是否同步
*/
sendInPack (logs, sync) {
let xhr = new XMLHttpRequest()
let data = new FormData()
for (var i = logs.length; i--;) {
data.append('logs', logs[i])
}
xhr.open('POST', this.url, !sync)
xhr.send(data)
}
/**
* POST上報單個日誌
* @param {string} 埋點型別事件
* @param {object} 埋點附加引數
*/
sendByPost (action, params) {
let xhr = new XMLHttpRequest()
let data = new FormData()
data.append('action', action)
for (let obj in params) {
data.append(obj, params[obj])
}
xhr.open('POST', this.url)
xhr.send(data)
}
/**
* Head上報單個日誌
* @param {string} 埋點型別事件
* @param {object} 埋點附加引數
*/
sendByHead (action, params) {
let str = ''
for (let key in params) {
str += `&${key}=${params[key]}`
}
fetch(`https://www.baidu.com?action=${action}×tamp=${Date.now()}${str}`, {method: 'head'})
}
/**
* unload事件觸發時,執行的上報事件
*/
unloadHandler () {
if (navigator.sendBeacon) {
let data = new FormData()
for (var i = this.logs.length; i--;) {
data.append('logs', this.logs[i])
}
navigator.sendBeacon(this.url, data)
} else {
this.sendInPack(this.logs, true)
}
}
}
複製程式碼
無網路延時上報
思考一個問題,假如我們的頁面處於斷網離線狀態(比如就是訊號不好),使用者在這期間進行了操作,而我們又想收集這部分資料會怎樣?
- 假如斷網非常短暫,指令碼持續執行並且未觸發打包上傳。由於log仍保留在記憶體中,繼續執行直到觸發可上傳數量後,網路已恢復,此時無影響。
- 斷網時間較長,中間觸發幾次上報,網路錯誤會導致上報失敗。之後恢復網路,後續日誌正常上報,此時丟失了斷網期間資料。
- 斷網從某一刻開始持續到使用者主動關閉頁面,期間日誌均無法上報。
我們可以嘗試增加“失敗重傳”的功能,比起網路不穩定,更多的情況是某個問題導致的穩定錯誤,重傳不能解決這類問題。設想我們在客戶端進行資料收集,我們可以很方便地記錄到log檔案中,於是同樣的考慮,我們也可以把資料暫存到localstorage上面,有網環境下再繼續上報,因此解決這個問題的方案我們可以歸納為:
- 上報資料,
navigator.onLine
判斷網路狀況 - 有網正常傳送
- 無網路時記入
localstorage
, 延時上報
我們修改下sendInPack
, 並增加對應方法
sendInPack (logs, sync) {
if (navigator.onLine) {
this.sendMultiData(logs, sync)
this.sendStorageData()
} else {
this.storageData(logs)
}
}
sendMultiData (logs, sync) {
console.log('sendMultiData', logs)
let xhr = new XMLHttpRequest()
let data = new FormData()
for (var i = logs.length; i--;) {
data.append('logs', logs[i])
}
xhr.open('POST', this.url, !sync)
xhr.send(data)
}
storageData (logs) {
console.log('storageData', logs)
let data = JSON.stringify(logs)
let before = localStorage['analytics_logs']
if (before) {
data = before.replace(']', ',') + data.replace('[', '')
}
localStorage.setItem('analytics_logs', data)
}
sendStorageData () {
let data = localStorage['analytics_logs']
if (!data) return
data = JSON.parse(data)
this.sendMultiData(data)
localStorage['analytics_logs'] = ''
}
複製程式碼
注意
navigator.onLine
在不同瀏覽器開發環境下的問題,比如chrome下localhost訪問時候,navigator.onLine值總為false, 改用127.0.0.1則正常返回值
更好的pv: visibilitychange
PV是日誌上報中很重要的一環。
目前為止我們基本實現完上報了,現在再回歸到業務層面。pv的目的是什麼,以及怎樣更好得達到我們的目的?
推薦先閱讀這篇關於pv的文章:
為什麼說你的pv統計是錯的
在大多數情況下,我們的pv上報假設每次頁面瀏覽(Page View)對應一次頁面載入(Page Load),且每次頁面載入完成後都會執行一些統計程式碼, 然而這情況對於尤其單頁應用存在一些問題
- 使用者開啟頁面一次,而在接下來的幾天之內使用數百次,但是並沒有重新整理頁面,這種情況應該只算一個 Page View 麼
- 如果兩個使用者每天訪問頁面次數完全相同,但是其中一個每次重新整理,而另一個保持頁面在後臺執行,這兩種使用模式的 Page View 統計結果應該有很大的不同麼
- ···
為了遵循更好的PV,我們可以在指令碼增加下列情況的處理:
- 頁面載入時,如果頁面的 visibilityState 是可見的,傳送 Page View 統計;
- 頁面載入時, 如果頁面的 visibilityState 是隱藏的,就監聽 visibilitychange 事件,並在 visibilityState 變為可見時傳送 Page View 統計;
- 如果 visibilityState 由隱藏變為可見,並且自上次使用者互動之後已經過了“足夠長”的時間,就傳送新的 Page View 統計;
- 如果 URL 發生變化(僅限於 pathname 或 search 部分傳送變化, hash 部分則應該忽略,因為它是用來標記頁面內跳轉的) 傳送新的 Page View 統計; 在我們的建構函式中增加以下片段:
this.option = Object.assign({
url: 'https://baidu.com/api/test',
maxLogNum: 10,
stayTime: 2000, // ms, 頁面由隱藏變為可見,並且自上次使用者互動之後足夠久,可以視為新pv的時間間隔
timeout: 6000 // 頁面切換間隔,小於多少ms不算間隔
}, option)
this.hiddenTime = Date.now()
···
// 監聽頁面可見性
document.addEventListener('visibilitychange', () => {
console.log(document.visibilityState, Date.now(), this.hiddenTime)
if (document.visibilityState === 'visible' && (Date.now() - this.hiddenTime > this.option.stayTime)) {
this.analytics('re-open')
console.log('send pv visible')
} else if (document.visibilityState === 'hidden') {
this.hiddenTime = Date.now()
}
})
···
複製程式碼
更好的pv: hash跳轉
考慮我們是一個hash模式的單頁應用,即路由跳轉以 ‘#’加路由結尾標識。 如果我們想對每個路由切換進行追蹤,一種做法是在每個路由元件的進行監聽,也可以在上報檔案中直接統一處理:
window.addEventListener('hashchange', () => {
this.analytics()
})
複製程式碼
但這樣子有個問題,如何判別當前hash跳轉是個有效跳轉。比如頁面存在重定向邏輯,使用者從A頁面進入(棄用頁面),我們程式碼把它跳轉到B頁面,這樣pv發出去了兩次,而實際有效的瀏覽只是B頁面一次。又或者使用者只是匆匆看了A頁面一眼,又跳轉到B頁面,A頁面要不要作為一次有效PV?
一種更好的方式是設定有效間隔,比如小於5s的瀏覽不作為一個有效pv,那由此而生的邏輯,我們需要調整我們的 analytics
方法:
// 封裝一個sendPV 專門用來傳送pv
constructor (option) {
···
this.sendPV = this.delay((args) => {
this.analytics({action: 'pageview', ...args})
})
window.addEventListener('hashchange', () => {
this.sendPV()
})
this.sendPV()
···
}
delay (func, time) {
let t = 0
let self = this
return function (...args) {
clearTimeout(t)
t = setTimeout(func.bind(this, args), time || self.option.timeout)
}
}
複製程式碼
ok, 到這裡就差不多了,完整示意在這裡 analytics.js,加了點呼叫測試
考慮到不同業務場景,我們還有有更多空間可以填補,資料閉環其實也是為了更好的業務分析服務,雖然是一個傳統功能,但值得細細考究的點還是挺多的吧