H5性能监控实践
背景
- 一个页面多次上报,导致计算平均值的分母被放大,数值普遍偏小,严重背离实际值。
- 各个数值很异常比如FMP小于100ms,fp比lcp大等等问题。
- 缺少其他关键指标。
于是我悟到了两个道理: 一是兵马未动粮草先行,搞技术建设前先确定观测指标,做好数据采集。 二是我们做一件事儿不能只是为了讲PPT,要摆正形态,持续优化。
于是就有了下边这套H5性能监控方案。
核心指标的选择
衡量H5加载性能的指标有很多,比如:
FP
:首次渲染时间;
FCP
:First Contentful Paint,首次内容绘制,开始加载到页面内容的任何部分在屏幕上完成;
LCP
: Largest Contentful Paint,最大内容绘制的时间,即视口内最大元素渲染的时间点;
FID
:First Input Delay,用户第一次和页面交互后(比如点击按钮),浏览器响应并处理时间的时间;
CLS
:Cumulative Layout Shift,测量整个页面生命周期内发生的所有意外布局偏移中最大一连串的布局偏移分数。
FMP
:First Meaningful Paint,首次有意义的绘制,用来衡量页面“主要内容”的加载速度;
TTI
:页面可交互时间,即从页面开始加载,一直到用户可以自由输入或操作页面的时间;
在众多指标中,我们选择了FMP(首次主要内容绘制时间);
站在用户的角度,首次渲染、首次内容绘制、最大内容绘制时间都没有意义,只有有意义的主内容展示在用户面前,才应该被认为用户的等待时间结束。
其次诸如FID、TTI等指标,有太依赖用户的操作,偏主观性。
而 FMP,主内容渲染完成时间,我们其实可以自己主动监控和上报,只需要确定哪些是主内容节点。 在我们的团队中定义的主内容,一般是等待接口返回后渲染的有意义内容,这样FMP的时间其实包含 主接口请求和返回的时间,当主接口返回的内容被返回并绘制出来后,对用户才有意义。
FMP示意图:
监控SDK的原则
性能监控SDK作为一个辅助的功能,是为我们自己服务。它的优先级要低于为用户服务的业务逻辑。 为了避免对主业务的影响,我们定了如下原则:
- 可独立引入,不对其他主业务产生影响
- SDK的异常自己兜底,不可抛出影响到业务
- 耗时的逻辑在浏览器空闲时再执行
- 异常的数据应该直接丢掉,我们要关注的是绝大部分用户的正常情况
同时站在接入方便的角度,要满足一键接入,不对现有代码产生影响,不给研发照成负担。
获取监控数据
如何获取FMP数据
有了目标和原则,我们就要思考如何能正确的获取FMP数据。 起点比较好确定,可以使用 window.performance.timing 中的 fetchStart 作为起始时间。
那终点时间了? 终点时间是在要主要内容渲染后,这时我们可以使用 MutationObserver API去观察主要内容是否渲染完成。 对 MutationObserver API。 感兴趣的大家可以看 张鑫旭 老师的这篇文章:## 聊聊JS DOM变化的监听检测与应用
MutationObserver 在 DOM Level 4 中被引入,其兼容性也很不错。
我们的方案是在埋点初始化时,传入主内容节点标识,如果未传入,默认取 class 名为 core 的元素,作为主内容。 之后进行这些关键节点的监控。 主内容节点可以是多个
代码如下:
function getReadyTime(selectors?: string[], root?: string): Promise<number> {
return new Promise(resolve => {
let readyTime = new Date().getTime()
try {
const hasSelectors = selectors && selectors.length > 0
if (!hasSelectors) {
logger.warn('未传入关键节点,将使用 class=".core" 作为关键节点')
}
const waitSelectors = hasSelectors ? selectors : DEFAULT_HOST_ELEMENT
const hasDom = waitSelectors.some(key => !!document.querySelector(key))
if (hasDom) {
resolve(readyTime)
return
}
let rootNode = root ? document.getElementById(root) : null
rootNode = rootNode || document.body || document.documentElement || document
if (MutationObserver && rootNode) {
// 创建一个观察器实例并传入回调函数
const observer = new MutationObserver((mutationsList, observer) => {
const change = mutationsList.some((mutation) => {
const target = mutation.target
if (!target) return false
if (target.querySelector) {
const hasSubDom = waitSelectors.some(key => !!target.querySelector(key))
if (hasSubDom) return true
}
return false
})
if (change) {
readyTime = new Date().getTime()
observer.disconnect()
resolve(readyTime)
}
})
observer.observe(rootNode, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
})
} else {
resolve(readyTime)
}
} catch (err) {
resolve(readyTime)
}
})
}
/** FMP 时间计算*/
fmp_time = getReadyTime() - fetchStart
对于不兼容 MutationObserver 的浏览器,我们的处理方式是降级到获取第一个接口(接口数量可以配置)的返回时间,因为主接口返回后就会开始有意义内容的渲染,主接口的放回时间最接近主内容渲染时间。
以上我们主要的指标就获取完成了。
除了FMP
数据,我们还需要获取如下三个方面的数据:
- 页面加载过程中的数据,用来衡量网络情况
- 内容渲染数据,用来判断有无代码堵塞了首屏渲染
- 图片等静态资源加载数据,用来衡量JS、CSS和图片的优化效果,这里图片尤其重要。
页面加载过程中的性能指标
页面加载过程中的数据主要是dns_time、tcp_time、resources_time等。 过程指标如何获得。
看下图:
基于 PerformanceNavigationTiming API,可以获取 HTML 加载过程中的各个时间节点,那不同的节点时间相减,即可获得耗时。
如下:
const times: PerformanceTimes = {
// 重定向耗时
redirect_time: timing.redirectEnd - timing.redirectStart,
// DNS 解析耗时: domainLookupEnd - domainLookupStart
dns_time: timing.domainLookupEnd - timing.domainLookupStart,
// TCP 连接耗时
tcp_time: timing.connectEnd - timing.connectStart,
// SSL 安全连接耗时
ssl_time: timing.secureConnectionStart ? timing.connectStart - timing.secureConnectionStart : 0,
// 网络请求耗时 (TTFB) 读取到第一个字节的时间
ttfb_time: timing.responseStart - timing.requestStart,
// 数据传输耗时
response_time: timing.responseEnd - timing.responseStart,
// DOM 解析耗时
dom_analysis_time: domInteractive - timing.responseEnd,
// 资源加载耗时
resources_time: timing.loadEventStart - timing.domContentLoadedEventEnd,
// 首包时间
firstbyte_time: timing.responseStart - timing.domainLookupStart,
// HTML 加载完成时间, 即 DOM Ready 时间
dom_ready_time: timing.domContentLoadedEventEnd - timing.fetchStart,
// 页面完全加载时间
load_time: timing.loadEventEnd - timing.fetchStart
};
这里要注意一点,虽然 PerformanceNavigationTiming
是标准API,但兼容性并不是很好,在它之前还有一个 PerformanceTiming
API 虽然不是标准,但兼容性更好。
PerformanceNavigationTiming
PerformanceTiming
这里可以对两种API做兼容性处理,优先使用 PerformanceNavigationTiming
。
let timing: PerformanceTiming | PerformanceNavigationTiming = window.performance.timing;
let domInteractive: number = timing.domLoading;
if (typeof window.PerformanceNavigationTiming === 'function') {
try {
const nt2Timing = window.performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
if (nt2Timing) {
timing = nt2Timing;
domInteractive = timing.domInteractive;
}
} catch (err) {
console.error(err);
}
}
内容渲染耗时指标
除了过程指标,内容渲染相关指标,我们主要采集了 FP、FCP、LCP。
采集的原理是基于 web-vitals,是Google 推出的一组用户衡量网站性能的指标。
web-vitals 我们这里偷了个懒,直接基于 web-vitals 来采集渲染相关的数据指标。其实 dns 等数据,在其中也有封装,可以直接获取。
web-vitals原理
本质的原理,还是利用 Observer 家族的另一个API PerformanceObserver
,通过监听性能事件获取相关阶段的性能数据。
用法如下:
const entryHandler = (list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
observer.disconnect()
}
console.log(entry)
}
}
const observer = new PerformanceObserver(entryHandler)
observer.observe({ type: 'paint', buffered: true })
通过 PerformanceObserver
API,可以对页面的各项性能指标进行观察,��浏览器的性能时间轴记录新的 performance entry 的时候将会被通知。
有如下可以被观察的条目:
指标 | 释义 |
---|---|
back-forward-cache-restoration | – |
element | 元素加载时间,实例项是 PerformanceElementTiming 对象。 |
event | 事件延迟,实例项是 PerformanceEventTiming 对象。 |
first-input | 用户第一次与网站交互(即点击链接、点击按钮或使用自定义的JavaScript控件时)到浏览器实际能够响应该交互的时间,称之为First input delay – FID。 |
largest-contentful-paint | 屏幕上触发的最大绘制元素,实例项是 LargestContentfulPaint 对象。 |
layout-shift | 元素移动时候的布局稳定性,实例项是 LayoutShift对象。 |
long-animation-frame | 长动画关键帧。 |
longtask | 长任务实例,归属于 PerformanceLongTaskTiming 对象。 |
mark | 用户自定义的性能标记。实例项是 PerformanceMark 对象。 |
measure | 用户自定义的性能测量。实例项是 PerformanceMeasure 对象。 |
navigation | 页面导航出去的时间,实例项是 PerformancePaintTiming 对象。 |
paint | 页面加载时内容渲染的关键时刻(第一次绘制,第一次有内容的绘制,实例项是 PerformancePaintTiming 对象。 |
resource | 页面中资源的时间信息,实例项是 PerformanceResourceTiming 对象。 |
taskattribution | 长期任务有重大贡献的工作类型,实例项是 TaskAttributionTiming 对象。 |
soft-navigation | – |
visibility-state | 页面可见性状态更改的时间,即选项卡何时从前台更改为后台,反之亦然。实例项是 VisibilityStateEntry 对象。 |
不过iOS Safari 11 之前的版本有不少条目不支持。
资源加载耗时指标
除了加载主流程的指标数据外,我们还可以能监控到资源加载的速度,尤其是图片,从而验证我们对图片优化的效果。
// 这里的 eleType 可以换成 SCRIPT,
const eleType = 'img'
const supportEntries = window.performance && window.performance.getEntries && typeof window.performance.getEntries === 'function'
const p = supportEntries ? window.performance.getEntries() : []
const files = p.filter(ele => ele.initiatorType === eleType)
const { sumTime, maxResponseEnd, minStartTime } = files.reduce<{ [key: string]: number }>((acc, item) => {
const { maxResponseEnd, sumTime } = acc
const { startTime, responseEnd } = item
const minStartTime = acc.minStartTime || Number.MAX_VALUE
const loadTime: number = responseEnd - startTime
return {
sumTime: (sumTime || 0) + loadTime,
maxResponseEnd: responseEnd > (maxResponseEnd || 0) ? responseEnd : maxResponseEnd,
minStartTime: startTime < minStartTime ? startTime : minStartTime,
}
}, {})
const imageLength = files.length
const imageEntries = {
image_average_time: imageLength > 0 ? (sumTime / imageLength) : 0,
image_load_time: imageLength > 0 ? (maxResponseEnd - minStartTime) : 0,
image_length: imageLength,
}
这样可以统计出,图片加载平均耗时、总耗时,以及请求的图片总数。
数据处理
异常数据处理
当采集的数据上报上来后,总会有一个因特殊原因导致的异常数据。 比如因为兼容性没有获得到LCP,用户网络问题,导致一些指标异常大。
这是怎么办嘞,我们需要关注的还是一般情况下使用的用户体验问题。 所以我们给一些指标定了合理范围,在合理范围内的会被上报,合理访问外的数据丢弃。
如下:
'dns_time': { keep2: true, scope: [0, 300] },
'tcp_time': { keep2: true, scope: [0, 300] },
'h5_fmp_time': { keep2: true, scope: [1, 10000], required: true },
这里我们给 h5_fmp_time 加上了 required 配置,只有 h5_fmp_time 指标是必须的,如果没能获取到该值,则性能数据不必再上报。
正态分布
对于上报的数据,可以在数据观察后台,使用 正态分布 的思路,来选择要观察的中位数。
例如:P95阈值表示 5% 的事务持续时间大于阈值。例如,如果 P95 阈值为 50 毫秒,则 5% 的事务超过该阈值,耗时超过 50 毫秒。
正态分布,也称为高斯分布,是一种常见的概率分布,其特点是呈钟形曲线。在正态分布中,大部分的值集中在均值附近,并且随着离均值的距离增加,概率逐渐减小。
数据上报策略
最后把各个阶段采集的数据进行汇总,这次为了不对业务逻辑参数负担,我们可以使用 requestIdleCallback
在浏览器空闲期间在发送数据。
最后来看下,我们采集的数据,对比之前,数据要合理很多,数据之间也有了有逻辑性。
做对的事情,把事情做对
转载自:https://juejin.cn/post/7396934542958690315