從一個埋點日誌上報指令碼說起

愛學習的kimmy發表於2019-03-07

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

主要內容:

  • 請求的方式:簡潔(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}&timestamp=${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}&timestamp=${Date.now()}`
}
複製程式碼

用img傳送請求的方法英文術語叫:image beacon
主要應用於只需要向伺服器傳送日誌資料的場合,且無需伺服器有訊息體迴應。比如收集訪問者的統計資訊。
這樣做和ajax請求的區別在於:
1.只能是get請求,因此可傳送的資料量有限。
2.只關心資料是否傳送到伺服器,伺服器不需要做出訊息體響應。並且一般客戶端也不需要做出響應。
3.實現了跨域

或者我們直接用新標準fetch方式上傳

// 簡潔的方式
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,加了點呼叫測試
考慮到不同業務場景,我們還有有更多空間可以填補,資料閉環其實也是為了更好的業務分析服務,雖然是一個傳統功能,但值得細細考究的點還是挺多的吧

相關文章