前端性能監控解決方案介紹

綜述:後端異常監控方案很多,前端卻不多,獲取頁面在大量用戶主機上的性能表現爲系統的性能優化指明瞭方向,是一個非常重要的知識點,主要實現思路是通過瀏覽器爲我們提供的performance API來獲取各個部分的加載時間,找到系統的薄弱環節

爲什麼監控

用(上)戶(帝)說,這個頁面怎麼這麼慢,還有沒有人管了?!

簡單而言,有三點原因:

  • 關注性能是工程師的本性 + 本分;
  • 頁面性能對用戶體驗而言十分關鍵。每次重構對頁面性能的提升,僅靠工程師開發設備的測試數據是沒有說服力的,需要有大量的真實數據用於驗證;
  • 資源掛了、加載出現異常,不能總靠用戶投訴才後知後覺,需要主動報警。

一次性能重構,在千兆網速和萬元設備的條件下,頁面加載時間的提升可能只有 0.1%,但是這樣的數(土)據(豪)不具備代表性。網絡環境、硬件設備千差萬別,對於中低端設備而言,性能提升的主觀體驗更爲明顯,對應的數據變化更具備代表性。

不少項目都會把資源上傳到 CDN。而 CDN 部分節點出現問題的時候,一般不能精準的告知“某某,你的 xx 資源掛了”,因此需要我們主動監控。

根據谷歌數據顯示,當頁面加載超過 10s 時,用戶會感到絕望,通常會離開當前頁面,並且很可能不再回來。

用什麼監控

關於前端性能指標,W3C 定義了強大的 Performance API,其中又包括了 High Resolution TimeFrame TimingNavigation TimingPerformance TimelineResource TimingUser Timing 等諸多具體標準。

本文主要涉及 Navigation Timing 以及 Resource Timing。截至到 2018 年中旬,各大主流瀏覽器均已完成了基礎實現。

Navigation Timing Support

 

 

Resource Timing Support

 

Performance API 功能衆多,其中一項,就是將頁面自身以及頁面中各個資源的性能表現(時間細節)記錄了下來。而我們要做的就是查詢和使用。

讀者可以直接在瀏覽器控制檯中輸入 performance ,查看相關 API。

接下來,我們將使用瀏覽器提供的 window.performance 對象(Performance API 的具體實現),來實現一個簡易的前端性能監控工具。

5 分鐘擼一個前端性能監控工具

第一行代碼

將工具命名爲 pMonitor,含義是 performance monitor

const pMonitor = {}

監控哪些指標

既然是“5 分鐘實現一個 xxx”系列,那麼就要有取捨。因此,本文只挑選了最爲重要的兩個指標進行監控:

  • 頁面加載時間
  • 資源請求時間

頁面加載

有關頁面加載的性能指標,可以在 Navigation Timing 中找到。Navigation Timing 包括了從請求頁面起,到頁面完成加載爲止,各個環節的時間明細。

可以通過以下方式獲取 Navigation Timing 的具體內容:

const navTimes = performance.getEntriesByType('navigation')

getEntriesByType 是我們獲取性能數據的一種方式。performance 還提供了 getEntries 以及 getEntriesByName 等其他方式,由於“時間限制”,具體區別不在此贅述,各位看官可以移步到此:www.w3.org/TR/performa…

返回結果是一個數組,其中的元素結構如下所示:

{
  "connectEnd": 64.15495765894057,
  "connectStart": 64.15495765894057,
  "domainLookupEnd": 64.15495765894057,
  "domainLookupStart": 64.15495765894057,
  "domComplete": 2002.5385066728431,
  "domContentLoadedEventEnd": 2001.7384263440083,
  "domContentLoadedEventStart": 2001.2386167400286,
  "domInteractive": 1988.638474368076,
  "domLoading": 271.75174283737226,
  "duration": 2002.9385468372606,
  "entryType": "navigation",
  "fetchStart": 64.15495765894057,
  "loadEventEnd": 2002.9385468372606,
  "loadEventStart": 2002.7383663540235,
  "name": "document",
  "navigationStart": 0,
  "redirectCount": 0,
  "redirectEnd": 0,
  "redirectStart": 0,
  "requestStart": 65.28225608537441,
  "responseEnd": 1988.283025689508,
  "responseStart": 271.75174283737226,
  "startTime": 0,
  "type": "navigate",
  "unloadEventEnd": 0,
  "unloadEventStart": 0,
  "workerStart": 0.9636893776343863
}

關於各個字段的時間含義,Navigation Timing Level 2 給出了詳細說明:

 

不難看出,細節滿滿。因此,能夠計算的內容十分豐富,例如 DNS 查詢時間,TLS 握手時間等等。可以說,只有想不到,沒有做不到~

既然我們關注的是頁面加載,那自然要讀取 domComplete:

const [{ domComplete }] = performance.getEntriesByType('navigation')
複製代碼

定義個方法,獲取 domComplete

pMonitor.getLoadTime = () => {
  const [{ domComplete }] = performance.getEntriesByType('navigation')
  return domComplete
}

到此,我們獲得了準確的頁面加載時間。

資源加載

既然頁面有對應的 Navigation Timing,那靜態資源是不是也有對應的 Timing 呢?

答案是肯定的,其名爲 Resource Timing。它包含了頁面中各個資源從發送請求起,到完成加載爲止,各個環節的時間細節,和 Navigation Timing 十分類似。

獲取資源加載時間的關鍵字爲 'resource', 具體方式如下:

performance.getEntriesByType('resource')

不難聯想,返回結果通常是一個很長的數組,因爲包含了頁面上所有資源的加載信息。

每條信息的具體結構爲:

{
  "connectEnd": 462.95008929525244,
  "connectStart": 462.95008929525244,
  "domainLookupEnd": 462.95008929525244,
  "domainLookupStart": 462.95008929525244,
  "duration": 0.9620853673520173,
  "entryType": "resource",
  "fetchStart": 462.95008929525244,
  "initiatorType": "img",
  "name": "https://cn.bing.com/sa/simg/SharedSpriteDesktopRewards_022118.png",
  "nextHopProtocol": "",
  "redirectEnd": 0,
  "redirectStart": 0,
  "requestStart": 463.91217466260445,
  "responseEnd": 463.91217466260445,
  "responseStart": 463.91217466260445,
  "startTime": 462.95008929525244,
  "workerStart": 0
}

以上爲 2018 年 7 月 7 日,在 cn.bing.com 下搜索 test 時,performance.getEntriesByType("resource") 返回的第二條結果。

我們關注的是資源加載的耗時情況,可以通過如下形式獲得:

const [{ startTime, responseEnd }] = performance.getEntriesByType('resource')
const loadTime = responseEnd - startTime

Navigation Timing 相似,關於 startTimefetchStartconnectStartrequestStart 的區別, Resource Timing Level 2 給出了詳細說明:

Resource Timing attributes

 

並非所有的資源加載時間都需要關注,重點還是加載過慢的部分。

出於簡化考慮,定義 10s 爲超時界限,那麼獲取超時資源的方法如下:

const SEC = 1000
const TIMEOUT = 10 * SEC
const setTime = (limit = TIMEOUT) => time => time >= limit
const isTimeout = setTime()
const getLoadTime = ({ startTime, responseEnd }) => responseEnd - startTime
const getName = ({ name }) => name
const resourceTimes = performance.getEntriesByType('resource')
const getTimeoutRes = resourceTimes
  .filter(item => isTimeout(getLoadTime(item)))
  .map(getName)

這樣一來,我們獲取了所有超時的資源列表。

簡單封裝一下:

const SEC = 1000
const TIMEOUT = 10 * SEC
const setTime = (limit = TIMEOUT) => time => time >= limit
const getLoadTime = ({ requestStart, responseEnd }) =>
  responseEnd - requestStart
const getName = ({ name }) => name
pMonitor.getTimeoutRes = (limit = TIMEOUT) => {
  const isTimeout = setTime(limit)
  const resourceTimes = performance.getEntriesByType('resource')
  return resourceTimes.filter(item => isTimeout(getLoadTime(item))).map(getName)
}

上報數據

獲取數據之後,需要向服務端上報:

// 生成表單數據
const convert2FormData = (data = {}) =>
  Object.entries(data).reduce((last, [key, value]) => {
    if (Array.isArray(value)) {
      return value.reduce((lastResult, item) => {
        lastResult.append(`${key}[]`, item)
        return lastResult
      }, last)
    }
    last.append(key, value)
    return last
  }, new FormData())
// 拼接 GET 時的url
const makeItStr = (data = {}) =>
  Object.entries(data)
    .map(([k, v]) => `${k}=${v}`)
    .join('&')
// 上報數據
pMonitor.log = (url, data = {}, type = 'POST') => {
  const method = type.toLowerCase()
  const urlToUse = method === 'get' ? `${url}?${makeItStr(data)}` : url
  const body = method === 'get' ? {} : { body: convert2FormData(data) }
  const option = {
    method,
    ...body
  }
  fetch(urlToUse, option).catch(e => console.log(e))
}

回過頭來初始化

數據上傳的 url、超時時間等細節,因項目而異,所以需要提供一個初始化的方法:

// 緩存配置
let config = {}
/**
 * @param {object} option
 * @param {string} option.url 頁面加載數據的上報地址
 * @param {string} option.timeoutUrl 頁面資源超時的上報地址
 * @param {string=} [option.method='POST'] 請求方式
 * @param {number=} [option.timeout=10000]
 */
pMonitor.init = option => {
  const { url, timeoutUrl, method = 'POST', timeout = 10000 } = option
  config = {
    url,
    timeoutUrl,
    method,
    timeout
  }
  // 綁定事件 用於觸發上報數據
  pMonitor.bindEvent()
}

何時觸發

性能監控只是輔助功能,不應阻塞頁面加載,因此只有當頁面完成加載後,我們才進行數據獲取和上報(實際上,頁面加載完成前也獲取不到必要信息):

// 封裝一個上報兩項核心數據的方法
pMonitor.logPackage = () => {
  const { url, timeoutUrl, method } = config
  const domComplete = pMonitor.getLoadTime()
  const timeoutRes = pMonitor.getTimeoutRes(config.timeout)
  // 上報頁面加載時間
  pMonitor.log(url, { domeComplete }, method)
  if (timeoutRes.length) {
    pMonitor.log(
      timeoutUrl,
      {
        timeoutRes
      },
      method
    )
  }
}
// 事件綁定
pMonitor.bindEvent = () => {
  const oldOnload = window.onload
  window.onload = e => {
    if (oldOnload && typeof oldOnload === 'function') {
      oldOnload(e)
    }
    // 儘量不影響頁面主線程
    if (window.requestIdleCallback) {
      window.requestIdleCallback(pMonitor.logPackage)
    } else {
      setTimeout(pMonitor.logPackage)
    }
  }
}

彙總

到此爲止,一個完整的前端性能監控工具就完成了~全部代碼如下:

const base = {
  log() {},
  logPackage() {},
  getLoadTime() {},
  getTimeoutRes() {},
  bindEvent() {},
  init() {}
}

const pm = (function() {
  // 向前兼容
  if (!window.performance) return base
  const pMonitor = { ...base }
  let config = {}
  const SEC = 1000
  const TIMEOUT = 10 * SEC
  const setTime = (limit = TIMEOUT) => time => time >= limit
  const getLoadTime = ({ startTime, responseEnd }) => responseEnd - startTime
  const getName = ({ name }) => name
  // 生成表單數據
  const convert2FormData = (data = {}) =>
    Object.entries(data).reduce((last, [key, value]) => {
      if (Array.isArray(value)) {
        return value.reduce((lastResult, item) => {
          lastResult.append(`${key}[]`, item)
          return lastResult
        }, last)
      }
      last.append(key, value)
      return last
    }, new FormData())
  // 拼接 GET 時的url
  const makeItStr = (data = {}) =>
    Object.entries(data)
      .map(([k, v]) => `${k}=${v}`)
      .join('&')
  pMonitor.getLoadTime = () => {
    const [{ domComplete }] = performance.getEntriesByType('navigation')
    return domComplete
  }
  pMonitor.getTimeoutRes = (limit = TIMEOUT) => {
    const isTimeout = setTime(limit)
    const resourceTimes = performance.getEntriesByType('resource')
    return resourceTimes
      .filter(item => isTimeout(getLoadTime(item)))
      .map(getName)
  }
  // 上報數據
  pMonitor.log = (url, data = {}, type = 'POST') => {
    const method = type.toLowerCase()
    const urlToUse = method === 'get' ? `${url}?${makeItStr(data)}` : url
    const body = method === 'get' ? {} : { body: convert2FormData(data) }
    const init = {
      method,
      ...body
    }
    fetch(urlToUse, init).catch(e => console.log(e))
  }
  // 封裝一個上報兩項核心數據的方法
  pMonitor.logPackage = () => {
    const { url, timeoutUrl, method } = config
    const domComplete = pMonitor.getLoadTime()
    const timeoutRes = pMonitor.getTimeoutRes(config.timeout)
    // 上報頁面加載時間
    pMonitor.log(url, { domeComplete }, method)
    if (timeoutRes.length) {
      pMonitor.log(
        timeoutUrl,
        {
          timeoutRes
        },
        method
      )
    }
  }
  // 事件綁定
  pMonitor.bindEvent = () => {
    const oldOnload = window.onload
    window.onload = e => {
      if (oldOnload && typeof oldOnload === 'function') {
        oldOnload(e)
      }
      // 儘量不影響頁面主線程
      if (window.requestIdleCallback) {
        window.requestIdleCallback(pMonitor.logPackage)
      } else {
        setTimeout(pMonitor.logPackage)
      }
    }
  }

  /**
   * @param {object} option
   * @param {string} option.url 頁面加載數據的上報地址
   * @param {string} option.timeoutUrl 頁面資源超時的上報地址
   * @param {string=} [option.method='POST'] 請求方式
   * @param {number=} [option.timeout=10000]
   */
  pMonitor.init = option => {
    const { url, timeoutUrl, method = 'POST', timeout = 10000 } = option
    config = {
      url,
      timeoutUrl,
      method,
      timeout
    }
    // 綁定事件 用於觸發上報數據
    pMonitor.bindEvent()
  }

  return pMonitor
})()

export default pm

 

補充說明

調用

如果想追(吹)求(毛)極(求)致(疵)的話,在頁面加載時,監測工具不應該佔用主線程的 JavaScript 解析時間。因此,最好在頁面觸發 onload 事件後,採用異步加載的方式:

// 在項目的入口文件的底部
const log = async () => {
  const pMonitor = await import('/path/to/pMonitor.js')
  pMonitor.init({ url: 'xxx', timeoutUrl: 'xxxx' })
  pMonitor.logPackage()
  // 可以進一步將 bindEvent 方法從源碼中刪除
}
const oldOnload = window.onload
window.onload = e => {
  if (oldOnload && typeof oldOnload === 'string') {
    oldOnload(e)
  }
  // 儘量不影響頁面主線程
  if (window.requestIdleCallback) {
    window.requestIdleCallback(log)
  } else {
    setTimeout(log)
  }
}

跨域等請求問題

工具在數據上報時,沒有考慮跨域問題,也沒有處理 GETPOST 同時存在的情況。

如有需求,可以自行覆蓋 pMonitor.logPackage 方法,改爲動態創建 <form/><iframe/> ,或者使用更爲常見的圖片打點方式

說好的報警呢?光有報沒有警?!

這個還是需要服務端配合的嘛[認真臉.jpg]。

既可以是每個項目對應不同的上報 url,也可以是統一的一套 url,項目分配唯一 id 作爲區分。

當超時次數在規定時間內超過約定的閾值時,郵件/短信通知開發人員。

細粒度

現在僅僅針對超時資源進行了簡單統計,但是沒有上報具體的超時原因(DNS?TCP?request? response?),這就留給讀者去優化了,動手試試吧~

下一步

本文介紹了關於頁面加載方面的性能監控, 此外,JavaScript 代碼的解析 + 執行,也是制約頁面首屏渲染快慢的重要因素(特別是單頁面應用)。下一話,小編將帶領大家 進一步探索 Performance Timeline Level 2, 實現更多對於 JavaScript 運行時的性能監控,敬請期待~

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章