簡(jiǎn)單而言推盛,有三點(diǎn)原因:
關(guān)注性能是工程師的本性 + 本分;
頁(yè)面性能對(duì)用戶體驗(yàn)而言十分關(guān)鍵谦铃。每次重構(gòu)對(duì)頁(yè)面性能的提升耘成,僅靠工程師開(kāi)發(fā)設(shè)備的測(cè)試數(shù)據(jù)是沒(méi)有說(shuō)服力的,需要有大量的真實(shí)數(shù)據(jù)用于驗(yàn)證驹闰;
資源掛了瘪菌、加載出現(xiàn)異常,不能總靠用戶投訴才后知后覺(jué)嘹朗,需要主動(dòng)報(bào)警控嗜。
一次性能重構(gòu),在千兆網(wǎng)速和萬(wàn)元設(shè)備的條件下骡显,頁(yè)面加載時(shí)間的提升可能只有 0.1%,但是這樣的數(shù)(土)據(jù)(豪)不具備代表性曾掂。網(wǎng)絡(luò)環(huán)境惫谤、硬件設(shè)備千差萬(wàn)別,對(duì)于中低端設(shè)備而言珠洗,性能提升的主觀體驗(yàn)更為明顯溜歪,對(duì)應(yīng)的數(shù)據(jù)變化更具備代表性。
不少項(xiàng)目都會(huì)把資源上傳到 CDN许蓖。而 CDN 部分節(jié)點(diǎn)出現(xiàn)問(wèn)題的時(shí)候蝴猪,一般不能精準(zhǔn)的告知“某某调衰,你的 xx 資源掛了”,因此需要我們主動(dòng)監(jiān)控自阱。
根據(jù)谷歌數(shù)據(jù)顯示嚎莉,當(dāng)頁(yè)面加載超過(guò) 10s 時(shí),用戶會(huì)感到絕望沛豌,通常會(huì)離開(kāi)當(dāng)前頁(yè)面趋箩,并且很可能不再回來(lái)。
用什么監(jiān)控
關(guān)于前端性能指標(biāo)加派,W3C 定義了強(qiáng)大的 Performance API叫确,其中又包括了 High Resolution Time 、 Frame Timing 芍锦、 Navigation Timing 竹勉、 Performance Timeline 、Resource Timing 娄琉、 User Timing 等諸多具體標(biāo)準(zhǔn)次乓。
本文主要涉及 Navigation Timing 以及 Resource Timing。截至到 2018 年中旬车胡,各大主流瀏覽器均已完成了基礎(chǔ)實(shí)現(xiàn)檬输。
Performance API 功能眾多,其中一項(xiàng)匈棘,就是將頁(yè)面自身以及頁(yè)面中各個(gè)資源的性能表現(xiàn)(時(shí)間細(xì)節(jié))記錄了下來(lái)丧慈。而我們要做的就是查詢和使用。
讀者可以直接在瀏覽器控制臺(tái)中輸入 performance 主卫,查看相關(guān) API逃默。
接下來(lái),我們將使用瀏覽器提供的 window.performance 對(duì)象(Performance API 的具體實(shí)現(xiàn))簇搅,來(lái)實(shí)現(xiàn)一個(gè)簡(jiǎn)易的前端性能監(jiān)控工具完域。
5 分鐘擼一個(gè)前端性能監(jiān)控工具
第一行代碼
將工具命名為 pMonitor,含義是 performance monitor瘩将。
const pMonitor = {}
復(fù)制代碼
監(jiān)控哪些指標(biāo)
既然是“5 分鐘實(shí)現(xiàn)一個(gè) xxx”系列吟税,那么就要有取舍。因此姿现,本文只挑選了最為重要的兩個(gè)指標(biāo)進(jìn)行監(jiān)控:
頁(yè)面加載時(shí)間
資源請(qǐng)求時(shí)間
看了看時(shí)間肠仪,已經(jīng)過(guò)去了 4 分鐘,小編表示情緒穩(wěn)定备典,沒(méi)有一絲波動(dòng)异旧。
頁(yè)面加載
有關(guān)頁(yè)面加載的性能指標(biāo),可以在 Navigation Timing 中找到提佣。Navigation Timing 包括了從請(qǐng)求頁(yè)面起吮蛹,到頁(yè)面完成加載為止荤崇,各個(gè)環(huán)節(jié)的時(shí)間明細(xì)。
可以通過(guò)以下方式獲取 Navigation Timing 的具體內(nèi)容:
const navTimes = performance.getEntriesByType('navigation')
復(fù)制代碼
getEntriesByType 是我們獲取性能數(shù)據(jù)的一種方式潮针。performance 還提供了 getEntries 以及 getEntriesByName 等其他方式术荤,由于“時(shí)間限制”,
返回結(jié)果是一個(gè)數(shù)組然低,其中的元素結(jié)構(gòu)如下所示:
{
"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
}
復(fù)制代碼
關(guān)于各個(gè)字段的時(shí)間含義喜每,Navigation Timing Level 2 給出了詳細(xì)說(shuō)明:
不難看出,細(xì)節(jié)滿滿雳攘。因此带兜,能夠計(jì)算的內(nèi)容十分豐富,例如 DNS 查詢時(shí)間吨灭,TLS 握手時(shí)間等等刚照。可以說(shuō)喧兄,只有想不到无畔,沒(méi)有做不到~
既然我們關(guān)注的是頁(yè)面加載,那自然要讀取 domComplete:
const [{ domComplete }] = performance.getEntriesByType('navigation')
復(fù)制代碼
定義個(gè)方法吠冤,獲取 domComplete:
pMonitor.getLoadTime = () => {
const [{ domComplete }] = performance.getEntriesByType('navigation')
return domComplete
}
復(fù)制代碼
到此浑彰,我們獲得了準(zhǔn)確的頁(yè)面加載時(shí)間。
資源加載
既然頁(yè)面有對(duì)應(yīng)的 Navigation Timing拯辙,那靜態(tài)資源是不是也有對(duì)應(yīng)的 Timing 呢郭变?
答案是肯定的,其名為 Resource Timing涯保。它包含了頁(yè)面中各個(gè)資源從發(fā)送請(qǐng)求起诉濒,到完成加載為止,各個(gè)環(huán)節(jié)的時(shí)間細(xì)節(jié)夕春,和 Navigation Timing 十分類(lèi)似未荒。
獲取資源加載時(shí)間的關(guān)鍵字為 'resource', 具體方式如下:
performance.getEntriesByType('resource')
復(fù)制代碼
不難聯(lián)想,返回結(jié)果通常是一個(gè)很長(zhǎng)的數(shù)組及志,因?yàn)榘隧?yè)面上所有資源的加載信息片排。
每條信息的具體結(jié)構(gòu)為:
{
"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
}
復(fù)制代碼
以上為 2018 年 7 月 7 日,在 cn.bing.com 下搜索 test 時(shí)速侈,performance.getEntriesByType("resource") 返回的第二條結(jié)果划纽。
我們關(guān)注的是資源加載的耗時(shí)情況,可以通過(guò)如下形式獲得:
const [{ startTime, responseEnd }] = performance.getEntriesByType('resource')
const loadTime = responseEnd - startTime
復(fù)制代碼
同 Navigation Timing 相似锌畸,關(guān)于 startTime 、 fetchStart靖避、connectStart 和 requestStart 的區(qū)別潭枣, Resource Timing Level 2 給出了詳細(xì)說(shuō)明:
并非所有的資源加載時(shí)間都需要關(guān)注比默,重點(diǎn)還是加載過(guò)慢的部分。
出于簡(jiǎn)化考慮盆犁,定義 10s 為超時(shí)界限命咐,那么獲取超時(shí)資源的方法如下:
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)
復(fù)制代碼
這樣一來(lái),我們獲取了所有超時(shí)的資源列表谐岁。
簡(jiǎn)單封裝一下:
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)
}
復(fù)制代碼
上報(bào)數(shù)據(jù)
獲取數(shù)據(jù)之后醋奠,需要向服務(wù)端上報(bào):
// 生成表單數(shù)據(jù)
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 時(shí)的url
const makeItStr = (data = {}) =>
Object.entries(data)
.map(([k, v]) => `${k}=${v}`)
.join('&')
// 上報(bào)數(shù)據(jù)
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))
}
復(fù)制代碼
回過(guò)頭來(lái)初始化
數(shù)據(jù)上傳的 url、超時(shí)時(shí)間等細(xì)節(jié)伊佃,因項(xiàng)目而異窜司,所以需要提供一個(gè)初始化的方法:
// 緩存配置
let config = {}
/**
* @param {object} option
* @param {string} option.url 頁(yè)面加載數(shù)據(jù)的上報(bào)地址
* @param {string} option.timeoutUrl 頁(yè)面資源超時(shí)的上報(bào)地址
* @param {string=} [option.method='POST'] 請(qǐng)求方式
* @param {number=} [option.timeout=10000]
*/
pMonitor.init = option => {
const { url, timeoutUrl, method = 'POST', timeout = 10000 } = option
config = {
url,
timeoutUrl,
method,
timeout
}
// 綁定事件 用于觸發(fā)上報(bào)數(shù)據(jù)
pMonitor.bindEvent()
}
復(fù)制代碼
何時(shí)觸發(fā)
性能監(jiān)控只是輔助功能,不應(yīng)阻塞頁(yè)面加載航揉,因此只有當(dāng)頁(yè)面完成加載后塞祈,我們才進(jìn)行數(shù)據(jù)獲取和上報(bào)(實(shí)際上,頁(yè)面加載完成前也獲取不到必要信息):
// 封裝一個(gè)上報(bào)兩項(xiàng)核心數(shù)據(jù)的方法
pMonitor.logPackage = () => {
const { url, timeoutUrl, method } = config
const domComplete = pMonitor.getLoadTime()
const timeoutRes = pMonitor.getTimeoutRes(config.timeout)
// 上報(bào)頁(yè)面加載時(shí)間
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)
}
// 盡量不影響頁(yè)面主線程
if (window.requestIdleCallback) {
window.requestIdleCallback(pMonitor.logPackage)
} else {
setTimeout(pMonitor.logPackage)
}
}
}
復(fù)制代碼
匯總
到此為止帅涂,一個(gè)完整的前端性能監(jiān)控工具就完成了~全部代碼如下:
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
// 生成表單數(shù)據(jù)
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 時(shí)的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)
}
// 上報(bào)數(shù)據(jù)
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))
}
// 封裝一個(gè)上報(bào)兩項(xiàng)核心數(shù)據(jù)的方法
pMonitor.logPackage = () => {
const { url, timeoutUrl, method } = config
const domComplete = pMonitor.getLoadTime()
const timeoutRes = pMonitor.getTimeoutRes(config.timeout)
// 上報(bào)頁(yè)面加載時(shí)間
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)
}
// 盡量不影響頁(yè)面主線程
if (window.requestIdleCallback) {
window.requestIdleCallback(pMonitor.logPackage)
} else {
setTimeout(pMonitor.logPackage)
}
}
}
/**
* @param {object} option
* @param {string} option.url 頁(yè)面加載數(shù)據(jù)的上報(bào)地址
* @param {string} option.timeoutUrl 頁(yè)面資源超時(shí)的上報(bào)地址
* @param {string=} [option.method='POST'] 請(qǐng)求方式
* @param {number=} [option.timeout=10000]
*/
pMonitor.init = option => {
const { url, timeoutUrl, method = 'POST', timeout = 10000 } = option
config = {
url,
timeoutUrl,
method,
timeout
}
// 綁定事件 用于觸發(fā)上報(bào)數(shù)據(jù)
pMonitor.bindEvent()
}
return pMonitor
})()
export default pm
復(fù)制代碼
如何议薪?是不是不復(fù)雜?甚至有點(diǎn)簡(jiǎn)單~
再次看了看時(shí)間媳友,5 分鐘什么的斯议,還是不要在意這些細(xì)節(jié)了吧 orz
補(bǔ)充說(shuō)明
調(diào)用
如果想追(吹)求(毛)極(求)致(疵)的話,在頁(yè)面加載時(shí)醇锚,監(jiān)測(cè)工具不應(yīng)該占用主線程的 JavaScript 解析時(shí)間哼御。因此,最好在頁(yè)面觸發(fā) onload 事件后搂抒,采用異步加載的方式:
// 在項(xiàng)目的入口文件的底部
const log = async () => {
const pMonitor = await import('/path/to/pMonitor.js')
pMonitor.init({ url: 'xxx', timeoutUrl: 'xxxx' })
pMonitor.logPackage()
// 可以進(jìn)一步將 bindEvent 方法從源碼中刪除
}
const oldOnload = window.onload
window.onload = e => {
if (oldOnload && typeof oldOnload === 'string') {
oldOnload(e)
}
// 盡量不影響頁(yè)面主線程
if (window.requestIdleCallback) {
window.requestIdleCallback(log)
} else {
setTimeout(log)
}
}
復(fù)制代碼
跨域等請(qǐng)求問(wèn)題
工具在數(shù)據(jù)上報(bào)時(shí)艇搀,沒(méi)有考慮跨域問(wèn)題,也沒(méi)有處理 GET 和 POST 同時(shí)存在的情況求晶。
5 分鐘還要什么自行車(chē)焰雕!
如有需求,可以自行覆蓋 pMonitor.logPackage 方法芳杏,改為動(dòng)態(tài)創(chuàng)建 <form/> 和 <iframe/> 矩屁,或者使用更為常見(jiàn)的圖片打點(diǎn)方式~
說(shuō)好的報(bào)警呢?光有報(bào)沒(méi)有警爵赵?吝秕!
這個(gè)還是需要服務(wù)端配合的嘛[認(rèn)真臉.jpg]。
既可以是每個(gè)項(xiàng)目對(duì)應(yīng)不同的上報(bào) url空幻,也可以是統(tǒng)一的一套 url烁峭,項(xiàng)目分配唯一 id 作為區(qū)分。
當(dāng)超時(shí)次數(shù)在規(guī)定時(shí)間內(nèi)超過(guò)約定的閾值時(shí),郵件/短信通知開(kāi)發(fā)人員约郁。
細(xì)粒度
現(xiàn)在僅僅針對(duì)超時(shí)資源進(jìn)行了簡(jiǎn)單統(tǒng)計(jì)缩挑,但是沒(méi)有上報(bào)具體的超時(shí)原因(DNS?TCP鬓梅?request供置? response?)绽快,這就留給讀者去優(yōu)化了芥丧,動(dòng)手試試吧~
下一步
本文介紹了關(guān)于頁(yè)面加載方面的性能監(jiān)控, 此外坊罢,JavaScript 代碼的解析 + 執(zhí)行续担,也是制約頁(yè)面首屏渲染快慢的重要因素(特別是單頁(yè)面應(yīng)用)。下一話艘绍,小編將帶領(lǐng)大家 進(jìn)一步探索 Performance Timeline Level 2赤拒, 實(shí)現(xiàn)更多對(duì)于 JavaScript 運(yùn)行時(shí)的性能監(jiān)控,敬請(qǐng)期待~