首屏加载统计的几个问题梳理

22 阅读7分钟

前言

前端性能优化方面离不开白屏的问题,今天只是侧重复习下白屏的计算方式与系统性的记录方式。关于白屏的性能优化方面有很多,大家可以先看看下面这位老哥写的性能优化的几种方式

关于白屏

基本定义:白屏时间(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 域名解析的时间
domainLookupEndDNS 域名解析完成的时间
connectStart开始建立 TCP 连接的时间
connectEndTCP 连接建立完成的时间(如果是 HTTPS,包含 SSL 握手)
requestStart浏览器向服务器发送请求的时间
responseStart浏览器收到服务器第一个字节响应的时间(首字节时间,TTFB)
responseEnd浏览器接收完服务器响应数据的时间
domContentLoadedEventEndDOM 解析完成,且所有 DOMContentLoaded 事件回调执行完毕的时间
loadEventEnd页面 load 事件触发且所有回调执行完毕的时间(页面完全加载完成)

三、简单用法:计算各阶段耗时

通过计算不同时间戳的差值,利用时间差得出页面加载各阶段的耗时,简单用法如下: (基于performance.timing):

性能指标计算方式(相对耗时)含义
DNS 解析耗时domainLookupEnd - domainLookupStart域名解析的总耗时
TCP 连接耗时connectEnd - connectStart建立 TCP 握手的耗时
首字节耗时 (TTFB)responseStart - navigationStart从导航到服务器返回首字节
页面加载完成loadEventEnd - navigationStart整个页面加载完成的总耗时
DOM 解析完成domContentLoadedEventEnd - navigationStartDOM 树构建完成的耗时

代码示例如下:

// 获取 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 的时间戳在浏览器接收到导航指令的瞬间被记录,具体分场景:

  1. 普通跳转(点击链接、输入 URL 回车):浏览器开始发起请求前的瞬间;
  2. 页面刷新:浏览器清空当前页面、准备重新请求资源的瞬间;
  3. 前进 / 后退(浏览器缓存):浏览器开始从缓存加载页面的瞬间。

常规本地测试可以这样:

// 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 />);

注意

  1. 跨域资源的性能数据:如果页面加载了跨域的 JS/CSS/ 图片,默认情况下 performance 无法获取这些资源的详细耗时,需要在服务端配置 Timing-Allow-Origin 响应头。

  2. 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 视觉检测 作为兜底:

  1. 在阈值时间后,用 html2canvas 对首屏区域截图。

  2. 计算截图的 像素灰度方差

    • 白屏时,像素值趋于一致,方差趋近于 0;
    • 非白屏时,像素值差异大,方差高于阈值(如 50)。
  3. 双重验证:只有 DOM 检测和视觉检测均判定为 “白屏”,才计入白屏次数。

总结

  1. 白屏时间计算:优先使用 first-contentful-paint (FCP),降级使用 first-paint (FP),最终兜底用 responseStart - navigationStart,更贴合用户实际感知;
  2. 性能统计时机:必须在 load 事件触发后执行,或判断 document.readyState === 'complete',否则数据不完整;
  3. 兼容性处理:兼顾新旧 Performance API(timinggetEntriesByType),并对异常值做 Math.max(0, ...) 过滤,避免负数。