三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

如何获取用户真实首屏时间

如何获取用户真实首屏时间

核心区分:

  • 实验室数据:Lighthouse、DevTools Performance 面板、Puppeteer 自动化脚本,是受控环境合成数据,不能代表真实用户手机 / 网络 / 浏览器;
  • 真实用户 RUM:在用户浏览器里嵌入 JS 埋点,采集每一位访客实际加载耗时,是业务可用的真实首屏数据。

一、先搞懂:用哪个指标代表 “首屏时间”(行业标准)

不要自己瞎定义,遵循 Google Core Web Vitals 规范:

  1. FCP 首次内容绘制(First Contentful Paint)页面第一次渲染出文本、图片、SVG 等有效内容的时间,对应用户不再白屏的起点。
  2. LCP 最大内容绘制(Largest Contentful Paint)【最推荐做主首屏指标】首屏可视区域内最大元素(banner 大图、标题块)完全加载渲染完成的时间,最贴合用户视觉感知页面加载完毕的体验,官方性能打分核心指标,理想值<2500ms。
  3. FP 首次绘制:只渲染空白背景,无参考价值;
  4. performance.timing已废弃,禁止使用

二、方案 1:官方 web-vitals 库(最简、兼容性最好,首选)

Google 官方封装库,自动监听 FCP/LCP/CLS,一行埋点上报,自动处理浏览器兼容性、页面卸载丢失数据问题。

1. 安装引入

npm install web-vitals

2. 全局埋点代码(直接放到入口 JS)

import { getFCP, getLCP, getCLS } from 'web-vitals'; // 统一上报接口,sendBeacon页面关闭也能发送,不阻塞页面 function sendRUMData(metric) { try{ // 1. 过滤本地环境,避免测试数据入库 if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') return; const data = { pageUrl: location.href, metricName: metric.name, // LCP / FCP / CLS value: metric.value, // 耗时ms device: /Mobile|Android/.test(navigator.userAgent) ? 'mobile' : 'pc', ua: navigator.userAgent }; // 上报到自己后端接口 if (navigator.sendBeacon) { navigator.sendBeacon('/api/rum/report', JSON.stringify(data)); } else { fetch('/api/rum/report', { method: 'POST', body: JSON.stringify(data), keepalive: true }); } }catch(error){ console.warn('sendRUMData error:', error); } } // 采集核心首屏指标 getFCP(sendRUMData); getLCP(sendRUMData); getCLS(sendRUMData);

1.pageUrl: location.href

  • 取值:当前完整页面地址(带参数https://xxx.com/page?id=1
  • 用途:后端做页面维度聚合分析,比如首页 LCP 多少、列表页 FCP 多少
  • 补充:如果需要剔除哈希、查询参数,可以改成location.origin + location.pathname

2.metricName: metric.name

metric是 web-vitals 库传给回调的指标对象,固定三个值:

  • FCP:首次内容绘制
  • LCP:最大内容绘制(核心首屏时间)
  • CLS:累积布局偏移(数值不是毫秒,是布局抖动分数) 后端靠这个字段区分是什么性能指标。

3.value: metric.value

  • FCP / LCP:单位毫秒 ms,数值越大加载越慢
  • CLS:0~无穷小的小数(比如 0.05),代表页面视觉抖动程度 后端统计分位数(P50/P75/P95)全靠这个值。

4. device 设备判断

/Mobile|Android/.test(navigator.userAgent) ? 'mobile' : 'pc'
  • 原理:正则匹配 UA 字符串里的MobileAndroid关键词
  • 输出:mobile/pc
  • 价值:后端拆分移动端、PC 端性能,绝大多数性能瓶颈都在手机端
  • 小缺陷 & 优化:这个判断不够严谨(iPad、平板会被判成 PC),升级版可以用:
const isMobile = window.innerWidth <= 768 || /Mobile|Android|iPhone|iPad|iPod/.test(navigator.userAgent); const device = isMobile ? 'mobile' : 'pc';

5.ua: navigator.userAgent

完整浏览器设备标识字符串,示例:Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15...后端用途:

  1. 排查奇葩机型 / 内嵌浏览器(微信、QQ、支付宝 WebView)的性能异常
  2. 过滤爬虫、测试机器的脏数据
  3. 统计浏览器版本占比

三、上报逻辑核心:sendBeacon 为什么是首选

1.navigator.sendBeacon(url, data)重点

核心优势(RUM 埋点灵魂)
  1. 异步、非阻塞,不影响页面性能不会阻塞主线程渲染、请求,性能埋点本身不能拖累页面。
  2. 页面卸载时依然可靠发送用户关闭标签页、返回上一页、刷新页面时,普通fetch/axios会直接被浏览器中断请求,数据丢失;sendBeacon由浏览器后台排队执行,优先保证上报完成。
  3. 入参限制:第二个参数只能是字符串、Blob、FormData,所以要用JSON.stringify(data)序列化。
兼容性

IE 不支持,现代浏览器(Chrome/Firefox/Safari/Edge)全部支持,覆盖率极高。

2. else 降级分支:fetch + keepalive: true

keepalive: true作用和 sendBeacon 一致:

告诉浏览器:哪怕页面已经销毁,也要把这个 POST 请求发完。

降级逻辑完整说明:

  • 老浏览器没有sendBeaconAPI,进入 fetch 分支;
  • method: 'POST':后端接口接收 POST 请求;
  • body传 JSON 字符串;
  • keepalive: true兜底防止页面关闭丢数据。

3. 后端接口约定/api/rum/report

后端需要做几件事:

  1. POST 请求接收 JSON 字符串,解析成对象;
  2. 入库字段示例:url、metric_name、value、device、ua、create_time、ip、network_type
  3. 加接口限流、去重,防止恶意刷接口。

补充:

location 对象详解

1. 常用属性

location.href // 完整URL(带域名、路径、参数、hash) location.origin // 协议+域名,如 https://www.xxx.com location.pathname // 路由路径 /home/list location.search // 查询字符串 ?id=1&name=xx location.hash // 哈希 #/dashboard location.host // 域名+端口

2. 注意坑点

  1. location 可赋值跳转页面
location.href = 'https://baidu.com' // 会直接跳转

你代码里只是读取,不会有副作用,安全。

  1. 路由模式影响 Vue/React hash 模式下location.pathname固定为/,要用location.hash提取路由。

navigator 对象详解(浏览器环境信息)

  1. navigator.userAgent浏览器内核、设备、系统、版本字符串,用来判断手机 / PC、区分 WebView(微信 / QQ 内置浏览器)。
  2. navigator.sendBeacon()专门用于页面卸载前可靠上报埋点的 API,只存在于navigator下。
  3. 扩展有用属性(埋点里经常加)
// 网络类型 4g/3g/2g/wifi const net = navigator.connection?.effectiveType // 是否在线 navigator.onLine // 语言 navigator.language

4. navigator 只读,不会误改

navigator整体是只读对象,只能读取信息,不会触发页面跳转、刷新,埋点放心用。

三、方案 2:原生 Performance API 手写采集(不依赖第三方库)

通过PerformanceObserver异步监听渲染事件,精准捕获 FCP、LCP 时间戳,适合不想引入依赖的场景。

// 存储性能指标 const perfResult = {}; const navEntry = performance.getEntriesByType('navigation')[0]; // 1. 监听FP、FCP绘制事件 const paintObserver = new PerformanceObserver((list) => { list.getEntries().forEach(entry => { if (entry.name === 'first-paint') { perfResult.FP = entry.startTime; } if (entry.name === 'first-contentful-paint') { perfResult.FCP = entry.startTime; } }); }); paintObserver.observe({ type: 'paint', buffered: true }); // 2. 监听LCP最大内容绘制(真实首屏完成时间) const lcpObserver = new PerformanceObserver((list) => { const entries = list.getEntries(); // 取最后一次LCP(避免滚动后误触发) perfResult.LCP = entries[entries.length - 1].startTime; lcpObserver.disconnect(); // 拿到后停止监听 }); lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true }); // 页面卸载前统一上报 window.addEventListener('beforeunload', () => { if (perfResult.LCP || perfResult.FCP) { navigator.sendBeacon('/api/rum/report', JSON.stringify({ FCP: perfResult.FCP, LCP: perfResult.LCP, TTFB: navEntry.responseStart - navEntry.requestStart, // 首字节耗时 page: location.pathname })); } });

performance.getEntriesByType('navigation')返回数组,正常页面加载只有 1 项,类型为PerformanceNavigationTiming对象,继承自PerformanceEntry。如下:

{ // 基础通用字段 name: "https://xxx.com/page", // 当前页面完整URL entryType: "navigation", startTime: 0, // 整个导航起始时间(基准0点) duration: 2345.6, // 页面从开始到完全加载总耗时 // 1. 重定向阶段 redirectStart: 0, redirectEnd: 0, // 有跳转时:redirectEnd - redirectStart = 重定向耗时 // 2. DNS 域名解析 domainLookupStart: 80, domainLookupEnd: 120, // DNS耗时 = domainLookupEnd - domainLookupStart // 3. TCP 三次握手建立连接 connectStart: 125, connectEnd: 180, secureConnectionStart: 150, // HTTPS TLS握手开始 // TCP耗时 = connectEnd - connectStart // 4. HTTP 请求发送 & 服务器响应(重点TTFB) requestStart: 185, // 浏览器开始发请求 responseStart: 320, // 服务器返回第一个字节(TTFB终点) responseEnd: 680, // 服务器所有数据传输完毕 // TTFB = responseStart - requestStart (你代码里用的就是这个) // 5. HTML 解析、DOM 构建 domLoading: 700, domInteractive: 1200, // DOM树构建完成,可执行JS domContentLoadedEventStart: 1210, domContentLoadedEventEnd: 1230, domComplete: 2100, // DOM+所有资源全部解析完成 // 6. load 页面完全加载事件 loadEventStart: 2105, loadEventEnd: 2120, // 其他辅助属性 type: "navigate", // 加载类型:navigate/back_forward/reload/prerender unloadEventStart: 0, unloadEventEnd: 0 }

常用可计算指标公式:

const t = performance.getEntriesByType('navigation')[0]; // 1. TTFB 首字节时间(你的代码) const TTFB = t.responseStart - t.requestStart; // 2. DNS 解析耗时 const dnsTime = t.domainLookupEnd - t.domainLookupStart; // 3. TCP 连接耗时 const tcpTime = t.connectEnd - t.connectStart; // 4. SSL/TLS 加密握手耗时(HTTPS) const sslTime = t.connectEnd - t.secureConnectionStart; // 5. 服务器整体下行传输耗时 const responseTime = t.responseEnd - t.responseStart; // 6. DOM 解析耗时 const domParseTime = t.domComplete - t.domLoading; // 7. 页面完整加载总耗时 const totalLoadTime = t.duration;

代码详解:

模块 1:初始化容器 + 获取导航级性能数据

// 存储性能指标 const perfResult = {}; const navEntry = performance.getEntriesByType('navigation')[0];
  1. perfResult = {}空对象,用来缓存捕获到的 FP/FCP/LCP 数值,方便最后统一打包上报。
  2. performance.getEntriesByType('navigation')
  • window.performance浏览器全局原生性能对象
  • navigation类型 =导航计时条目,记录从输入网址、DNS、TCP、请求发送、服务器返回首字节、页面加载全过程所有时间戳
  • 取数组第 0 项[0],拿到当前页面本次加载的完整导航耗时对象

后续用它计算TTFB(首字节时间)

隐患:如果数组为空,navEntryundefined,后面取值会报错。

1.PerformanceObserver是什么

浏览器原生异步性能监听类,替代老旧轮询performance.getEntries(),性能开销极低,专门用来监听页面各类性能事件。

2. 回调函数参数list

性能条目列表,list.getEntries()取出本次触发的所有绘制记录。

3. 两个指标含义
  1. FP first-paint 首次绘制浏览器第一次向屏幕绘制像素的时间,比如背景色、边框,代表页面白屏结束。
  2. FCP first-contentful-paint 首次内容绘制页面第一个有效内容(文字、图片、SVG)渲染到屏幕的时间,衡量用户什么时候看到有效信息。

entry.startTime:页面开始加载到该事件发生的毫秒数。

4. observe 监听配置
observe({ type: 'paint', buffered: true })
  • type: 'paint':监听绘制类性能事件(FP/FCP 都归类在 paint 类型)
  • buffered: true关键配置代码执行时机往往晚于页面绘制,如果不加这个,已经发生的 FP/FCP 会丢失;开启后浏览器会读取历史已发生的性能记录,保证一定能拿到数据。

缺陷:这个paintObserver只创建不销毁,页面路由跳转后会常驻内存,造成内存泄漏。

模块 3:监听 LCP 最大内容绘制(Web Vitals 核心指标)

// 2. 监听LCP最大内容绘制(真实首屏完成时间) const lcpObserver = new PerformanceObserver((list) => { const entries = list.getEntries(); // 取最后一次LCP(避免滚动后误触发) perfResult.LCP = entries[entries.length - 1].startTime; lcpObserver.disconnect(); // 拿到后停止监听 }); lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
1. LCP 指标定义

Largest Contentful Paint 最大内容绘制谷歌 Web Vitals 三大核心指标之首,代表可视区域内最大元素渲染完成的时间,最贴近用户感知的「页面加载完毕」体验。

2. 为什么取最后一条entries[entries.length - 1]

浏览器规则: 初始首屏大图片加载完成会触发一次 LCP;如果用户滚动页面,可视区域最大元素变化,会再次触发 LCP。 业务统计只需要初始首屏那次,多次触发时取最后一次即可。

3.lcpObserver.disconnect()

拿到 LCP 数值后立即销毁观察者,停止监听,释放浏览器资源,写法很规范。

监听类型type: 'largest-contentful-paint'是浏览器原生固定类型。

模块 4:页面卸载前用 sendBeacon 批量上报数据

// 页面卸载前统一上报 window.addEventListener('beforeunload', () => { if (perfResult.LCP || perfResult.FCP) { navigator.sendBeacon('/api/rum/report', JSON.stringify({ FCP: perfResult.FCP, LCP: perfResult.LCP, TTFB: navEntry.responseStart - navEntry.requestStart, // 首字节耗时 page: location.pathname })); } });
1. 触发时机:beforeunload

用户关闭标签页、刷新页面、输入新网址跳转时触发。 为什么不在 LCP 拿到就立即上报? 减少接口请求次数,页面关闭一次性汇总所有指标上报,降低后端压力。

2. 判断条件if (perfResult.LCP || perfResult.FCP)

防止指标全部没采集到(比如浏览器不支持 API)时发送空数据。

3. TTFB 计算逻辑
TTFB: navEntry.responseStart - navEntry.requestStart
  • requestStart:浏览器开始向服务器发送请求的时间戳
  • responseStart:浏览器接收到服务器返回第一个字节的时间戳 差值 =TTFB 首字节时间,衡量服务器响应速度、网络链路质量的核心后端指标。
4.navigator.sendBeacon原生上报 API(重中之重)
为什么不用 fetch /axios/ XMLHttpRequest?

页面beforeunload执行时,浏览器会终止所有异步请求,普通fetch大概率被直接丢弃,后端收不到日志。

sendBeacon浏览器专门为页面卸载场景设计:

  1. 异步执行,不阻塞页面关闭、不卡顿用户
  2. 浏览器保证尽力完成请求,可靠性极高
  3. 只能发送 POST 二进制 / 字符串数据,无返回值
上报字段说明
{ "FCP": 数值, "LCP": 数值, "TTFB": 服务器首字节耗时, "page": location.pathname // 当前页面路由路径,后端区分哪个页面性能差 }

延伸知识点

  1. 所有 API 归属标准
  • performance/ Navigation Timing:W3C Navigation Timing Level 2
  • PerformanceObserver:Performance Timeline Level 2
  • LCP、FCP、FP:Paint Timing API + Largest Contentful Paint 规范
  • sendBeacon:Beacon API 规范 全部属于浏览器原生 Web 标准,无需引入任何 SDK。
  1. 可继续扩展采集的指标 CLS(布局偏移)、INP(交互响应)、DNS 耗时、TCP 耗时、DOM 加载时间、资源加载耗时等,都可以用同一套PerformanceObserver写法监听。
  2. 后端接收建议 接口/api/rum/report用 POST 接收 JSON 字符串,存入数据库做看板分析:比如哪些页面 LCP 超过 2.5s(谷歌不合格阈值)、TTFB 过高排查服务器 / CDN 问题。

四、方案 3:业务自定义精准首屏(最贴合产品自己定义)

上面 FCP/LCP 是浏览器通用规则,如果你的业务需要自己判定 “首屏算加载完”(比如核心模块 #main 渲染、接口数据返回后才算首屏),用MutationObserver监听 DOM 变化:

function getCustomFirstScreenTime() { const startTime = performance.now(); const observer = new MutationObserver((mutations) => { // 判断首屏核心容器渲染完成 const mainBox = document.querySelector('#app .main-screen'); if (mainBox && mainBox.offsetHeight > 0) { const cost = performance.now() - startTime; // 上报自定义首屏时间 navigator.sendBeacon('/api/rum/report', JSON.stringify({ customFirstScreen: cost })); observer.disconnect(); // 停止监听 } }); // 监听body下所有子节点变更 observer.observe(document.body, { childList: true, subtree: true, attributes: true }); } // 页面初始化执行 getCustomFirstScreenTime();

还可以叠加:首屏内所有图片资源加载完毕、核心接口请求完成,综合计算最终业务首屏时间。

五、数据后端存储与统计(拿到真实数据后怎么做)

  1. 后端接收:写一个简单 POST 接口,入库字段:页面路径、LCP、FCP、设备类型、浏览器、网络环境、时间戳;
  2. 统计口径(关键)不要看平均值,用P50/P75/P95 分位数
    • P50:一半用户的首屏耗时(中位数);
    • P75:75% 用户优于该数值,性能优化核心参考;
    • P95:最差 5% 用户,弱网 / 老旧设备兜底优化点;
  3. 维度拆分:按移动端 / PC、Chrome / 微信内置浏览器、4G/WiFi/5G 拆分,定位哪类用户加载最慢。

六、现成成熟 RUM 工具(零开发接入)

不想自己写埋点、搭后端,直接接入商用监控平台,自动收集全量真实用户首屏数据:

  1. Sentry:免费额度够用,自动采集 Web Vitals 性能指标;
  2. 阿里云 ARMS、腾讯云前端监控:国内访问稳定,可视化报表、分位数自动计算;
  3. 百度统计、GA4:偏重流量分析,附带核心性能采集;
  4. CrUX(Chrome 用户体验报告):Google 官方真实全网用户大数据,可查域名整体 LCP 分布。

七、避坑重点(保证数据真实不污染)

  1. 上报必须用navigator.sendBeacon/fetch keepalive,防止用户关闭页面丢失指标;
  2. LCP 要取最后一次触发值,页面滚动、懒加载大图会二次触发 LCP;
  3. 过滤爬虫、测试 IP、内网访问数据,只统计真实访客;
  4. 微信 / 支付宝 WebView、iOS Safari 做兼容性兜底,部分低版本浏览器 LCP 不支持时降级用 FCP;
  5. 区分首次冷启动二次缓存访问,缓存命中首屏时间无优化参考意义。

最简总结

  1. 真实用户数据= RUM 前端 JS 埋点采集,DevTools/Lighthouse 只是实验室模拟;
  2. 对外统一用LCP(最大内容绘制)作为首屏标准指标;
  3. 快速落地直接引入web-vitals库,两行代码完成全量采集上报。
← 返回列表