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

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

用什麼監控

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

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

Navigation Timing Support

 

 

Resource Timing Support
 

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

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

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

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

第一行代碼

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

const pMonitor = {}

監控哪些指標

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

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

頁面加載

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

能夠經過如下方式獲取 Navigation Timing 的具體內容:dom

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 做爲區分。

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

相關文章
相關標籤/搜索