面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:
yunmz777
。
FMP (First Meaningful Paint) 是 Web 性能优化中的一个重要指标,用于衡量页面在加载过程中,用户看到页面的"首次有意义内容"的时间。具体来说,它表示的是用户在浏览器中看到内容的第一刻------即页面的 可见性 和 可交互性 发生了变化。
FMP 作为衡量页面加载速度的一个关键指标,它专注于页面 可视区域 的内容渲染,而不仅仅是页面的加载完成。FMP 试图衡量的是,用户可以开始看到和与之交互的最早时刻,而不是页面完全加载完成的时刻(例如,当 load 事件触发时)。
换句话说,FMP 让你知道在页面加载过程中,用户可以开始实际感知和使用页面的时间,这对于优化 用户体验 和 互动性 至关重要。
首先我们看看下面的图:
我们可以发现在页面中比较 useful 的内容,都是含有信息量比较丰富的,比如图片,视频,动画,另外就是占可视面积较大的,页面中还存在两种形态的内容可以被视为是 useful 的,一种是单一的块状元素,另外一种是由多个元素组合而成的大元素,比如视频元素,banner 图,这种属于单一的块状元素,而像图片列表,多图像的组合,这种属于元素组合
讨论 FMP,实际上就是回答 is it useful?
这个问题。通常业界会将 FMP 的时间当成是首屏时间,虽然在绝对准确度方面不会相等,但是都可以精准的反映出当前页面的加载和渲染的性能情况,FMP 通常被认为是用户获取到了页面主要信息的时刻,也就是说此时用户的需求是得到了满足的,所以产品通常也会关注 FMP 指标。
总结一下成为 FMP 元素的条件:
-
体积占比比较大
-
屏幕内可见占比大
-
资源加载元素占比更高(img, svg , video , object , embed, canvas)
-
主要元素可能是多个组成的
算法如何设置
前面介绍了 FMP 的概念还有成为 FMP 的条件,接下来我们来看看如何设计 FMP 获取的算法,按照上面的介绍,我们知道算法分为以下两个部分:
-
获取 FMP 元素
-
计算 FMP 元素的加载时间
具体的算法流程如下图:
完整代码如下所示:
ts
interface FMPScore {
score: number;
elements: HTMLElement[];
}
class FMPTiming {
private observer: MutationObserver | null = null;
private startTime: number;
private entries: { time: number; score: number }[] = [];
private stopped = false;
private timer: number | null = null;
// 权重配置
private static readonly WEIGHT_MAP = {
IMG: 2,
SVG: 2,
CANVAS: 4,
VIDEO: 4,
OBJECT: 4,
EMBED: 4,
// 其他元素权重为 1
} as const;
// 忽略的标签
private static readonly IGNORE_TAGS = new Set([
"SCRIPT",
"STYLE",
"META",
"HEAD",
"LINK",
"NOSCRIPT",
]);
constructor() {
this.startTime = performance.now();
this.init();
}
public getFMP(): Promise<number> {
return new Promise((resolve) => {
this.onFMP = resolve;
});
}
private onFMP: ((time: number) => void) | null = null;
private init(): void {
// 首次计算
this.calculateScore();
// 观察 DOM 变化
this.observer = new MutationObserver(this.handleMutations);
this.observer.observe(document, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
// 设置超时检查
this.timer = window.setTimeout(
() => this.stop(),
10000
) as unknown as number;
// 监听页面加载完成
if (document.readyState === "complete") {
this.checkFMP();
} else {
window.addEventListener("load", () => this.checkFMP());
}
}
private handleMutations = (): void => {
if (this.stopped) return;
this.calculateScore();
};
private calculateScore(): void {
const score = this.getPageScore();
const time = performance.now() - this.startTime;
this.entries.push({ time, score: score.score });
// 如果发现分数显著增加,可能就是 FMP 时刻
this.checkFMP();
}
private getPageScore(): FMPScore {
const elements: HTMLElement[] = [];
let totalScore = 0;
const walk = (node: HTMLElement) => {
if (node.nodeType !== Node.ELEMENT_NODE) return 0;
const tagName = node.tagName.toUpperCase();
if (FMPTiming.IGNORE_TAGS.has(tagName)) return 0;
// 计算元素分数
const rect = node.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return 0;
// 检查元素是否在视口内
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (
rect.bottom < 0 ||
rect.right < 0 ||
rect.top > viewportHeight ||
rect.left > viewportWidth
) {
return 0;
}
// 计算权重
const weight =
FMPTiming.WEIGHT_MAP[tagName as keyof typeof FMPTiming.WEIGHT_MAP] || 1;
// 计算元素分数
const score = rect.width * rect.height * weight;
// 检查背景图片
const style = window.getComputedStyle(node);
const hasBgImage =
style.backgroundImage && style.backgroundImage !== "none";
const finalScore = hasBgImage ? score * 2 : score;
elements.push(node);
totalScore += finalScore;
// 递归计算子元素
for (const child of Array.from(node.children)) {
totalScore += walk(child as HTMLElement);
}
return totalScore;
};
walk(document.body);
return { score: totalScore, elements };
}
private checkFMP(): void {
if (this.stopped || this.entries.length < 2) return;
const entries = this.entries;
let maxIncrease = 0;
let fmpTime = 0;
// 寻找最大分数增长点
for (let i = 1; i < entries.length; i++) {
const increase = entries[i].score - entries[i - 1].score;
if (increase > maxIncrease) {
maxIncrease = increase;
fmpTime = entries[i].time;
}
}
// 如果找到显著变化或页面加载完成,则停止检测
if (
maxIncrease > 0 &&
(document.readyState === "complete" ||
maxIncrease > entries[0].score * 0.5)
) {
this.stop();
if (this.onFMP) {
this.onFMP(Math.round(fmpTime));
}
}
}
private stop(): void {
if (this.stopped) return;
this.stopped = true;
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
}
}
export default FMPTiming;
在 React 中应该这样使用:
jsx
const measurePerformance = async () => {
try {
const fmpTime = await measureFMP();
console.log("First Meaningful Paint:", fmpTime, "ms");
// 可以将 FMP 数据发送到你的分析服务
// await sendToAnalytics({ fmp: fmpTime });
} catch (error) {
console.error("Failed to measure FMP:", error);
}
};
useEffect(() => {
measurePerformance();
}, []);
接下来我们将对代码进行完整的分析:
- 构造函数 constructor
在构造函数中,startTime 用于记录开始时间,调用 performance.now() 获取页面加载开始的时间。而 init() 方法初始化检测器,启动了 DOM 变化观察、超时处理和页面加载监听等功能。
ts
constructor() {
this.startTime = performance.now();
this.init();
}
- 初始化 init 方法
ts
private init(): void {
this.calculateScore();
this.observer = new MutationObserver(this.handleMutations);
this.observer.observe(document, {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
this.timer = window.setTimeout(() => this.stop(), 10000) as unknown as number;
if (document.readyState === 'complete') {
this.checkFMP();
} else {
window.addEventListener('load', () => this.checkFMP());
}
}
calculateScore()
用于计算当前页面的得分;MutationObserver
监听 DOM 树的变化,当有新元素添加、删除或属性变化时,它会触发 handleMutations
回调,重新计算得分并检查 FMP。通过 setTimeout
设置超时机制,最多等待 10 秒钟,超时则停止检测。而 window.addEventListener('load', ...)
监听页面加载完成事件,确保在页面加载完成后调用 checkFMP()
方法检查是否已到达 FMP 时刻。
- 检测 FMP 时的回调 checkFMP
ts
private checkFMP(): void {
if (this.stopped || this.entries.length < 2) return;
const entries = this.entries;
let maxIncrease = 0;
let fmpTime = 0;
for (let i = 1; i < entries.length; i++) {
const increase = entries[i].score - entries[i - 1].score;
if (increase > maxIncrease) {
maxIncrease = increase;
fmpTime = entries[i].time;
}
}
if (
maxIncrease > 0 &&
(document.readyState === 'complete' || maxIncrease > entries[0].score * 0.5)
) {
this.stop();
if (this.onFMP) {
this.onFMP(Math.round(fmpTime));
}
}
}
checkFMP()
通过分析每个时间点的得分,找出得分变化最显著的时刻,作为首次重要内容渲染的时刻。首先,检查 this.entries
是否至少有 2 个得分记录。然后,计算每两个时间点之间的得分变化,找出最大变化点作为潜在的 FMP 时刻。如果找到了显著的得分变化,且页面加载完成或得分变化超过 50%,则停止检测并触发 onFMP
回调,返回 FMP 时刻。
- DOM 变化处理 handleMutations
ts
private handleMutations = (): void => {
if (this.stopped) return;
this.calculateScore();
};
每次 DOM 树发生变化时(如新增元素),会调用 handleMutations
方法。该方法会进一步调用 calculateScore()
,重新计算页面的得分。
- 得分计算 calculateScore
ts
private calculateScore(): void {
const score = this.getPageScore();
const time = performance.now() - this.startTime;
this.entries.push({ time, score: score.score });
this.checkFMP();
}
calculateScore
调用 getPageScore()
计算当前页面的得分,同时记录时间戳,并将得分和时间戳存储到 entries
数组中。在计算完得分后,调用 checkFMP()
方法检查是否已经到达 FMP 时刻。
- 页面得分计算 getPageScore
ts
private getPageScore(): FMPScore {
const elements: HTMLElement[] = [];
let totalScore = 0;
const walk = (node: HTMLElement) => {
if (node.nodeType !== Node.ELEMENT_NODE) return 0;
const tagName = node.tagName.toUpperCase();
if (FMPTiming.IGNORE_TAGS.has(tagName)) return 0;
const rect = node.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return 0;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (
rect.bottom < 0 ||
rect.right < 0 ||
rect.top > viewportHeight ||
rect.left > viewportWidth
) {
return 0;
}
const weight = FMPTiming.WEIGHT_MAP[tagName as keyof typeof FMPTiming.WEIGHT_MAP] || 1;
const score = rect.width * rect.height * weight;
const style = window.getComputedStyle(node);
const hasBgImage = style.backgroundImage && style.backgroundImage !== 'none';
const finalScore = hasBgImage ? score * 2 : score;
elements.push(node);
totalScore += finalScore;
for (const child of Array.from(node.children)) {
totalScore += walk(child as HTMLElement);
}
return totalScore;
};
walk(document.body);
return { score: totalScore, elements };
}
getPageScore
方法遍历页面的所有元素,计算每个元素的得分,并根据是否包含背景图片进行加权处理。在计算元素得分时,首先检查该元素是否可见,并计算其尺寸;若尺寸为零或超出视口,则跳过该元素。根据元素类型(如图片、视频、Canvas 等),使用不同的权重来计算得分。
参考资料
总结
主要思路是通过监控 DOM 变化来计算页面元素的得分,找出最大得分变化的时刻,从而确定 FMP。得分计算通过遍历页面中可见的元素,并根据元素类型、大小、背景图片等因素来计算得分。FMP 时刻的判定则是通过得分变化来判断,首次出现显著得分增加的时刻即为 FMP。该算法通过不断观察 DOM 更新,动态跟踪页面加载过程,并利用页面内容的变化来检测 FMP,提供了一个高效且灵活的检测机制。