一、前 言
最近在項目中遇到了頁面加載速度優化的問題,為了提高秒開率等指標,我決定從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資源類型識別
圖片資源: <img> 標籤的 src屬性
視頻資源: <video> 標籤的 src屬性
背景圖片: CSS background-image屬性
嵌入資源: <embed>, <object>標籤
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++異常捕獲|得物技術
文 /阿列
關注得物技術,每週更新技術乾貨
要是覺得文章對你有幫助的話,歡迎評論轉發點贊~
未經得物技術許可嚴禁轉載,否則依法追究法律責任。