Web優化相關,前端性能監控工具


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

用什么監控

關於前端性能指標,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')

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

{
  "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 給出了詳細說明:

 

Navigation Timing attributes

 

不難看出,細節滿滿。因此,能夠計算的內容十分豐富,例如 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
}

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

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)
  }
}

設置報警

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

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM