前端监控FMP原理
总述
这是前端监控原理系列文章的第三部分。在这篇文章中,会为大家讲解:
- FMP(First Meaningful Paint,即首次有意义内容绘制时间) 的背景、概念以及缺陷。
- FMP 监控实现:基于布局变动趋势的 FMP 算法。
FMP 的概念
FMP(首次有意义内容绘制时间,First Meaningful Paint)是页面的主要内容被渲染到屏幕的时间点,换句话来说,即页面的主要内容何时对用户可见。这是我们衡量网页加载对用户体验的一个重要指标。
指标计算原理 -- 基于布局变动趋势衡量 FMP
FMP 并不像 FP、FCP 一样有浏览器直接提供的 API,需要开发者自行研究算法实现,这里介绍一种基于布局变动趋势衡量的算法:基本原理是依照 DOM 的复杂度为当前 DOM 设定一个分数,分析 DOM 分数的变动程度来找出合适的 FMP 时间点,而 DOM 的复杂程度和 DOM 的深度息息相关。
TIP: 由于计算 FMP 比较耗费性能,而且在实践中,FMP并不是一个十分准确的值,这和被检测网页的 DOM 性质、用户衡量 FMP 的算法息息相关。
此外,该指标的定义依赖于特定于浏览器的实现细节,这意味着它不能标准化,也不能在所有 Web 浏览器中实现。
核心思路
随着页面加载的进行,布局对象(layout objects)会逐渐添加到布局树中(layout tree)。下面的第一幅图中展示了打开 Google 搜索时,大量的布局对象被添加到布局树中的过程,第二幅图则是重要时间点的屏幕截图,我们可得出以下结论:
- 在 0 - 1.57 秒,页面只渲染了一个搜索框和一个 tabs header,布局对象约为 70 个。此时可以看成是 FCP 的时间点。
- 在 1.57 - 1.76 秒,顶部的 header 被渲染。
- 在 1.76 秒,第一个搜索结果被渲染,此时布局对象有 100 个左右。
- 在 1.8 - 1.9 秒左右,有更多的搜索结果被渲染,另外布局对象的数量也发生了较大的变动,到达了 300 个左右。此时,页面的主要内容都已经渲染完成,从产品的角度来看,页面的主要信息已经基本呈现,满足用户要求,所以这个时间点我们可以看成 FMP。
所以,大量布局对象变动的那个时间点可能就是 FMP,也就是:
FMP = 布局改变最大的那一次绘制
在上图中,布局改变最大的一次绘制是 1.907 秒,故 FMP = 1.907。
注意不可见区域
上面的计算方法并不是很完善。请看下面的布局对象和对应网页的案例:
- 在 6.047 秒和 24.25 秒,发生了一次大的布局变动,其中 24.25 秒的布局变动更大,根据上面的算法,它应该是 FMP。
- 但是 24.25 秒的变动来自于不可见的区域(页面高度大于浏览器窗口高度),用户没有任何感知,这个不应该称之为有意义的变动。
- 另外在 6.047 秒,页面的主要内容(基本骨架、文本)都已经呈现,它应该才是我们最终的 FMP,也就是:
FMP = 可见区域布局改变最大的那一次绘制
具体实现
如何计算某个元素是否可见?
-
H5 提供了
Element.getBoundingClientRect()
这个 API,可以获取元素距离视口的相对位置、宽度和高度,以下情况均可以排除:- 元素的位置不在可视范围内,包括横向和纵向的。
- 元素的高度、宽度小于等于 0。
-
另外,注意考虑 CSS 的影响:
visibility === 'hidden'
,这种情况下元素占据空间,有宽度和高度,但不可见。display === 'none'
,这种情况不占据元素空间,宽度和高度都为 0,可以和上面的判断合并。
-
但是,出于性能考虑,应该尽可能少的去获取 DOM 的宽高或者样式以避免浏览器回流 & 重绘,在实际应用中我们可以忽略一些边界情况,例如在拥有横向滚动条时,右侧的元素不在视口中,或者 css 样式为
visibility === 'hidden'
的情况。
实现代码如下:
// 为确保性能,exact 默认为 false 以忽略一些边界情况
const isUnderView = top > window.innerHeight;
let isNotVisible: boolean;
if (!exact) {
isNotVisible = height <= 0;
} else {
isNotVisible = height <= 0 || width <= 0 || element.style.visibility === 'hidden';
}
console.log(isNotVisible);
如何判断布局变动情况?
浏览器有一个 MutationObserver()
API,该接口提供了监听 DOM 树变动的能力。通过这个 API,我们可以在 DOM 发生变动时,在注册的回调函数做出一些分析 DOM 的操作(后面详细讲解),从而判断出布局改变的程度,以获得合适的 FMP 时间。
值得注意的是,我们得考虑一些性能问题:
- 在上一小节中提到我们需要判断元素的位置,那么这些操作势必会触发浏览器的重绘(paint)甚至重新布局(layout) , 这些代码的执行时机应该要好好考量。
MutationObserver()
API 在 DOM 发生变动的时候就会回调,并且我们的分析操作会在其中执行,这个回调函数很可能会被频繁调用,需要进行一些处理。
对于上面提出的两条性能需求, window.requestAnimationFrame
(下面简称 RAF)可以帮到我们,RAF 在浏览器下一帧开始时执行,避免使用 getBoundingClientRect
之类的 API 导致强制回流。
如何对 DOM 进行分析?
一个合理方法是分析出 DOM 的复杂程度并为其打分,理论上 DOM 嵌套层级越深、子节点越多,DOM 树越“茂密”,其复杂程度也越高,如何某一个时刻 DOM 分数变化的越大,那么此时很有可能就是 FMP,下面先给出计算分数的代码:
type HTMLElementWithCss = HTMLElement & {
readonly style?: CSSStyleDeclaration;
};
// 需要忽略的功能性标签
export const IGNORE_TAGS = ['SCRIPT', 'STYLE', 'META', 'HEAD'];
/**
* 递归地获取 DOM 布局分数, 该分数体现了某个节点的复杂程度
* 注:不在视口中的子元素不会被考虑
* @param element 根 dom 元素
* @param depth 当前元素的深度
* @param isSiblingExists 符合标准的(在视口中)的兄弟节点是否存在
* @param exact 是否开启精确模式,如果开启,则还会验证元素的宽度和 css 样式属性,确保不在视口内,这可能会影响性能,默认为 false
*/
export const getDomLayoutScore = (
element: HTMLElementWithCss,
depth: number,
isSiblingExists: boolean,
exact?: boolean
) => {
const { tagName, children } = element;
if (!element || IGNORE_TAGS.includes(tagName)) {
return 0;
}
const childNodes = Array.from(children || []) as HTMLElementWithCss[];
const childrenScore = childNodes.reduceRight((siblingScore, currentNode) => {
// 如果它的右子树兄弟分数存在,则无需计算 dom 位置
const score = getDomLayoutScore(currentNode, depth + 1, siblingScore > 0, exact, onGetScore);
return siblingScore + score;
}, 0);
// 如果有必要的话,会对该元素的位置进行 check
// 需要满足的条件:1. 它的相邻兄弟节点没有分数 2. 它不是叶子节点
const isPositionCheckNeeded = childrenScore <= 0 && !isSiblingExists;
if (isPositionCheckNeeded) {
if (!isFunction(element.getBoundingClientRect)) {
return 0;
}
const { top, height, width } = element.getBoundingClientRect();
// 这个 dom 元素是否可见,如果不可见那么这个元素对我们的 fmp 没有影响
// https://docs.google.com/document/d/1BR94tJdZLsin5poeet0XoTW60M0SjvOJQttKT-JK8HI/view#
// 主要包括:元素顶部位置是否在视口之下
// 宽度是否小于 0,visibility 是否为 hidden 按理说也应该被考虑进去
// 但是基于性能考虑默认尽可能忽略它们(在实际应用中这样的 DOM 元素应该也很少碰到,但也提供了 exact 选项进行判断)
const isUnderView = top > window.innerHeight;
let isNotVisible: boolean;
if (!exact) {
isNotVisible = height <= 0;
} else {
isNotVisible = height <= 0 || width <= 0 || element.style.visibility === 'hidden';
}
const isElementOutOfView = isUnderView || isNotVisible;
if (isElementOutOfView) {
return 0;
}
}
return childrenScore + 1 + 0.5 * depth;
};
算法(下面称getDomLayoutScore
)描述如下:
-
给定一个根节点 element。
-
如果 element 的 标签是一些功能性标签,例如
script、meta
等,返回 0。 -
获得所有孩子,递归地为每一个孩子执行
getDomLayoutScore
,最终得到所有孩子分数之和。 -
如果该节点的所有孩子的总分小于等于 0 ,且其未存在兄弟节点, 若:
- 该元素在可视范围内,返回孩子的总分 + 1 + 0.5 * 当前节点深度。
- 该元素不在可是范围内,返回 0。
-
如果该节点的所有孩子的总分大于 0 或其存在兄弟节点,则直接返回 孩子的总分 + 1 + 0.5 * 当前节点深度。
不难看出,这个算法主要通过 DOM 的深度和子节点数目来衡量 DOM 的复杂度。
有一些值得注意的优化点:
- 在遍历到一些功能性节点时,直接返回 0 而不再递归,达到剪枝的目的。
- 大部分情况下, 如果孩子节点的总分大于 0,说明孩子存在且在视口范围内,父亲也在视口范围内,不需要再额外获取位置影响性能。
- 大部分情况下,如果下方兄弟节点的总分大于 0,说明下方兄弟节点在视口范围内,则当前节点也在视口范围内,不需要再额外获取位置影响性能。
- 对于校验视口范围的方法可以选择精确度,供开发者按照项目的特性自行选择(默认性能优先)。
综合上述内容,我们可以在某个时刻,通过下面的调用来获取某个 DOM 元素的得分:
const myScore = getDomLayoutScore(document.body, 1, false);
下面的 CodePen 案例以可视化的形式展示了上面的递归调用逻辑,读者可以更好理解上面的算法:
- 空白的方框表示这个元素的分数为 0(不在视口内)
isPositionCheckedNeeded
表示这个元素是否执行了getBoundingClientRect()
,你可以发现基于上面提到的优化策略,只有很少的元素执行了此操作,大大提高了性能。- depth 和 score 表示当前节点的深度和总分。
无法复制加载中的内容
接着,利用上面提到的 MutationObserver
和 requestAnimationFrame
调度它,首先对 raf 进行一次封装,这里有个小注意点,那就是 requestAnimationFrame
不管理回调函数,也就是说绑定 MutationObserver
的带有 raf 的回调函数被执行多次 -> 导致最终的 DOM 打分在同一帧内执行多次,影响性能,可以看下面这个 CodePen DEMO:
无法复制加载中的内容
上面问题的解决方案使用我们熟悉的节流函数包装回调,或者增加一个标记变量用来标记是否已经初始化,如果是,利用 cancelAnimationFrame
将旧的回调取消而使用新的,具体实现如下:
/**
* 使用 request animation frame 调度某个回调函数
*/
export const useRequestAnimationFrame = (callback: FrameRequestCallback) => {
// getAnimationFrame 只是获取了 window 下的相关 api 函数,无额外作用
const apis = getAnimationFrame();
if (!apis) {
return;
}
const { raf, caf } = apis;
if (!isFunction(raf) || !isFunction(caf)) {
return;
}
// raf 的返回值为非 0 数字
let rafTimer: number = 0;
const runCallback = () => {
if (rafTimer) {
// requestAnimationFrame 不管理回调函数
// 在回调被执行前,多次调用带有同一回调函数的 requestAnimationFrame,会导致回调在同一帧中执行多次
// 常见的情况是一些事件机制导致多次触发
// 设定一个 timer,如果接下来回调再次被调度,那么撤销上一个
// https://www.w3.org/TR/animation-timing/#dom-windowanimationtiming-requestanimationframe
caf(rafTimer);
} else {
rafTimer = raf(callback);
}
};
const cancelCallback = () => {
if (rafTimer) {
caf(rafTimer);
}
};
return {
runCallback,
cancelCallback,
};
};
综上所述,主程序代码流程如下:
- 在主程序启动时,初始化
MutationObserver
和当前时间。 - 每当 DOM 变动,
MutationObserver
回调被执行,会在requestAnimationCallback
中注册计算 DOM 分数的回调,在下一次绘制之前调用之。 - 计算 DOM 分数的回调执行完成之后,将本次计算分数和时间保存到一个数组
scoredData
中。 - 在 onload(DOM 和所需资源加载完成的时机) 事件后 1 秒销毁,原因是 FMP 和 onload 时间并没有直接联系,不过按照常理应该是出现在它之前,至于延迟一秒是为了减少误差。
interface FMPRecodeData {
time: number;
domScore: number;
}
export const createFMPMonitor = (options: FMPMonitorOptions) => {
const MutationObserver = getMutationObserver();
if (!MutationObserver) {
return;
}
const startTime = Date.now();
const scoredData: FMPRecodeData[] = [];
const observeFMP = () => {
const callback = useRequestAnimationFrame(() => {
const bodyScore = getDomLayoutScore(document.body, 1, true);
scoredData.push({
domScore: bodyScore,
time: Date.now() - startTime,
});
});
const observer = new MutationObserver(() => {
callback.runCallback();
});
observer.observe(document.body, {
subtree: true,
childList: true,
});
return observer;
};
const observer = observeFMP();
const reportData = () => {
options.onReport({
data: {
fmp: calculateFMP(scoredData).time ,
},
eventType: EventType.FMP,
});
observer.disconnect();
};
// FMP 和 onload 事件并不密切相关,但它很可能在 onload 事件附近,所以我们延时一小段时间再报告
onPageLoad(() => {
setTimeout(() => {
reportData();
}, 1000);
});
};
结束监听之后,就需要处理并上报数据了,在 scoredData
中已经得到了一个或者多个时间点下的 DOM 分数,接下来需要:
- 对
scoredData
按照时间的顺序从小到大排序。 - 计算每两个相邻时间点的 DOM 分数差值,选择一个差值最大的,其对应的较晚的那个时间点就是 FMP,例如下面的代码:
const res = calculateFMP([
{
time: 0,
domScore: 10,
},
{
time: 110,
domScore: 20,
},
{
time: 220,
domScore: 1200,
},
{
time: 1220,
domScore: 1000,
},
]);
不难得出,最终得到的时间点为 220
,对应的分数差为 1200 - 20 = 1180
****
具体实现代码如下(TIP: 这种场景下使用数组的 reduce
方法很方便):
export const calculateFMP = (scoredData: FMPRecodeData[]) => {
// 首先将打分结果基于时间排序(时间戳从小到大)
scoredData.sort((a, b) => a.time - b.time);
// 计算每两个时间戳之间的得分差值,变动最大的即为最终结果
const initInfoValue = {
maxDelta: -1,
time: -1,
prev: {
time: 0,
domScore: 0,
} as FMPRecodeData,
};
const res = scoredData.reduce((info, curr) => {
const delta = curr.domScore - info.prev.domScore;
if (delta > info.maxDelta) {
info.maxDelta = delta;
info.time = curr.time;
}
info.prev = curr;
return info;
}, initInfoValue);
return res;
};