從一個埋點日誌上報腳本提及

設計和封裝一個前端埋點上報腳本, 並逐步思考優化這個過程。
javascript

主要內容:前端

  • 請求的方式:簡潔(fetch) | 高效(head) | 通用(post)
  • 批量打包上報
  • 無網絡延時上報
  • 更好的pv: visibilitychange
  • 更好的pv: 單頁應用hash監聽

做用:java

  • 統計平臺服務端若只提供上報接口,對於前端如何封裝數據上報能夠借鑑
  • 使用第三方分析平臺的api的話,能夠思考可否優化和封裝
  • 不是規範,側重想法

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}&timestamp=${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}&timestamp=${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}&timestamp=${Date.now()}`, {method: 'get'})
}
複製代碼

考慮到上報數據過程咱們並不關心返回值,只須要知道上報成功與否,咱們能夠用Head請求來更高效地實現咱們的上報過程:跨域

// 高效的方式
export default function analytics (action = 'pageview') {
 fetch(`https://www.baidu.com?action=${action}&timestamp=${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前,先繼續優化下當前代碼,結合前面的過程,咱們能夠考慮優化這幾點:

  1. 上報請求方式應可選:調用形式如analytics.head(單條上報), analytics.post(默認)
  2. 頁面unload時候,採用更好的sendBeacon方式,並向下兼容

關於sendBeacon, 該方法能夠將少許數據異步傳輸到Web服務器。在上述代碼的uploadLog方法中,咱們使用了同步的xhr請求,這樣作是爲了防止頁面因關閉或者切換,腳原本不及執行致使最後的日誌沒法上報。
beforeunload的場景下,同步xhrsendBeacon的特色

  • 同步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}&timestamp=${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)
    }
  }
}
複製代碼

無網絡延時上報

思考一個問題,假如咱們的頁面處於斷網離線狀態(好比就是信號很差),用戶在這期間進行了操做,而咱們又想收集這部分數據會怎樣?

  1. 假如斷網很是短暫,腳本持續執行而且未觸發打包上傳。因爲log仍保留在內存中,繼續執行直到觸發可上傳數量後,網絡已恢復,此時無影響。
  2. 斷網時間較長,中間觸發幾回上報,網絡錯誤會致使上報失敗。以後恢復網絡,後續日誌正常上報,此時丟失了斷網期間數據。
  3. 斷網從某一刻開始持續到用戶主動關閉頁面,期間日誌均沒法上報。

咱們能夠嘗試增長「失敗重傳」的功能,比起網絡不穩定,更多的狀況是某個問題致使的穩定錯誤,重傳不能解決這類問題。設想咱們在客戶端進行數據收集,咱們能夠很方便地記錄到log文件中,因而一樣的考慮,咱們也能夠把數據暫存到localstorage上面,有網環境下再繼續上報,所以解決這個問題的方案咱們能夠概括爲:

  1. 上報數據,navigator.onLine判斷網絡情況
  2. 有網正常發送
  3. 無網絡時記入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),且每次頁面加載完成後都會運行一些統計代碼, 然而這狀況對於尤爲單頁應用存在一些問題

  1. 用戶打開頁面一次,而在接下來的幾天以內使用數百次,可是並無刷新頁面,這種狀況應該只算一個 Page View 麼
  2. 若是兩個用戶天天訪問頁面次數徹底相同,可是其中一個每次刷新,而另外一個保持頁面在後臺運行,這兩種使用模式的 Page View 統計結果應該有很大的不一樣麼
  3. ···

爲了遵循更好的PV,咱們能夠在腳本增長下列狀況的處理:

  1. 頁面加載時,若是頁面的 visibilityState 是可見的,發送 Page View 統計;
  2. 頁面加載時, 若是頁面的 visibilityState 是隱藏的,就監聽 visibilitychange 事件,並在 visibilityState 變爲可見時發送 Page View 統計;
  3. 若是 visibilityState 由隱藏變爲可見,而且自上次用戶交互以後已通過了「足夠長」的時間,就發送新的 Page View 統計;
  4. 若是 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,加了點調用測試 考慮到不一樣業務場景,咱們還有有更多空間能夠填補,數據閉環其實也是爲了更好的業務分析服務,雖然是一個傳統功能,但值得細細考究的點仍是挺多的吧

相關文章
相關標籤/搜索