前端FP 、FCP 与 LCP监控原理
总述
在这篇文章中,会为大家讲解:
- FP、FCP、LCP 的概念。
- 如何去监控浏览器的 FP、FCP、LCP 性能指标。
提示
- 本系列文章主要聚焦于浏览器端上的监控能力。
基本概念
FP
FP,全称 First Paint, 代表首次渲染的时间点,即首次视觉变化发生的时间点。前端开发者经常谈到的白屏时间(用户看不到任何内容)就是用户访问网页到 FP 的这段时间。
所以 FP 是一个比较重要的性能指标,优化 FP 能够大大减小用户流失的概率,换句话来说,FP 是用户对一个网站的第一印象。
FCP
FCP,全程 First Contentful Paint,代表首次 DOM 内容 渲染的时间点,DOM 内容 可以是文本、图像(包括背景图像)、<svg>
元素或非白色的 <canvas>
元素。
FCP 的评价范围如图所示:
FP 和 FCP 的区别?
从上面的描述中不难看出,FP 侧重于非 DOM 的视觉变化(例如 css 样式) ,FCP 侧重于 DOM 内容变化,下面我们来看一个 codepen 案例,点击 runpen 按钮运行之:
<div id="app"></div>
<div id="content"></div>
<style>
#app {
height: 100px;
width: 100px;
background-color: #409eff;
}
</style>
<script>
let data = [];
const observerInstance = new PerformanceObserver((list) => {
list.getEntries().forEach(item => data.push(item));
});
observerInstance.observe({
entryTypes: ['paint']
});
</script>
<script>
setTimeout(() => {
const el = document.getElementById("app");
el.innerText = "hello world";
const res = data.map(item => `${item.name}: ${item.startTime}`).join('\n');
}, 2000);
setTimeout(() => {
const el2 = document.getElementById("content");
el2.innerText = data.map(item => `${item.name}: ${item.startTime}`).join('\n');
}, 3000);
</script>
分析一下过程:
- 在页面被打开时,一个方形区域被渲染成了蓝色(css 生效),此时只发生了视觉变化,并没有发生 DOM 变动,该时间点即为 FP。
- 在 2 秒后,hello world 单词被插入,此时发生了 DOM 变动,该时间点即为 FCP。
- 从右侧输出的 fp、fcp 值来看,它们相差了 2 秒,印证了上面的结论。
LCP
LCP,全程 Largest Contentful Paint,根据页面首次开始加载的时间点(即 first started loading,可以通过 performance.timeOrigin
得到)来报告可视区域内可见的最大图像或文本块完成渲染的相对时间,其考量的元素有:
<img>
元素- 内嵌在
<svg>
元素内的<image>
元素 <video>
元素(使用封面图像)- 通过
url()
函数(而非使用 CSS 渐变)加载的带有背景图像的元素 - 包含文本节点或其他行内级文本元素子元素的块级元素。
提示
- LCP 一定在 onload 之后吗?
不一定,虽然大部分情况下是。上面的图例就回答了这个问题,如果在一开始最大元素就没有发送变化,LCP 时机完全可以在页面完全加载之前出现。
- 一个长度和宽度都很大的 div,没有内容但是有背景色,可能会触发 LCP 吗?
不可能,LCP 的捕获范围是可视区域内可见的最大图像或文本块, 对于 div(看成文本块) 应该是以内容的多少来判断是否触发 LCP。
LCP 的评价范围如下图所示:
实现原理
FP 和 FCP
FP 和 FCP 指标可以结合 performance.getEntriesByType
和 PerformanceObserver
这两个 API 来得到,实现方法如下,和获取静态资源的优化方法类似,我们可以在开始监听之前进行一次主动获取,如果两个指标都能得到,则不用再进行后续监听,以提高性能。
export enum PERFORMANCE_ENTRY_TYPES {
PAINT = 'paint',
LARGEST_CONTENTFUL_PAINT = 'largest-contentful-paint'
}
const FIRST_PAINT = 'first-paint';
const FIRST_CONTENTFUL_PAINT = 'first-contentful-paint';
// 封装 observer API
export const observePerformance = (
options: PerformanceObserverInit,
callback: (entryList: PerformanceEntry[]) => void
) => {
// 通过 observer 监听
const PerformanceObserver = getPerformanceObserver();
if (PerformanceObserver) {
const observerInstance = new PerformanceObserver((list) => {
const performanceEntries = list.getEntries();
callback(performanceEntries);
});
observerInstance.observe(options);
}
};
export function createPaintMonitor(options: PaintMonitorOptions) {
const performance = getPerformance();
if (!performance) {
return;
}
const getDataFromPaintPreferenceArray = (entries: PerformanceEntry[]) => {
// 无法确定由于浏览器的差异造成的可能的先后顺序问题,我们使用 filter name 来拿到相关指标
const [firstPaintEntry] = entries.filter((entry) => entry.name === FIRST_PAINT);
const [firstContentfulPaintEntry] = entries.filter((entry) => entry.name === FIRST_CONTENTFUL_PAINT);
return [firstPaintEntry, firstContentfulPaintEntry];
};
// 尝试监听器上报 FP && FCP
const reportFirstPaintAndFirstContentfulPaintByObserver = () => {
// 先尝试主动上报 FP && FCP
const entries = performance.getEntriesByType(PERFORMANCE_ENTRY_TYPES.PAINT);
const [firstPaintEntry, firstContentfulPaintEntry] = getDataFromPaintPreferenceArray(entries);
if (firstPaintEntry && firstContentfulPaintEntry) {
// 上报数据,具体实现略去
doReport(firstPaintEntry, firstContentfulPaintEntry, EventType.PAINT);
return;
}
// 如果主动获取失败(脚本执行,但还没绘制完成),再添加监听器
const observerOptions: PerformanceObserverInit = {
entryTypes: [PERFORMANCE_ENTRY_TYPES.PAINT],
};
observePerformance(
observerOptions,
(entryList) => {
const [firstPaintEntry, firstContentfulPaintEntry] = getDataFromPaintPreferenceArray(entryList);
if (firstPaintEntry && firstContentfulPaintEntry) {
// 上报数据,具体实现略去
doReport(firstPaintEntry, firstContentfulPaintEntry, EventType.PAINT);
}
}
);
};
reportFirstPaintAndFirstContentfulPaintByObserver();
}
LCP
下面是我们获取 LCP 的代码,和 FP、FCP 类似:
// 监听最大内容绘制时间(需要尽可能早地执行)
const reportLargestContentfulPaintByObserver = () => {
const observerOptions: PerformanceObserverInit = {
entryTypes: [PERFORMANCE_ENTRY_TYPES.LARGEST_CONTENTFUL_PAINT],
};
const destroy = observePerformance(observerOptions, (entryList) => {
// 上报数据, 细节略去
entryList.forEach((entry) => {
// ...
});
});
};
实现它不难,但是在实际环境中,有关 LCP 的获取与上报,有几个注意点和难点:
LCP 的两次分发
网页在加载的过程中的最大元素很有可能会发生变化,也就是说,LCP 可能会有两个,根据官方资料的描述:
- 浏览器在绘制第一帧后立即分发一个
largest-contentful-paint
类型的PerformanceEntry
,用于识别最大内容元素。 - 但是,在渲染后续帧之后,浏览器会在最大内容元素发生变化时分发另一个
PerformanceEntry
。
综上所述,我们应该要综合两次分发的结果确定最终上报的 LCP。
LCP 的监听时机
很不幸,LCP 的主动获取 API performance.getEntriesByType
已经被废弃:
那么只有监听器 API 可用,所以我们需要保证监听器的初始化一定在 LCP 之前, 事实上,我们第一篇提到的 JavaScript 异常监控也有类似的问题。
将 SDK entry 放在 head 标签里面
这是很多人可能会想到的方法,主要的理由如下:
- 页面在浏览器解析到
<body>
的起始标签时才可能开始渲染,放在 head 里面可以保证执行在渲染之前, 避免遗漏浏览器分发的 LCP 指标 。 - 某些特性要求监控 SDK 尽可能比较早地初始化完成,例如我们在第一篇中提到的 JavaScript 异常。
不过这也带来了一些问题:
- SDK 监控的指标比较多,并且要初始化很多的监听器,一次在首屏初始化并不合适,势必会影响性能。
- 不是所有的指标都需要像 LCP、js error 一样尽可能早地去初始化 , 例如 FP、FMP 这种可以在后期主动获取的指标应该异步加载。
这就需要我们维护一个合适的全局架构来管理这些监控指标,其能力至少包括:
- 每个监控指标的 sdk 可以自行配置同步还是异步加载。
- 各个监控指标的 sdk 应该做到可插拔。
全局模块具体的技术方案与实现待所有监控指标讲解完成之后再来讨论。
优化策略
对于 FP、FCP 侧重于首次渲染,那我们不难想到如下问题:什么情况会阻塞渲染?我们有下面的结论(下面结论的原因不是本文的重点,读者可以自行编写代码验证)
script
会阻止 DOM 的渲染。- 外部
script
标签会触发一次渲染。
所以,我们可以尝试下面的优化策略:
- 如非必要,不要往
<head/>
里面添加 script,而应该放到 DOM 的底部。 - 尽量不使用内联 script 标签,因为外部 script 标签会触发一次渲染,让页面尽可能早地出现。
- 使用 script 的
async
、defer
属性。
另外,我们还可以对关键请求进行预加载,preload 提供了一种声明式的命令,让浏览器提前加载指定资源(加载后并不执行),在需要执行的时候再执行:
<!-- 使用 link 标签静态标记需要预加载的资源 -->
<link rel="preload" href="/path/to/style.css" as="style">
<!-- 或使用脚本动态创建一个 link 标签后插入到 head 头部 -->
<script>
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'style';
link.href = '/path/to/style.css';
document.head.appendChild(link);
</script>