阅读视图

发现新文章,点击刷新页面。

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

前言

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

关于白屏

基本定义:白屏时间(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 的时间戳在浏览器接收到导航指令的瞬间被记录,具体分场景:

  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, ...) 过滤,避免负数。
❌