如何获取用户真实首屏时间
核心区分:
- 实验室数据:Lighthouse、DevTools Performance 面板、Puppeteer 自动化脚本,是受控环境合成数据,不能代表真实用户手机 / 网络 / 浏览器;
- 真实用户 RUM:在用户浏览器里嵌入 JS 埋点,采集每一位访客实际加载耗时,是业务可用的真实首屏数据。
一、先搞懂:用哪个指标代表 “首屏时间”(行业标准)
不要自己瞎定义,遵循 Google Core Web Vitals 规范:
- FCP 首次内容绘制(First Contentful Paint)页面第一次渲染出文本、图片、SVG 等有效内容的时间,对应用户不再白屏的起点。
- LCP 最大内容绘制(Largest Contentful Paint)【最推荐做主首屏指标】首屏可视区域内最大元素(banner 大图、标题块)完全加载渲染完成的时间,最贴合用户视觉感知页面加载完毕的体验,官方性能打分核心指标,理想值<2500ms。
- FP 首次绘制:只渲染空白背景,无参考价值;
- 旧
performance.timing已废弃,禁止使用。
二、方案 1:官方 web-vitals 库(最简、兼容性最好,首选)
Google 官方封装库,自动监听 FCP/LCP/CLS,一行埋点上报,自动处理浏览器兼容性、页面卸载丢失数据问题。
1. 安装引入
npm install web-vitals2. 全局埋点代码(直接放到入口 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 字符串里的
Mobile或Android关键词 - 输出:
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...后端用途:
- 排查奇葩机型 / 内嵌浏览器(微信、QQ、支付宝 WebView)的性能异常
- 过滤爬虫、测试机器的脏数据
- 统计浏览器版本占比
三、上报逻辑核心:sendBeacon 为什么是首选
1.navigator.sendBeacon(url, data)重点
核心优势(RUM 埋点灵魂)
- 异步、非阻塞,不影响页面性能不会阻塞主线程渲染、请求,性能埋点本身不能拖累页面。
- 页面卸载时依然可靠发送用户关闭标签页、返回上一页、刷新页面时,普通
fetch/axios会直接被浏览器中断请求,数据丢失;sendBeacon由浏览器后台排队执行,优先保证上报完成。 - 入参限制:第二个参数只能是字符串、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
后端需要做几件事:
- POST 请求接收 JSON 字符串,解析成对象;
- 入库字段示例:
url、metric_name、value、device、ua、create_time、ip、network_type - 加接口限流、去重,防止恶意刷接口。
补充:
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. 注意坑点
- location 可赋值跳转页面
location.href = 'https://baidu.com' // 会直接跳转你代码里只是读取,不会有副作用,安全。
- 路由模式影响 Vue/React hash 模式下
location.pathname固定为/,要用location.hash提取路由。
navigator 对象详解(浏览器环境信息)
navigator.userAgent浏览器内核、设备、系统、版本字符串,用来判断手机 / PC、区分 WebView(微信 / QQ 内置浏览器)。navigator.sendBeacon()专门用于页面卸载前可靠上报埋点的 API,只存在于navigator下。- 扩展有用属性(埋点里经常加)
// 网络类型 4g/3g/2g/wifi const net = navigator.connection?.effectiveType // 是否在线 navigator.onLine // 语言 navigator.language4. 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];perfResult = {}空对象,用来缓存捕获到的 FP/FCP/LCP 数值,方便最后统一打包上报。performance.getEntriesByType('navigation')
window.performance浏览器全局原生性能对象navigation类型 =导航计时条目,记录从输入网址、DNS、TCP、请求发送、服务器返回首字节、页面加载全过程所有时间戳- 取数组第 0 项
[0],拿到当前页面本次加载的完整导航耗时对象
后续用它计算TTFB(首字节时间)。
隐患:如果数组为空,
navEntry是undefined,后面取值会报错。
1.PerformanceObserver是什么
浏览器原生异步性能监听类,替代老旧轮询performance.getEntries(),性能开销极低,专门用来监听页面各类性能事件。
2. 回调函数参数list
性能条目列表,list.getEntries()取出本次触发的所有绘制记录。
3. 两个指标含义
- FP first-paint 首次绘制浏览器第一次向屏幕绘制像素的时间,比如背景色、边框,代表页面白屏结束。
- 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.requestStartrequestStart:浏览器开始向服务器发送请求的时间戳responseStart:浏览器接收到服务器返回第一个字节的时间戳 差值 =TTFB 首字节时间,衡量服务器响应速度、网络链路质量的核心后端指标。
4.navigator.sendBeacon原生上报 API(重中之重)
为什么不用 fetch /axios/ XMLHttpRequest?
页面beforeunload执行时,浏览器会终止所有异步请求,普通fetch大概率被直接丢弃,后端收不到日志。
sendBeacon浏览器专门为页面卸载场景设计:
- 异步执行,不阻塞页面关闭、不卡顿用户
- 浏览器保证尽力完成请求,可靠性极高
- 只能发送 POST 二进制 / 字符串数据,无返回值
上报字段说明
{ "FCP": 数值, "LCP": 数值, "TTFB": 服务器首字节耗时, "page": location.pathname // 当前页面路由路径,后端区分哪个页面性能差 }延伸知识点
- 所有 API 归属标准
performance/ Navigation Timing:W3C Navigation Timing Level 2PerformanceObserver:Performance Timeline Level 2- LCP、FCP、FP:Paint Timing API + Largest Contentful Paint 规范
sendBeacon:Beacon API 规范 全部属于浏览器原生 Web 标准,无需引入任何 SDK。
- 可继续扩展采集的指标 CLS(布局偏移)、INP(交互响应)、DNS 耗时、TCP 耗时、DOM 加载时间、资源加载耗时等,都可以用同一套
PerformanceObserver写法监听。 - 后端接收建议 接口
/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();还可以叠加:首屏内所有图片资源加载完毕、核心接口请求完成,综合计算最终业务首屏时间。
五、数据后端存储与统计(拿到真实数据后怎么做)
- 后端接收:写一个简单 POST 接口,入库字段:页面路径、LCP、FCP、设备类型、浏览器、网络环境、时间戳;
- 统计口径(关键)不要看平均值,用P50/P75/P95 分位数:
- P50:一半用户的首屏耗时(中位数);
- P75:75% 用户优于该数值,性能优化核心参考;
- P95:最差 5% 用户,弱网 / 老旧设备兜底优化点;
- 维度拆分:按移动端 / PC、Chrome / 微信内置浏览器、4G/WiFi/5G 拆分,定位哪类用户加载最慢。
六、现成成熟 RUM 工具(零开发接入)
不想自己写埋点、搭后端,直接接入商用监控平台,自动收集全量真实用户首屏数据:
- Sentry:免费额度够用,自动采集 Web Vitals 性能指标;
- 阿里云 ARMS、腾讯云前端监控:国内访问稳定,可视化报表、分位数自动计算;
- 百度统计、GA4:偏重流量分析,附带核心性能采集;
- CrUX(Chrome 用户体验报告):Google 官方真实全网用户大数据,可查域名整体 LCP 分布。
七、避坑重点(保证数据真实不污染)
- 上报必须用
navigator.sendBeacon/fetch keepalive,防止用户关闭页面丢失指标; - LCP 要取最后一次触发值,页面滚动、懒加载大图会二次触发 LCP;
- 过滤爬虫、测试 IP、内网访问数据,只统计真实访客;
- 微信 / 支付宝 WebView、iOS Safari 做兼容性兜底,部分低版本浏览器 LCP 不支持时降级用 FCP;
- 区分首次冷启动和二次缓存访问,缓存命中首屏时间无优化参考意义。
最简总结
- 要真实用户数据= RUM 前端 JS 埋点采集,DevTools/Lighthouse 只是实验室模拟;
- 对外统一用LCP(最大内容绘制)作为首屏标准指标;
- 快速落地直接引入
web-vitals库,两行代码完成全量采集上报。