项目性能优化实践:深入FMP算法原理探索|得物技术
一、前 言
最近在项目中遇到了页面加载速度优化的问题,为了提高秒开率等指标,我决定从eebi报表入手,分析一下当前项目的性能监控体系。
通过查看报表中的cost_time、is_first等字段,我开始了解项目的性能数据采集情况。为了更好地理解这些数据的含义,我深入研究了相关SDK的源码实现。
在分析过程中,我发现采集到的cost_time参数实际上就是FMP(First Meaningful Paint) 指标。于是我对FMP的算法实现进行了梳理,了解了它的计算逻辑。
本文将分享我在性能优化过程中的一些思考和发现,希望能对关注前端性能优化的同学有所帮助。
二、什么是FMP
FMP (First Meaningful Paint) 首次有意义绘制,是指页面首次绘制有意义内容的时间点。与 FCP (First Contentful Paint) 不同,FMP 更关注的是对用户有实际价值的内容,而不是任何内容的首次绘制。
三、FMP 计算原理
3.1核心思想
FMP 的核心思想是:通过分析视口内重要 DOM 元素的渲染时间,找到对用户最有意义的内容完成渲染的时间点。
3.2FMP的三种计算方式
- 新算法 FMP (specifiedValue) 基于用户指定的 DOM 元素计算通过fmpSelector配置指定元素计算指定元素的完整加载时间
- 传统算法 FMP (value) 基于视口内重要元素计算选择权重最高的元素取所有参考元素中最晚完成的时间
- P80 算法 FMP (p80Value) 基于 P80 百分位计算取排序后80%位置的时间更稳定的性能指标
3.3新算法vs传统算法
传统算法流程
- 遍历整个DOM树
- 计算每个元素的权重分数
- 选择多个重要元素
- 计算所有元素的加载时间
- 取最晚完成的时间作为FMP
新算法(指定元素算法)流程
核心思想: 直接指定一个关键 DOM 元素,计算该元素的完整加载时间作为FMP。
传统算法详细步骤
第一步:DOM元素选择
// 递归遍历 DOM 树,选择重要元素
selectMostImportantDOMs(dom: HTMLElement = document.body): void {
const score = this.getWeightScore(dom);
if (score > BODY_WEIGHT) {
// 权重大于 body 权重,作为参考元素
this.referDoms.push(dom);
} else if (score >= this.highestWeightScore) {
// 权重大于等于最高分数,作为重要元素
this.importantDOMs.push(dom);
}
// 递归处理子元素
for (let i = 0, l = dom.children.length; i < l; i++) {
this.selectMostImportantDOMs(dom.children[i] as HTMLElement);
}
}
第二步:权重计算
// 计算元素权重分数
getWeightScore(dom: Element) {
// 获取元素在视口中的位置和大小
const viewPortPos = dom.getBoundingClientRect();
const screenHeight = this.getScreenHeight();
// 计算元素在首屏中的可见面积
const fpWidth = Math.min(viewPortPos.right, SCREEN_WIDTH) - Math.max(0, viewPortPos.left);
const fpHeight = Math.min(viewPortPos.bottom, screenHeight) - Math.max(0, viewPortPos.top);
// 权重 = 可见面积 × 元素类型权重
return fpWidth * fpHeight * getDomWeight(dom);
}
权重计算公式:
权重分数 = 可见面积 × 元素类型权重
元素类型权重:
- OBJECT, EMBED, VIDEO: 最高权重
- SVG, IMG, CANVAS: 高权重
- 其他元素: 权重为 1
第三步:加载时间计算
getLoadingTime(dom: HTMLElement, resourceLoadingMap: Record<string, any>): number {
// 获取 DOM 标记时间
const baseTime = getMarkValueByDom(dom);
// 获取资源加载时间
let resourceTime = 0;
if (RESOURCE_TAG_SET.indexOf(tagType) >= 0) {
// 处理图片、视频等资源
const resourceTiming = resourceLoadingMap[resourceName];
resourceTime = resourceTiming ? resourceTiming.responseEnd : 0;
}
// 返回较大值(DOM 时间 vs 资源时间)
return Math.max(resourceTime, baseTime);
}
第四步:FMP值计算
calcValue(resourceLoadingMap: Record<string, any>, isSubPage: boolean = false): void {
// 构建参考元素列表(至少 3 个元素)
const referDoms = this.referDoms.length >= 3
? this.referDoms
: [...this.referDoms, ...this.importantDOMs.slice(this.referDoms.length - 3)];
// 计算每个元素的加载时间
const timings = referDoms.map(dom => this.getLoadingTime(dom, resourceLoadingMap));
// 排序时间数组
const sortedTimings = timings.sort((t1, t2) => t1 - t2);
// 计算最终值
const info = getMetricNumber(sortedTimings);
this.value = info.value; // 最后一个元素的时间(最晚完成)
this.p80Value = info.p80Value; // P80 百分位时间
}
新算法详细步骤
第一步:配置指定元素
// 通过全局配置指定 FMP 目标元素
const { fmpSelector = "" } = SingleGlobal?.getOptions?.();
配置示例:
// 初始化时配置
init({
fmpSelector: '.main-content', // 指定主要内容区域
// 或者
fmpSelector: '#hero-section', // 指定首屏区域
// 或者
fmpSelector: '.product-list' // 指定产品列表
});
第二步:查找指定元素
if (fmpSelector) {
// 使用 querySelector 查找指定的 DOM 元素
const $specifiedEl = document.querySelector(fmpSelector);
if ($specifiedEl && $specifiedEl instanceof HTMLElement) {
// 找到指定元素,进行后续计算
this.specifiedDom = $specifiedEl;
}
}
查找逻辑:
- 使用document.querySelector()查找元素
- 验证元素存在且为 HTMLElement 类型
- 保存元素引用到specifiedDom
第三步:计算指定元素的加载时间
// 计算指定元素的完整加载时间
this.specifiedValue = this.getLoadingTime(
$specifiedEl,
resourceLoadingMap
);
加载时间计算包含:
- DOM 标记时间
// 获取 DOM 元素的基础标记时间
const baseTime = getMarkValueByDom(dom);
- 资源加载时间
let resourceTime = 0;
// 处理直接资源(img, video, embed 等)
const tagType = dom.tagName.toUpperCase();
if (RESOURCE_TAG_SET.indexOf(tagType) >= 0) {
const resourceName = normalizeResourceName((dom as any).src);
const resourceTiming = resourceLoadingMap[resourceName];
resourceTime = resourceTiming ? resourceTiming.responseEnd : 0;
}
// 处理背景图片
const bgImgUrl = getDomBgImg(dom);
if (isImageUrl(bgImgUrl)) {
const resourceName = normalizeResourceName(bgImgUrl);
const resourceTiming = resourceLoadingMap[resourceName];
resourceTime = resourceTiming ? resourceTiming.responseEnd : 0;
}
- 综合时间计算
// 返回 DOM 时间和资源时间的较大值
return Math.max(resourceTime, baseTime);
第四步:FMP值确定
// 根据是否有指定值来决定使用哪个 FMP 值
if (specifiedValue === 0) {
// 如果没有指定值,回退到传统算法
fmp = isSubPage ? value - diffTime : value;
} else {
// 如果有指定值,使用指定值
fmp = isSubPage ? specifiedValue - diffTime : specifiedValue;
}
决策逻辑:
- 如果 specifiedValue > 0:使用指定元素的加载时间
- 如果 specifiedValue === 0:回退到传统算法
第五步:子页面时间调整
// 子页面的 FMP 值需要减去时间偏移
if (isSubPage) {
fmp = specifiedValue - diffTime;
// diffTime = startSubTime - initTime
}
新算法的优势
精确性更高
- 直接针对业务关键元素
- 避免权重计算的误差
- 更贴近业务需求
可控性强
- 开发者可以指定关键元素
- 可以根据业务场景调整
- 避免算法自动选择的偏差
计算简单
- 只需要计算一个元素
- 不需要复杂的权重计算
- 性能开销更小
业务导向
- 直接反映业务关键内容的加载时间
- 更符合用户体验评估需求
- 便于性能优化指导
3.4关键算法
P80 百分位计算
export function getMetricNumber(sortedTimings: number[]) {
const value = sortedTimings[sortedTimings.length - 1]; // 最后一个(最晚)
const p80Value = sortedTimings[Math.floor((sortedTimings.length - 1) * 0.8)]; // P80
return { value, p80Value };
}
元素类型权重
const IMPORTANT_ELEMENT_WEIGHT_MAP = {
SVG: IElementWeight.High, // 高权重
IMG: IElementWeight.High, // 高权重
CANVAS: IElementWeight.High, // 高权重
OBJECT: IElementWeight.Highest, // 最高权重
EMBED: IElementWeight.Highest, // 最高权重
VIDEO: IElementWeight.Highest // 最高权重
};
四、时间标记机制
4.1DOM变化监听
// MutationObserver 监听 DOM 变化
private observer = new MutationObserver((mutations = []) => {
const now = Date.now();
this.handleChange(mutations, now);
});
4.2时间标记
// 为每个 DOM 变化创建性能标记
mark(count); // 创建 performance.mark(`mutation_pc_${count}`)
// 为 DOM 元素设置标记
setDataAttr(elem, TAG_KEY, `${mutationCount}`);
4.3标记值获取
// 根据 DOM 元素获取标记时间
getMarkValueByDom(dom: HTMLElement) {
const markValue = getDataAttr(dom, TAG_KEY);
return getMarkValue(parseInt(markValue));
}
五、资源加载考虑
5.1资源类型识别
图片资源: 标签的 src属性
视频资源: 标签的 src属性
背景图片: CSS background-image属性
嵌入资源: , 标签
5.2资源时间获取
// 从 Performance API 获取资源加载时间
const resourceTiming = resourceLoadingMap[resourceName];
const resourceTime = resourceTiming ? resourceTiming.responseEnd : 0;
5.3综合时间计算
// DOM 时间和资源时间的较大值
return Math.max(resourceTime, baseTime);
六、子页面支持
6.1时间偏移处理
// 子页面从调用 send 方法开始计时
const diffTime = this.startSubTime - this.initTime;
// 子页面只统计开始时间之后的资源
if (!isSubPage || resource.startTime > diffTime) {
resourceLoadingMap[resourceName] = resource;
}
6.2FMP值调整
// 子页面的 FMP 值需要减去时间偏移
fmp = isSubPage ? value - diffTime : value;
七、FMP的核心优势
7.1用户感知导向
FMP 最大的优势在于它真正关注用户的实际体验:
- 内容价值优先:只计算对用户有意义的内容渲染时间
- 智能权重评估:根据元素的重要性和可见性进行差异化计算
- 真实体验映射:更贴近用户的实际感知,而非技术层面的指标
7.2多维度计算体系
FMP 采用了更加全面的计算方式:
- 元素权重分析:综合考虑元素类型和渲染面积的影响
- 资源加载关联:将静态资源加载时间纳入计算范围
- 算法对比验证:支持多种算法并行计算,确保结果准确性
7.3高精度测量
FMP 在测量精度方面表现突出:
- DOM 变化追踪:基于实际 DOM 结构变化的时间点
- API 数据融合:结合 Performance API 提供的详细数据
- 统计分析支持:支持 P80 百分位等多种统计指标,便于性能分析
八、FMP的实际应用场景
8.1性能监控实践
FMP 在性能监控中发挥着重要作用:
- 关键指标追踪:实时监控页面首次有意义内容的渲染时间
- 瓶颈识别:快速定位性能瓶颈和潜在的优化点
- 趋势分析:通过历史数据了解性能变化趋势
8.2用户体验评估
FMP 为产品团队提供了用户视角的性能评估:
- 真实感知测量:评估用户实际感受到的页面加载速度
- 竞品对比分析:对比不同页面或产品的性能表现
- 用户满意度关联:将技术指标与用户满意度建立关联
8.3优化指导价值
FMP 数据为性能优化提供了明确的方向:
- 资源优化策略:指导静态资源加载顺序和方式的优化
- 渲染路径优化:帮助优化关键渲染路径,提升首屏体验
- 量化效果评估:为优化效果提供可量化的评估标准
九、总结
通过这次深入分析,我对 FMP 有了更全面的认识。FMP 通过科学的算法设计,能够准确反映用户感知的页面加载性能,是前端性能监控的重要指标。
它不仅帮助我们更好地理解页面加载过程,更重要的是为性能优化提供了科学的依据。在实际项目中,合理运用 FMP 指标,能够有效提升用户体验,实现真正的"秒开"效果。
希望这篇文章能对正在关注前端性能优化的同学有所帮助,也欢迎大家分享自己的实践经验。
往期回顾
1. Dragonboat统一存储LogDB实现分析|得物技术
2. 从数字到版面:得物数据产品里数字格式化的那些事
3. 一文解析得物自建 Redis 最新技术演进
4. Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术
5. RN与hawk碰撞的火花之C++异常捕获|得物技术
文 /阿列
关注得物技术,每周更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。