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

爲何監控

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

簡單而言,有三點緣由:git

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

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

很多項目都會把資源上傳到 CDN。而 CDN 部分節點出現問題的時候,通常不能精準的告知「某某,你的 xx 資源掛了」,所以須要咱們主動監控。web

根據谷歌數據顯示,當頁面加載超過 10s 時,用戶會感到絕望,一般會離開當前頁面,而且極可能再也不回來。json

用什麼監控

關於前端性能指標,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」系列,那麼就要有取捨。所以,本文只挑選了最爲重要的兩個指標進行監控:

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

看了看時間,已通過去了 4 分鐘,小編表示情緒穩定,沒有一絲波動。

Everything is fine

頁面加載

有關頁面加載的性能指標,能夠在 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 給出了詳細說明:

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
}
複製代碼

以上爲 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
複製代碼

如何?是否是不復雜?甚至有點簡單~

再次看了看時間,5 分鐘什麼的,仍是不要在乎這些細節了吧 orz

It doesn't matter

補充說明

調用

若是想追(吹)求(毛)極(求)致(疵)的話,在頁面加載時,監測工具不該該佔用主線程的 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 同時存在的狀況。

5 分鐘還要什麼自行車!

若有需求,能夠自行覆蓋 pMonitor.logPackage 方法,改成動態建立 <form/><iframe/> ,或者使用更爲常見的圖片打點方式~

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

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

既能夠是每一個項目對應不一樣的上報 url,也能夠是統一的一套 url,項目分配惟一 id 做爲區分。

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

細粒度

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

下一步

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

參考資料

關於奇舞週刊

《奇舞週刊》是360公司專業前端團隊「奇舞團」運營的前端技術社區。關注公衆號後,直接發送連接到後臺便可給咱們投稿。

相關文章
相關標籤/搜索