首屏加载统计的几个问题梳理
前言
前端性能优化方面离不开白屏的问题,今天只是侧重复习下白屏的计算方式与系统性的记录方式。关于白屏的性能优化方面有很多,大家可以先看看下面这位老哥写的性能优化的几种方式
关于白屏
基本定义:白屏时间(FP)是用户从发起请求到浏览器首次渲染出像素(结束白屏)的耗时。
所以按照上面的定义,白屏会经历以下几个阶段:
-
DNS解析:浏览器将域名解析为IP地址。
-
建立TCP连接:浏览器与服务器建立TCP连接(三次握手)。
-
发起HTTP请求:浏览器向服务器发送HTTP请求。
-
服务器响应:服务器处理请求并返回响应数据。
-
浏览器解析HTML:浏览器解析HTML文档并构建DOM树。
-
浏览器渲染页面:浏览器根据DOM树和CSSOM树生成渲染树,并开始渲染页面。
-
页面展示html元素:浏览器首次将页面内容渲染到屏幕上。
关于白屏的合理计算
白屏的计算始于浏览器,所以计算的依据也是浏览器提供,浏览器 Performance API 中提供了计算依据。
一、Performance API 是什么?
简单来说,Performance API 是浏览器提供的一套原生接口,专门用于精确测量、监控和分析网页的性能数据(比如页面加载时间、资源加载耗时、自定义代码执行耗时等),是前端性能优化的核心工具之一。
二、利用Performance API提供的方法:performance.timing 常规的计算方式如下:
下面是 performance.timing 里最常用的时间戳,以及它们的含义:
| 时间戳属性 | 含义 |
|---|---|
navigationStart |
页面开始导航的时间(比如用户输入网址回车、点击链接),是整个流程的起点 |
domainLookupStart |
开始 DNS 域名解析的时间 |
domainLookupEnd |
DNS 域名解析完成的时间 |
connectStart |
开始建立 TCP 连接的时间 |
connectEnd |
TCP 连接建立完成的时间(如果是 HTTPS,包含 SSL 握手) |
requestStart |
浏览器向服务器发送请求的时间 |
responseStart |
浏览器收到服务器第一个字节响应的时间(首字节时间,TTFB) |
responseEnd |
浏览器接收完服务器响应数据的时间 |
domContentLoadedEventEnd |
DOM 解析完成,且所有 DOMContentLoaded 事件回调执行完毕的时间 |
loadEventEnd |
页面 load 事件触发且所有回调执行完毕的时间(页面完全加载完成) |
三、简单用法:计算各阶段耗时
通过计算不同时间戳的差值,利用时间差得出页面加载各阶段的耗时,简单用法如下:
(基于performance.timing):
| 性能指标 | 计算方式(相对耗时) | 含义 |
|---|---|---|
| DNS 解析耗时 | domainLookupEnd - domainLookupStart | 域名解析的总耗时 |
| TCP 连接耗时 | connectEnd - connectStart | 建立 TCP 握手的耗时 |
| 首字节耗时 (TTFB) | responseStart - navigationStart | 从导航到服务器返回首字节 |
| 页面加载完成 | loadEventEnd - navigationStart | 整个页面加载完成的总耗时 |
| DOM 解析完成 | domContentLoadedEventEnd - navigationStart | DOM 树构建完成的耗时 |
代码示例如下:
// 获取 timing 对象
const timing = performance.timing;
// 1. DNS解析耗时
const dnsTime = timing.domainLookupEnd - timing.domainLookupStart;
// 2. TCP连接耗时(含HTTPS握手)
const tcpTime = timing.connectEnd - timing.connectStart;
// 3. 首字节时间(TTFB):请求发送到收到第一个响应字节的时间
const ttfb = timing.responseStart - timing.requestStart;
// 4. 白屏时间:导航开始到首字节返回的时间(核心体验指标)
const blankScreenTime = timing.responseStart - timing.navigationStart;
// 5. DOM解析完成耗时
const domParseTime = timing.domContentLoadedEventEnd - timing.responseEnd;
// 6. 页面完全加载总耗时
const totalLoadTime = timing.loadEventEnd - timing.navigationStart;
console.log({
DNS解析耗时: `${dnsTime}ms`,
TCP连接耗时: `${tcpTime}ms`,
首字节时间(TTFB): `${ttfb}ms`,
白屏时间: `${blankScreenTime}ms`,
DOM解析耗时: `${domParseTime}ms`,
页面总加载耗时: `${totalLoadTime}ms`
});
当然还有新版本的计算方式:performance.getEntriesByType('navigation')
比如这样:
const navEntry = performance.getEntriesByType('navigation')[0];
// 等价于 timing 的核心计算
const dnsTime = navEntry.domainLookupEnd - navEntry.domainLookupStart;
const ttfb = navEntry.responseStart - navEntry.requestStart;
const totalLoadTime = navEntry.loadEventEnd - navEntry.navigationStart;
navigationStart触发时机
navigationStart 的时间戳在浏览器接收到导航指令的瞬间被记录,具体分场景:
- 普通跳转(点击链接、输入 URL 回车):浏览器开始发起请求前的瞬间;
- 页面刷新:浏览器清空当前页面、准备重新请求资源的瞬间;
- 前进 / 后退(浏览器缓存):浏览器开始从缓存加载页面的瞬间。
常规本地测试可以这样:
// main.js
import { createApp } from 'vue'
import App from './App.vue'
function calculatePerformance() {
// 先判断浏览器是否支持 Performance API
if (!window.performance || !window.performance.timing) {
console.warn('当前浏览器不支持 Performance.timing API');
return;
}
const timing = performance.timing;
// 核心:先判断 loadEventEnd 是否已完成(值大于 0)
if (timing.loadEventEnd === 0) {
console.warn('页面 load 事件还未完成,暂无法统计完整性能数据');
// 退而求其次,统计已完成的阶段(比如 DOM 解析完成)
const partialTotalTime = timing.domContentLoadedEventEnd - timing.navigationStart;
console.log('已完成的性能数据(非完整):', {
白屏时间: `${timing.responseStart - timing.navigationStart}ms`,
DOM解析完成耗时: `${timing.domContentLoadedEventEnd - timing.responseEnd}ms`,
截至DOM完成总耗时: `${partialTotalTime}ms`
});
return;
}
// 计算各阶段耗时(增加异常值过滤)
const dnsTime = Math.max(0, timing.domainLookupEnd - timing.domainLookupStart);
const tcpTime = Math.max(0, timing.connectEnd - timing.connectStart);
const ttfb = Math.max(0, timing.responseStart - timing.requestStart);
const blankScreenTime = Math.max(0, timing.responseStart - timing.navigationStart);
const domParseTime = Math.max(0, timing.domContentLoadedEventEnd - timing.responseEnd);
const totalLoadTime = Math.max(0, timing.loadEventEnd - timing.navigationStart);
console.log('完整性能统计数据:', {
DNS解析耗时: `${dnsTime}ms${dnsTime === 0 ? '(DNS缓存命中)' : ''}`,
TCP连接耗时: `${tcpTime}ms`,
首字节时间(TTFB): `${ttfb}ms`,
白屏时间: `${blankScreenTime}ms`,
DOM解析完成耗时: `${domParseTime}ms`,
页面总加载耗时: `${totalLoadTime}ms`
});
}
// 方案1:优先等待 load 事件(最准确)
if (document.readyState === 'complete') {
// 页面已经加载完成,直接执行
calculatePerformance();
} else {
// 页面还在加载,监听 load 事件
window.addEventListener('load', calculatePerformance);
}
// 初始化 Vue 应用(放在统计逻辑之后不影响,因为统计已异步等待 load 事件)
const app = createApp(App)
app.mount('#app')
路由切换时的简单处理:
// Vue3 + Vue Router 路由切换埋点
import { useRouter } from 'vue-router';
const router = useRouter();
router.afterEach((to, from) => {
const startTime = Date.now();
const threshold = 2000;
// 路由切换后,延迟阈值时间检测首屏
setTimeout(() => {
const keyNode = document.querySelector(`#${to.name}-container`); // 路由对应容器
const isBlank = !keyNode || keyNode.offsetHeight === 0;
report({
isBlank,
type: 'route-change', // 标记是路由切换首屏
from: from.path,
to: to.path,
duration: Date.now() - startTime
});
}, threshold);
});
从用户的感知角度进行计算
responseStart - navigationStart 是传统白屏时间计算方式,但从用户体验角度,更精准的白屏结束标志是「首次绘制(FP)」或「首次内容绘制(FCP)」,这两个指标可以通过 performance.getEntriesByType('paint') 获取,比仅用 responseStart 更贴合实际视觉体验:
// 获取更精准的白屏时间(FP/FCP)
function getAccurateBlankScreenTime() {
// 兼容处理:先判断是否支持 Paint Timing API
if (!window.performance || !window.performance.getEntriesByType) {
// 降级使用传统方式
const timing = performance.timing;
return Math.max(0, timing.responseStart - timing.navigationStart);
}
// 获取 Paint 类型的性能指标
const paintEntries = performance.getEntriesByType('paint');
let fpTime = 0; // 首次绘制(First Paint)
let fcpTime = 0; // 首次内容绘制(First Contentful Paint)
for (const entry of paintEntries) {
if (entry.name === 'first-paint') {
fpTime = entry.startTime;
} else if (entry.name === 'first-contentful-paint') {
fcpTime = entry.startTime;
}
}
// 优先级:FCP > FP > 传统方式
if (fcpTime) return fcpTime;
if (fpTime) return fpTime;
// 最终降级
const timing = performance.timing;
return Math.max(0, timing.responseStart - timing.navigationStart);
}
区别:
-
responseStart:服务器返回第一个字节的时间(仅代表数据开始传输,页面未必渲染); -
first-paint (FP):浏览器首次渲染像素(哪怕是背景色,结束纯黑 / 纯白屏); -
first-contentful-paint (FCP):浏览器首次渲染有意义的内容(文字、图片、按钮等),更贴近用户感知的「白屏结束」。
完整的常规性能统计工具示例
function getPagePerformance() {
// 基础校验
if (!window.performance) {
console.warn('当前浏览器不支持 Performance API');
return null;
}
// 1. 初始化基础数据
const timing = performance.timing;
const navEntry = performance.getEntriesByType('navigation')[0] || {};
const paintEntries = performance.getEntriesByType('paint') || [];
// 2. 核心指标计算(兼容新旧API)
const performanceData = {
// 基础导航时间(兼容 navEntry 和 timing)
navigationStart: navEntry.navigationStart || timing.navigationStart || 0,
// DNS 解析耗时
dnsTime: Math.max(0,
(navEntry.domainLookupEnd || timing.domainLookupEnd) -
(navEntry.domainLookupStart || timing.domainLookupStart)
),
// TCP 连接耗时(含HTTPS握手)
tcpTime: Math.max(0,
(navEntry.connectEnd || timing.connectEnd) -
(navEntry.connectStart || timing.connectStart)
),
// 首字节时间 TTFB
ttfb: Math.max(0,
(navEntry.responseStart || timing.responseStart) -
(navEntry.requestStart || timing.requestStart)
),
// 传统白屏时间(兼容)
blankScreenTime_legacy: Math.max(0,
(navEntry.responseStart || timing.responseStart) -
(navEntry.navigationStart || timing.navigationStart)
),
// 精准白屏时间(FP/FCP)
firstPaint: 0, // 首次绘制
firstContentfulPaint: 0, // 首次内容绘制
// DOM 解析耗时
domParseTime: Math.max(0,
timing.domContentLoadedEventEnd - timing.responseEnd
),
// 页面总加载耗时
totalLoadTime: Math.max(0,
(navEntry.loadEventEnd || timing.loadEventEnd) -
(navEntry.navigationStart || timing.navigationStart)
)
};
// 3. 补充 FP/FCP 数据
paintEntries.forEach(entry => {
if (entry.name === 'first-paint') {
performanceData.firstPaint = entry.startTime;
} else if (entry.name === 'first-contentful-paint') {
performanceData.firstContentfulPaint = entry.startTime;
}
});
// 4. 最终推荐的白屏时间(优先级:FCP > FP > 传统方式),适配性选择
performanceData.blankScreenTime = performanceData.firstContentfulPaint ||
performanceData.firstPaint ||
performanceData.blankScreenTime_legacy;
// 5. 补充友好的格式化数据
performanceData.formatted = {
dnsTime: `${performanceData.dnsTime}ms${performanceData.dnsTime === 0 ? '(DNS缓存命中)' : ''}`,
tcpTime: `${performanceData.tcpTime}ms`,
ttfb: `${performanceData.ttfb}ms`,
blankScreenTime: `${performanceData.blankScreenTime.toFixed(2)}ms`, // 保留两位小数
domParseTime: `${performanceData.domParseTime}ms`,
totalLoadTime: `${performanceData.totalLoadTime}ms`
};
return performanceData;
}
// 初始化性能统计(确保在页面加载完成后执行)
function initPerformanceMonitor() {
// 监听页面加载完成事件
function handleLoad() {
const perfData = getPagePerformance();
if (perfData) {
console.log('页面性能统计数据:', perfData.formatted);
// 可选:上报性能数据到后端/监控平台
// reportPerformanceToServer(perfData);
}
}
// 页面已加载完成则直接执行,否则监听 load 事件
if (document.readyState === 'complete') {
setTimeout(handleLoad, 0); // 微任务延迟,确保所有资源加载完毕
} else {
window.addEventListener('load', handleLoad);
// 兜底:如果 load 事件迟迟不触发,5秒后强制统计
setTimeout(handleLoad, 5000);
}
}
// ========== 业务集成示例(Vue/React 通用) ==========
// Vue 项目:在 main.js 中调用
// import { createApp } from 'vue'
// import App from './App.vue'
// // 先初始化性能监控
// initPerformanceMonitor();
// // 再挂载应用
// createApp(App).mount('#app');
// React 项目:在 index.js 中调用
// import React from 'react';
// import ReactDOM from 'react-dom/client';
// import App from './App';
// // 先初始化性能监控
// initPerformanceMonitor();
// // 再渲染应用
// const root = ReactDOM.createRoot(document.getElementById('root'));
// root.render(<App />);
注意
-
跨域资源的性能数据:如果页面加载了跨域的 JS/CSS/ 图片,默认情况下
performance无法获取这些资源的详细耗时,需要在服务端配置Timing-Allow-Origin响应头。 -
SPA 应用的适配:单页应用的路由跳转不会触发
navigationStart,需要手动标记路由切换的开始时间,结合performance.mark()自定义统计:// SPA 路由切换时标记开始时间 function markRouteStart(routeName) { performance.mark(`route_${routeName}_start`); } // 路由渲染完成后计算耗时 function calculateRouteTime(routeName) { performance.mark(`route_${routeName}_end`); const measure = performance.measure(`route_${routeName}_duration`, `route_${routeName}_start`, `route_${routeName}_end`); console.log(`${routeName} 路由渲染耗时:`, measure.duration); }
关于DOM 树构建完成的耗时问题:
DOM 检测可能存在 “DOM 存在但样式异常导致不可见” 的误判(如 z-index: -1、背景色与内容色一致),大厂会在核心页面增加 Canvas 视觉检测 作为兜底:
-
在阈值时间后,用
html2canvas对首屏区域截图。 -
计算截图的 像素灰度方差:
- 白屏时,像素值趋于一致,方差趋近于 0;
- 非白屏时,像素值差异大,方差高于阈值(如 50)。
-
双重验证:只有 DOM 检测和视觉检测均判定为 “白屏”,才计入白屏次数。
总结
-
白屏时间计算:优先使用
first-contentful-paint (FCP),降级使用first-paint (FP),最终兜底用responseStart - navigationStart,更贴合用户实际感知; -
性能统计时机:必须在
load事件触发后执行,或判断document.readyState === 'complete',否则数据不完整; -
兼容性处理:兼顾新旧 Performance API(
timing和getEntriesByType),并对异常值做Math.max(0, ...)过滤,避免负数。