設計和封裝一個前端埋點上報腳本级遭, 并逐步思考優(yōu)化這個過程肩碟。
主要內容:
- 請求的方式:簡潔(fetch) | 高效(head) | 通用(post)
- 批量打包上報
- 無網(wǎng)絡延時上報
- 更好的pv: visibilitychange
- 更好的pv: 單頁應用hash監(jiān)聽
作用:
- 統(tǒng)計平臺服務端若只提供上報接口刻伊,對于前端如何封裝數(shù)據(jù)上報可以借鑒
- 使用第三方分析平臺的api的話,可以思考能否優(yōu)化和封裝
- 不是規(guī)范蹂午,側重想法
final code:analytics.js
請求的方式:簡潔|高效|通用
我們先用最直接的方式來實現(xiàn)這個埋點上報腳本愿汰。
創(chuàng)建文件并命名為 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()
阴汇,往我們的統(tǒng)計服務端提交一條消息数冬,并指明一個行為類型。
如果我們需要上報的數(shù)據(jù)確實不多,如只需要‘行為/事件’拐纱,‘時間’铜异,‘用戶(id)’,‘平臺環(huán)境’等,并且數(shù)據(jù)量在瀏覽器支持的url長度限制內秸架,那我們可以用簡化下這個請求:
// 簡潔的方式
export default function analytics (action = 'pageview') {
(new Image()).src = `https://xxx/test_upload?action=${action}×tamp=${Date.now()}`
}
用img發(fā)送請求的方法英文術語叫:image beacon
主要應用于只需要向服務器發(fā)送日志數(shù)據(jù)的場合揍庄,且無需服務器有消息體回應。比如收集訪問者的統(tǒng)計信息东抹。
這樣做和ajax請求的區(qū)別在于:
1.只能是get請求蚂子,因此可發(fā)送的數(shù)據(jù)量有限。
2.只關心數(shù)據(jù)是否發(fā)送到服務器缭黔,服務器不需要做出消息體響應食茎。并且一般客戶端也不需要做出響應。
3.實現(xiàn)了跨域
或者我們直接用新標準fetch
方式上傳
// 簡潔的方式
export default function analytics (action = 'pageview') {
fetch(`https://www.baidu.com?action=${action}×tamp=${Date.now()}`, {method: 'get'})
}
考慮到上報數(shù)據(jù)過程我們并不關心返回值馏谨,只需要知道上報成功與否别渔,我們可以用Head請求來更高效地實現(xiàn)我們的上報過程:
// 高效的方式
export default function analytics (action = 'pageview') {
fetch(`https://www.baidu.com?action=${action}×tamp=${Date.now()}`, {method: 'head'})
}
head
請求方式和參數(shù)傳遞方式與get
請求一致,也會受限于瀏覽器惧互,但因為其不需要返回響應實體哎媚,其效率要比get方式高得多。單上述示例的簡單請求在chrome下表現(xiàn)大概就有20ms的優(yōu)化喊儡。
如果要上傳的數(shù)據(jù)確實比較多拨与,拼接參數(shù)后的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)
}
批量打包上報
無論單個埋點的數(shù)據(jù)量多少买喧,現(xiàn)在假設頁面為了做用戶行為分析,有多處埋點匆赃,頻繁上報可能對用戶正常功能的訪問有一定影響岗喉。
解決這個問題最直接思路就是減少上報的請求數(shù)。因此我們來實現(xiàn)一個批量上傳的feature炸庞,一個簡單的思路是每收集完10條數(shù)據(jù)就打包上報:
// 每10條數(shù)據(jù)數(shù)據(jù)進行打包
let logs = []
/**
* @params {array} 日志數(shù)組
*/
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 = []
}
}
在埋點的位置,我們先執(zhí)行個幾十次看看
import analy from '@/vendor/analytics1.js'
for (let i = 33; i--;) {
analy1('pv')
}
ok, 正常的話應該上報成功了荚斯,并且每條請求都包含了10個數(shù)據(jù)埠居。
但問題很快也暴露了,這種湊夠N條數(shù)據(jù)再統(tǒng)一發(fā)送的行為會出現(xiàn)斷層事期,如果在沒有湊夠N條數(shù)據(jù)的時候用戶就關掉頁面滥壕,或者是超過N倍數(shù)但湊不到N的那部分,如果不處理的話這部分數(shù)據(jù)就丟失了兽泣。
一種直接的解決方案是監(jiā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 = []
// 監(jiān)聽unload事件,
window.addEventListener('beforeunload', this.uploadLog.bind(this), false)
}
/**
* 收集日志冈止,集滿 maxLogNum 后上傳
* @param {string} 埋點行為
* @param {object} 埋點附帶數(shù)據(jù)
*/
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 = []
}
}
// 上報一個日志數(shù)組
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)
}
}
目前為止我們初步實現(xiàn)了功能狂票,在進一步新增feature前,先繼續(xù)優(yōu)化下當前代碼熙暴,結合前面的過程闺属,我們可以考慮優(yōu)化這幾點:
- 上報請求方式應可選:調用形式如
analytics.head
(單條上報),analytics.post
(默認) - 頁面unload時候,采用更好的sendBeacon方式周霉,并向下兼容
關于sendBeacon
, 該方法可以將少量數(shù)據(jù)異步傳輸?shù)絎eb服務器掂器。在上述代碼的uploadLog
方法中,我們使用了同步的xhr請求俱箱,這樣做是為了防止頁面因關閉或者切換国瓮,腳本來不及執(zhí)行導致最后的日志無法上報。
beforeunload的場景下匠楚,同步xhr
和sendBeacon
的特點
- 同步xhr: 離開頁面時阻塞一會腳本巍膘,確保日志發(fā)出
- sendBeacon: 離開頁面時發(fā)起異步請求,不阻塞并確保日志發(fā)出芋簿。有瀏覽器兼容問題
值得一提的是峡懈,單頁應用中,路由的切換并不會對漏報造成太大影響与斤,只要確保上報腳本是掛載到全局肪康,并處理好頁面關閉和跳轉到其他域名的情況就好。
總之撩穿,根據(jù)這兩點優(yōu)化磷支,我們在增加新功能前再完善下代碼:
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)
}
// 監(jiān)聽unload事件食寡,
window.addEventListener('beforeunload', this.unloadHandler.bind(this), false)
}
/**
* 收集日志雾狈,集滿 maxLogNum 后上傳
* @param {string} 埋點行為
* @param {object} 埋點附帶數(shù)據(jù)
*/
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 = []
}
}
/**
* 批量上報一個日志數(shù)組
* @param {array} logs 日志數(shù)組
* @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} 埋點附加參數(shù)
*/
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} 埋點附加參數(shù)
*/
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事件觸發(fā)時,執(zhí)行的上報事件
*/
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)
}
}
}
無網(wǎng)絡延時上報
思考一個問題抵皱,假如我們的頁面處于斷網(wǎng)離線狀態(tài)(比如就是信號不好)善榛,用戶在這期間進行了操作,而我們又想收集這部分數(shù)據(jù)會怎樣呻畸?
- 假如斷網(wǎng)非常短暫移盆,腳本持續(xù)執(zhí)行并且未觸發(fā)打包上傳。由于log仍保留在內存中伤为,繼續(xù)執(zhí)行直到觸發(fā)可上傳數(shù)量后咒循,網(wǎng)絡已恢復,此時無影響。
- 斷網(wǎng)時間較長叙甸,中間觸發(fā)幾次上報颖医,網(wǎng)絡錯誤會導致上報失敗。之后恢復網(wǎng)絡蚁署,后續(xù)日志正常上報便脊,此時丟失了斷網(wǎng)期間數(shù)據(jù)。
- 斷網(wǎng)從某一刻開始持續(xù)到用戶主動關閉頁面光戈,期間日志均無法上報哪痰。
我們可以嘗試增加“失敗重傳”的功能,比起網(wǎng)絡不穩(wěn)定久妆,更多的情況是某個問題導致的穩(wěn)定錯誤晌杰,重傳不能解決這類問題。設想我們在客戶端進行數(shù)據(jù)收集筷弦,我們可以很方便地記錄到log文件中肋演,于是同樣的考慮,我們也可以把數(shù)據(jù)暫存到localstorage上面烂琴,有網(wǎng)環(huán)境下再繼續(xù)上報多望,因此解決這個問題的方案我們可以歸納為:
- 上報數(shù)據(jù)菊匿,
navigator.onLine
判斷網(wǎng)絡狀況 - 有網(wǎng)正常發(fā)送
- 無網(wǎng)絡時記入
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
在不同瀏覽器開發(fā)環(huán)境下的問題,比如chrome下localhost訪問時候,navigator.onLine值總為false掰吕, 改用127.0.0.1則正常返回值
更好的pv: visibilitychange
PV是日志上報中很重要的一環(huán)杖们。
目前為止我們基本實現(xiàn)完上報了画饥,現(xiàn)在再回歸到業(yè)務層面蚂斤。pv的目的是什么,以及怎樣更好得達到我們的目的畔派?
推薦先閱讀這篇關于pv的文章:
為什么說你的pv統(tǒng)計是錯的
在大多數(shù)情況下铅碍,我們的pv上報假設每次頁面瀏覽(Page View)對應一次頁面加載(Page Load),且每次頁面加載完成后都會運行一些統(tǒng)計代碼, 然而這情況對于尤其單頁應用存在一些問題
- 用戶打開頁面一次线椰,而在接下來的幾天之內使用數(shù)百次胞谈,但是并沒有刷新頁面,這種情況應該只算一個 Page View 么
- 如果兩個用戶每天訪問頁面次數(shù)完全相同憨愉,但是其中一個每次刷新呜魄,而另一個保持頁面在后臺運行,這兩種使用模式的 Page View 統(tǒng)計結果應該有很大的不同么
- ···
為了遵循更好的PV莱衩,我們可以在腳本增加下列情況的處理:
- 頁面加載時,如果頁面的 visibilityState 是可見的娇澎,發(fā)送 Page View 統(tǒng)計笨蚁;
- 頁面加載時, 如果頁面的 visibilityState 是隱藏的,就監(jiān)聽 visibilitychange 事件,并在 visibilityState 變?yōu)榭梢姇r發(fā)送 Page View 統(tǒng)計括细;
- 如果 visibilityState 由隱藏變?yōu)榭梢娢焙埽⑶易陨洗斡脩艚换ブ笠呀?jīng)過了“足夠長”的時間,就發(fā)送新的 Page View 統(tǒng)計奋单;
- 如果 URL 發(fā)生變化(僅限于 pathname 或 search 部分發(fā)送變化, hash 部分則應該忽略锉试,因為它是用來標記頁面內跳轉的) 發(fā)送新的 Page View 統(tǒng)計;
在我們的構造函數(shù)中增加以下片段:
this.option = Object.assign({
url: 'https://api.djigo.com/api/test',
maxLogNum: 10,
stayTime: 2000, // ms, 頁面由隱藏變?yōu)榭梢娎辣簦⑶易陨洗斡脩艚换ブ笞銐蚓么舾牵梢砸暈樾聀v的時間間隔
timeout: 6000 // 頁面切換間隔,小于多少ms不算間隔
}, option)
this.hiddenTime = Date.now()
···
// 監(jiān)聽頁面可見性
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模式的單頁應用贷笛,即路由跳轉以 ‘#’加路由結尾標識应又。
如果我們想對每個路由切換進行追蹤,一種做法是在每個路由組件的進行監(jiān)聽乏苦,也可以在上報文件中直接統(tǒng)一處理:
window.addEventListener('hashchange', () => {
this.analytics()
})
但這樣子有個問題株扛,如何判別當前hash跳轉是個有效跳轉。比如頁面存在重定向邏輯汇荐,用戶從A頁面進入(棄用頁面)洞就,我們代碼把它跳轉到B頁面,這樣pv發(fā)出去了兩次掀淘,而實際有效的瀏覽只是B頁面一次旬蟋。又或者用戶只是匆匆看了A頁面一眼,又跳轉到B頁面繁疤,A頁面要不要作為一次有效PV?
一種更好的方式是設置有效間隔咖为,比如小于5s的瀏覽不作為一個有效pv,那由此而生的邏輯稠腊,我們需要調整我們的 analytics
方法:
// 封裝一個sendPV 專門用來發(fā)送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,加了點調用測試
考慮到不同業(yè)務場景架忌,我們還有有更多空間可以填補吞彤,數(shù)據(jù)閉環(huán)其實也是為了更好的業(yè)務分析服務,雖然是一個傳統(tǒng)功能叹放,但值得細細考究的點還是挺多的吧