前端性能指标 —— FMP

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

接下来我们将对代码进行完整的分析:

  1. 构造函数 constructor

在构造函数中,startTime 用于记录开始时间,调用 performance.now() 获取页面加载开始的时间。而 init() 方法初始化检测器,启动了 DOM 变化观察、超时处理和页面加载监听等功能。

ts 复制代码
constructor() {
  this.startTime = performance.now();
  this.init();
}
  1. 初始化 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 时刻。

  1. 检测 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 时刻。

  1. DOM 变化处理 handleMutations
ts 复制代码
private handleMutations = (): void => {
  if (this.stopped) return;
  this.calculateScore();
};

每次 DOM 树发生变化时(如新增元素),会调用 handleMutations 方法。该方法会进一步调用 calculateScore(),重新计算页面的得分。

  1. 得分计算 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 时刻。

  1. 页面得分计算 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,提供了一个高效且灵活的检测机制。

相关推荐
几何心凉1 分钟前
两款好用的工具,大模型训练事半功倍.....
前端
Pandaconda10 分钟前
【后端开发面试题】每日 3 题(十二)
数据库·后端·面试·负载均衡·高并发·后端开发·acid
uhakadotcom23 分钟前
阿里云MaxFrame分布式计算框架:十倍性能提升的Python大数据利器
算法·面试·github
uhakadotcom25 分钟前
实时计算Flink版:解锁数据处理新世界
后端·面试·github
Dontla26 分钟前
黑马node.js教程(nodejs教程)——AJAX-Day01-04.案例_地区查询——查询某个省某个城市所有地区(代码示例)
前端·ajax·node.js
uhakadotcom27 分钟前
Hologres实时数仓引擎:简化数据处理与分析
后端·面试·github
威哥爱编程27 分钟前
vue2和vue3的响应式原理有何不同?
前端·vue.js
呆呆的猫30 分钟前
【前端】Vue3 + AntdVue + Ts + Vite4 + pnpm + Pinia 实战
前端
qq_4560016532 分钟前
30、Vuex 为啥可以进行缓存处理
前端
浪裡遊1 小时前
Nginx快速上手
运维·前端·后端·nginx