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


 

簡單而言,有三點原因:

  • 關注性能是工程師的本性 + 本分;
  • 頁面性能對用戶體驗而言十分關鍵。每次重構對頁面性能的提升,僅靠工程師開發設備的測試數據是沒有說服力的,需要有大量的真實數據用於驗證;
  • 資源掛了、加載出現異常,不能總靠用戶投訴才后知后覺,需要主動報警。
一次性能重構,在千兆網速和萬元設備的條件下,頁面加載時間的提升可能只有 0.1%,但是這樣的數(土)據(豪)不具備代表性。網絡環境、硬件設備千差萬別,對於中低端設備而言,性能提升的主觀體驗更為明顯,對應的數據變化更具備代表性。
不少項目都會把資源上傳到 CDN。而 CDN 部分節點出現問題的時候,一般不能精准的告知“某某,你的 xx 資源掛了”,因此需要我們主動監控。
根據谷歌數據顯示,當頁面加載超過 10s 時,用戶會感到絕望,通常會離開當前頁面,並且很可能不再回來。

用什么監控

關於前端性能指標,W3C 定義了強大的 Performance API,其中又包括了 High Resolution Time 、 Frame Timing 、 Navigation Timing 、 Performance Timeline 、Resource Timing 、 User Timing 等諸多具體標准。

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

 

 

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

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

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

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

第一行代碼

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

const pMonitor = {}
復制代碼

監控哪些指標

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

  • 頁面加載時間
  • 資源請求時間
看了看時間,已經過去了 4 分鍾,小編表示情緒穩定,沒有一絲波動。

 

頁面加載

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

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

const navTimes = performance.getEntriesByType('navigation')
復制代碼
getEntriesByType 是我們獲取性能數據的一種方式。performance 還提供了 getEntries 以及 getEntriesByName 等其他方式,由於“時間限制”,

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

{
 "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 日,在   下搜索 test 時,performance.getEntriesByType("resource") 返回的第二條結果。

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

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

同 Navigation Timing 相似,關於 startTime 、 fetchStart、connectStart 和 requestStart 的區別, Resource Timing Level 2 給出了詳細說明:

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

出於簡化考慮,定義 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

 

補充說明

調用

如果想追(吹)求(毛)極(求)致(疵)的話,在頁面加載時,監測工具不應該占用主線程的 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)
 }
}
復制代碼

跨域等請求問題

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

5 分鍾還要什么自行車!

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

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

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

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

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

細粒度

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

下一步

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


免責聲明!

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



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