設計和封裝一個前端埋點上報腳本, 並逐步思考優化這個過程。
javascript
主要內容:前端
做用:java
final code:analytics.jsgit
咱們先用最直接的方式來實現這個埋點上報腳本。
建立文件並命名爲 analytics.js, 在腳本里面添加一個請求,稍微包一下:github
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長度限制內,那咱們能夠用簡化下這個請求:ajax
// 簡潔的方式
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.實現了跨域chrome
或者咱們直接用新標準fetch
方式上傳api
// 簡潔的方式
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
(默認)關於sendBeacon
, 該方法能夠將少許數據異步傳輸到Web服務器。在上述代碼的uploadLog
方法中,咱們使用了同步的xhr請求,這樣作是爲了防止頁面因關閉或者切換,腳原本不及執行致使最後的日誌沒法上報。
beforeunload的場景下,同步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文件中,因而一樣的考慮,咱們也能夠把數據暫存到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是日誌上報中很重要的一環。
目前爲止咱們基本實現完上報了,如今再回歸到業務層面。pv的目的是什麼,以及怎樣更好得達到咱們的目的? 推薦先閱讀這篇關於pv的文章:
爲何說你的pv統計是錯的
在大多數狀況下,咱們的pv上報假設每次頁面瀏覽(Page View)對應一次頁面加載(Page Load),且每次頁面加載完成後都會運行一些統計代碼, 然而這狀況對於尤爲單頁應用存在一些問題
爲了遵循更好的PV,咱們能夠在腳本增長下列狀況的處理:
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()
}
})
···
複製代碼
考慮咱們是一個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,加了點調用測試 考慮到不一樣業務場景,咱們還有有更多空間能夠填補,數據閉環其實也是爲了更好的業務分析服務,雖然是一個傳統功能,但值得細細考究的點仍是挺多的吧