面试导航 是一个专注于前、后端技术学习和面试准备的 免费 学习平台,提供系统化的技术栈学习,深入讲解每个知识点的核心原理,帮助开发者构建全面的技术体系。平台还收录了大量真实的校招与社招面经,帮助你快速掌握面试技巧,提升求职竞争力。如果你想加入我们的交流群,欢迎通过微信联系:
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,提供了一个高效且灵活的检测机制。