DOMContentLoaded、load 事件
当纯 HTML 被完全加载以及解析时,DOMContentLoaded
事件会被触发,不用等待 css、img、iframe 加载完。
当整个页面及所有依赖资源如样式表和图片都已完成加载时,将触发 load
事件。
虽然这两个性能指标比较旧了,但是它们仍然能反映页面的一些情况。对于它们进行监听仍然是必要的。
import { lazyReportCache } from '../utils/report' ['load', 'DOMContentLoaded'].forEach(type => onEvent(type)) function onEvent(type) { function callback() { lazyReportCache({ type: 'performance', subType: type.toLocaleLowerCase(), startTime: performance.now(), }) window.removeEventListener(type, callback, true) } window.addEventListener(type, callback, true) }
首屏渲染时间
大多数情况下,首屏渲染时间可以通过 load
事件获取。除了一些特殊情况,例如异步加载的图片和 DOM。
<script> setTimeout(() => { document.body.innerHTML = ` <div> <!-- 省略一堆代码... --> </div> ` }, 3000) </script>
像这种情况就无法通过 load
事件获取首屏渲染时间了。这时我们需要通过 MutationObserver 来获取首屏渲染时间。MutationObserver 在监听的 DOM 元素属性发生变化时会触发事件。
首屏渲染时间计算过程:
- 利用 MutationObserver 监听 document 对象,每当 DOM 元素属性发生变更时,触发事件。
- 判断该 DOM 元素是否在首屏内,如果在,则在
requestAnimationFrame()
回调函数中调用performance.now()
获取当前时间,作为它的绘制时间。 - 将最后一个 DOM 元素的绘制时间和首屏中所有加载的图片时间作对比,将最大值作为首屏渲染时间。
监听 DOM
const next = window.requestAnimationFrame ? requestAnimationFrame : setTimeout const ignoreDOMList = ['STYLE', 'SCRIPT', 'LINK'] observer = new MutationObserver(mutationList => { const entry = { children: [], } for (const mutation of mutationList) { if (mutation.addedNodes.length && isInScreen(mutation.target)) { // ... } } if (entry.children.length) { entries.push(entry) next(() => { entry.startTime = performance.now() }) } }) observer.observe(document, { childList: true, subtree: true, })
上面的代码就是监听 DOM 变化的代码,同时需要过滤掉 style
、script
、link
等标签。
判断是否在首屏
一个页面的内容可能非常多,但用户最多只能看见一屏幕的内容。所以在统计首屏渲染时间的时候,需要限定范围,把渲染内容限定在当前屏幕内。
const viewportWidth = window.innerWidth const viewportHeight = window.innerHeight // dom 对象是否在屏幕内 function isInScreen(dom) { const rectInfo = dom.getBoundingClientRect() if (rectInfo.left < viewportWidth && rectInfo.top < viewportHeight) { return true } return false }
使用 requestAnimationFrame()
获取 DOM 绘制时间
当 DOM 变更触发 MutationObserver 事件时,只是代表 DOM 内容可以被读取到,并不代表该 DOM 被绘制到了屏幕上。
从上图可以看出,当触发 MutationObserver 事件时,可以读取到 document.body
上已经有内容了,但实际上左边的屏幕并没有绘制任何内容。所以要调用 requestAnimationFrame()
在浏览器绘制成功后再获取当前时间作为 DOM 绘制时间。
和首屏内的所有图片加载时间作对比
function getRenderTime() { let startTime = 0 entries.forEach(entry => { if (entry.startTime > startTime) { startTime = entry.startTime } }) // 需要和当前页面所有加载图片的时间做对比,取最大值 // 图片请求时间要小于 startTime,响应结束时间要大于 startTime performance.getEntriesByType('resource').forEach(item => { if ( item.initiatorType === 'img' && item.fetchStart < startTime && item.responseEnd > startTime ) { startTime = item.responseEnd } }) return startTime }
优化
现在的代码还没优化完,主要有两点注意事项:
- 什么时候上报渲染时间?
- 如果兼容异步添加 DOM 的情况?
第一点,必须要在 DOM 不再变化后再上报渲染时间,一般 load 事件触发后,DOM 就不再变化了。所以我们可以在这个时间点进行上报。
第二点,可以在 LCP 事件触发后再进行上报。不管是同步还是异步加载的 DOM,它都需要进行绘制,所以可以监听 LCP 事件,在该事件触发后才允许进行上报。
将以上两点方案结合在一起,就有了以下代码:
let isOnLoaded = false executeAfterLoad(() => { isOnLoaded = true }) let timer let observer function checkDOMChange() { clearTimeout(timer) timer = setTimeout(() => { // 等 load、lcp 事件触发后并且 DOM 树不再变化时,计算首屏渲染时间 if (isOnLoaded && isLCPDone()) { observer && observer.disconnect() lazyReportCache({ type: 'performance', subType: 'first-screen-paint', startTime: getRenderTime(), pageURL: getPageURL(), }) entries = null } else { checkDOMChange() } }, 500) }
checkDOMChange()
代码每次在触发 MutationObserver 事件时进行调用,需要用防抖函数进行处理。
接口请求耗时
接口请求耗时需要对 XMLHttpRequest 和 fetch 进行监听。
监听 XMLHttpRequest
originalProto.open = function newOpen(...args) { this.url = args[1] this.method = args[0] originalOpen.apply(this, args) } originalProto.send = function newSend(...args) { this.startTime = Date.now() const onLoadend = () => { this.endTime = Date.now() this.duration = this.endTime - this.startTime const { status, duration, startTime, endTime, url, method } = this const reportData = { status, duration, startTime, endTime, url, method: (method || 'GET').toUpperCase(), success: status >= 200 && status < 300, subType: 'xhr', type: 'performance', } lazyReportCache(reportData) this.removeEventListener('loadend', onLoadend, true) } this.addEventListener('loadend', onLoadend, true) originalSend.apply(this, args) }
如何判断 XML 请求是否成功?可以根据他的状态码是否在 200~299 之间。如果在,那就是成功,否则失败。
监听 fetch
const originalFetch = window.fetch function overwriteFetch() { window.fetch = function newFetch(url, config) { const startTime = Date.now() const reportData = { startTime, url, method: (config?.method || 'GET').toUpperCase(), subType: 'fetch', type: 'performance', } return originalFetch(url, config) .then(res => { reportData.endTime = Date.now() reportData.duration = reportData.endTime - reportData.startTime const data = res.clone() reportData.status = data.status reportData.success = data.ok lazyReportCache(reportData) return res }) .catch(err => { reportData.endTime = Date.now() reportData.duration = reportData.endTime - reportData.startTime reportData.status = 0 reportData.success = false lazyReportCache(reportData) throw err }) } }
对于 fetch,可以根据返回数据中的的 ok
字段判断请求是否成功,如果为 true
则请求成功,否则失败。
注意,监听到的接口请求时间和 chrome devtool 上检测到的时间可能不一样。这是因为 chrome devtool 上检测到的是 HTTP 请求发送和接口整个过程的时间。但是 xhr 和 fetch 是异步请求,接口请求成功后需要调用回调函数。事件触发时会把回调函数放到消息队列,然后浏览器再处理,这中间也有一个等待过程。
资源加载时间、缓存命中率
通过 PerformanceObserver
可以监听 resource
和 navigation
事件,如果浏览器不支持 PerformanceObserver
,还可以通过 performance.getEntriesByType(entryType)
来进行降级处理。
当 resource
事件触发时,可以获取到对应的资源列表,每个资源对象包含以下一些字段:
从这些字段中我们可以提取到一些有用的信息:
{ name: entry.name, // 资源名称 subType: entryType, type: 'performance', sourceType: entry.initiatorType, // 资源类型 duration: entry.duration, // 资源加载耗时 dns: entry.domainLookupEnd - entry.domainLookupStart, // DNS 耗时 tcp: entry.connectEnd - entry.connectStart, // 建立 tcp 连接耗时 redirect: entry.redirectEnd - entry.redirectStart, // 重定向耗时 ttfb: entry.responseStart, // 首字节时间 protocol: entry.nextHopProtocol, // 请求协议 responseBodySize: entry.encodedBodySize, // 响应内容大小 responseHeaderSize: entry.transferSize - entry.encodedBodySize, // 响应头部大小 resourceSize: entry.decodedBodySize, // 资源解压后的大小 isCache: isCache(entry), // 是否命中缓存 startTime: performance.now(), }
判断该资源是否命中缓存
在这些资源对象中有一个 transferSize
字段,它表示获取资源的大小,包括响应头字段和响应数据的大小。如果这个值为 0,说明是从缓存中直接读取的(强制缓存)。如果这个值不为 0,但是 encodedBodySize
字段为 0,说明它走的是协商缓存(encodedBodySize
表示请求响应数据 body 的大小)。
function isCache(entry) { // 直接从缓存读取或 304 return entry.transferSize === 0 || (entry.transferSize !== 0 && entry.encodedBodySize === 0) }
不符合以上条件的,说明未命中缓存。然后将所有命中缓存的数据/总数据
就能得出缓存命中率。
浏览器往返缓存 BFC(back/forward cache)
bfcache 是一种内存缓存,它会将整个页面保存在内存中。当用户返回时可以马上看到整个页面,而不用再次刷新。据该文章 bfcache 介绍,firfox 和 safari 一直支持 bfc,chrome 只有在高版本的移动端浏览器支持。但我试了一下,只有 safari 浏览器支持,可能我的 firfox 版本不对。
但是 bfc 也是有缺点的,当用户返回并从 bfc 中恢复页面时,原来页面的代码不会再次执行。为此,浏览器提供了一个 pageshow
事件,可以把需要再次执行的代码放在里面。
window.addEventListener('pageshow', function(event) { // 如果该属性为 true,表示是从 bfc 中恢复的页面 if (event.persisted) { console.log('This page was restored from the bfcache.'); } else { console.log('This page was loaded normally.'); } });
从 bfc 中恢复的页面,我们也需要收集他们的 FP、FCP、LCP 等各种时间。
onBFCacheRestore(event => { requestAnimationFrame(() => { ['first-paint', 'first-contentful-paint'].forEach(type => { lazyReportCache({ startTime: performance.now() - event.timeStamp, name: type, subType: type, type: 'performance', pageURL: getPageURL(), bfc: true, }) }) }) })
上面的代码很好理解,在 pageshow
事件触发后,用当前时间减去事件触发时间,这个时间差值就是性能指标的绘制时间。注意,从 bfc 中恢复的页面的这些性能指标,值一般都很小,一般在 10 ms 左右。所以要给它们加个标识字段 bfc: true
。这样在做性能统计时可以对它们进行忽略。
FPS
利用 requestAnimationFrame()
我们可以计算当前页面的 FPS。
const next = window.requestAnimationFrame ? requestAnimationFrame : (callback) => { setTimeout(callback, 1000 / 60) } const frames = [] export default function fps() { let frame = 0 let lastSecond = Date.now() function calculateFPS() { frame++ const now = Date.now() if (lastSecond + 1000 <= now) { // 由于 now - lastSecond 的单位是毫秒,所以 frame 要 * 1000 const fps = Math.round((frame * 1000) / (now - lastSecond)) frames.push(fps) frame = 0 lastSecond = now } // 避免上报太快,缓存一定数量再上报 if (frames.length >= 60) { report(deepCopy({ frames, type: 'performace', subType: 'fps', })) frames.length = 0 } next(calculateFPS) } calculateFPS() }
代码逻辑如下:
- 先记录一个初始时间,然后每次触发
requestAnimationFrame()
时,就将帧数加 1。过去一秒后用帧数/流逝的时间
就能得到当前帧率。
当连续三个低于 20 的 FPS 出现时,我们可以断定页面出现了卡顿,详情请看 如何监控网页的卡顿。
export function isBlocking(fpsList, below = 20, last = 3) { let count = 0 for (let i = 0; i < fpsList.length; i++) { if (fpsList[i] && fpsList[i] < below) { count++ } else { count = 0 } if (count >= last) { return true } } return false }