1. 首屏加载时间到底是什么,为什么我非要测它
做前端的人大概率都遇到过这么个场景:运营跑过来说“我们的页面怎么这么慢,别人打开只要两秒你这里五秒”,测试同学提了个 bug 说“首屏白屏太久了”,老板直接在群里艾特你“优化一下打开速度”。你嘴上说好好好,心里其实先得搞清楚一件事——他们说的“首屏慢”,到底慢在哪里,你手里连个靠谱的数值都没有。
获取网页首屏加载时间,听起来是个再基础不过的指标,但真把它定义清楚、测准确、能持续对比,很多团队其实没干明白。这里说的“首屏时间”,不是浏览器 bottom 栏里那个“加载完成”,也不是你在 DevTools 里看到的 DOMContentLoaded 数字。它指的是用户从输入网址、按下回车,到页面首屏区域内主要内容渲染完成、肉眼可见不再是白屏的那一刻。这个时间直接决定了用户对“这个网站快不快”的第一印象,所以它是前端性能监控里最核心、也最容易被聊偏的话题。
这篇文章写给谁?一种是前端开发,想在项目里接入首屏测速,但不知道从哪下手;一种是技术负责人或性能专项的同学,需要给团队定一套可对比的测量口径,别让数据各说各话;还有一种就是被“页面慢”困扰、想先量化再优化的普通开发者。我会把测量口径、浏览器原生 API、手动埋点、上报设计、SPA 场景处理、常见坑全部过一遍,最后给出一套可以直接抄的简版实现。
2. 测量口径先统一,不然数据全是鸡同鸭讲
2.1 互相打架的三个口径:FCP、LCP 和传统 FST
首屏时间的最大问题不是“不好测”,而是“你说你的我说我的”。大家手机上看的 App 不一样,统计口径不一样,最后对不上就互相甩锅。所以第一步,先把口径理清楚。
业内常见的首屏相关指标有三个:FCP(First Contentful Paint,首次内容绘制)、LCP(Largest Contentful Paint,最大内容绘制)和传统的 FST(First Screen Time,手动埋点首屏时间)。
FCP 是浏览器在页面上绘制出第一个文本、图片或 canvas 的时间点,它只代表“有东西出来了”,但不代表这个内容是你想看的主体。LCP 则更贴近“主要内容渲染完成”的体验,它取的是页面加载过程中最大的那个内容元素(比如 hero 大图、头条标题)的渲染时间。Chrome 团队这两年一直在推 LCP,因为它跟用户感知的相关性更好。
传统 FST 则是我们自己定的“首屏元素全部渲染完”的近似估算,通常通过轮询检查指定元素是否出现了来测得。现在做前端监控,一般建议是:标准指标用 FCP + LCP,业务指标用自定义 FST,两者并存,不能混着当同一个数用。
2.2 我怎么在团伙里把口径钉死
我自己踩过最大的坑,就是团队里有人统计“DOMContentLoaded”,有人统计“window.onload”,还有人统计“请求响应时间”,每周性能周报贴出来完全对不上。后来定了一个原则:除非你能把底层逻辑讲清楚,否则谁都不许自定义首屏指标。
具体做法是分三层看。第一层是网络层指标,比如 DNS 查询耗时、TCP 建连耗时、TTFB(首字节时间),这些反映服务器响应和网络状况;第二层是渲染层指标,就是 FCP、LCP 这些,反映浏览器渲染进程干了多少活;第三层是业务层指标,也就是你自己埋的“首屏模块是否可交互”。三层分开统计、分开优化、分开看板,谁慢一目了然。千万别把 TTFB 慢的锅甩给前端渲染,也别把大图加载慢的锅甩给后端接口。
提示:不管团队用什么监控平台,第一件事就是先在文档里写清楚“首屏时间 = LCP(含标准口径) + FST(业务自定义口径)”,然后所有报表都按这个口径出,避免数据口径漂移。
3. 浏览器已经在给你发数据了,只是很多人没接
3.1 Navigation Timing 和 Resource Timing 才是最底层的答案
浏览器的性能数据其实早就打包好了,等着你调 API。最关键的是performance.timing(新版是performance.getEntriesByType('navigation'))和performance.getEntriesByType('resource')。前者能拿到整个页面从导航开始到加载完成的各个时间节点,后者能拿到页面里每个静态资源、接口请求的详细耗时。
可能你会问“这些不都是浏览器自己算好的吗,为什么还要手动埋点”?因为这两个 API 只给了时间段,不告诉你“首屏元素到底什么时候渲染出来的”。Navigation Timing 里有domContentLoadedEventEnd、loadEventEnd,你确实能靠loadEventEnd - navigationStart拿到完整加载时间,但首屏往往在load之前就已经完成了,你得靠别的手段。
3.2 PerformanceObserver:现代浏览器订阅性能事件的正确姿势
传统的window.onload或performance.timing是“事后去取”的快照,而 PerformanceObserver 是“事件订阅”,一旦浏览器产出了性能条目,立刻推给你。用它的好处是:不污染主流程、能拿到 LCP/FCP 这些高精度指标、支持 buffer 机制防止 entry 丢失。
拿 LCP 来说,页面里最大元素可能加载到一半又被替换了,所以 LCP 存在“后发制人”的情况。标准的监听写法是持续收集所有 LCP 值,等用户交互(比如点击、输入)之后再取最大的那次作为最终 LCP。这块是很多新手容易漏的地方——直接在onload里读一次performance.getEntriesByName('largest-contentful-paint'),读到的可能是 0 或者偏小的中间值。
// 监听 LCP 的标准姿势 let lcpValue = 0; const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); for (const entry of entries) { if (entry.startTime > lcpValue) { lcpValue = entry.startTime; } } }); observer.observe({ type: 'largest-contentful-paint', buffered: true }); // 用户交互后收集最终值 ['keydown', 'click', 'touchstart'].forEach((eventType) => { window.addEventListener(eventType, () => { observer.disconnect(); reportLCP(lcpValue); }, { once: true, passive: true }); });注意这里我加了buffered: true。因为脚本可能是异步加载的,等脚本执行时浏览器已经产出了性能条目,没有 buffered 的话这些历史条目就丢了。
3.3 Synthetic(实验室)和 RUM(真实用户)的场景选择
测首屏时间,行业内分两种路线。Synthetic(实验室监控)是在受控环境里用脚本模拟访问,跑在固定的浏览器、固定的网络条件下,比如 Lighthouse 就是典型的 lab 工具。RUM(真实用户监控)则是在线上页面嵌入 SDK,采集每个真实用户浏览器的性能数据,最终拿到的是“人群分布”。
这两个不是替代关系,而是互补关系。我用一个类比:Synthetic 是体检报告,指标标准化、可复现、方便对比前后改动;RUM 是自由市场里卖菜大爷的实际吆喝声,什么网络都有,数据脏但真实。做决策的话,性能优化发版前必须看 Synthetic,发版后必须靠 RUM 验证真实效果。只做一样,你看不到全貌。
4. 自己埋首屏打点:从轮询到 MutationObserver 的进化
4.1 最简单粗暴的方案:轮询检查关键元素
如果只是临时要个数据,不引入监控 SDK,最直接的办法就是轮询。假设你的首屏区域有一个 id 是#main-content的容器,它内部是接口返回后渲染的列表,那你可以每隔 200ms 查一次document.querySelector('#main-content')有没有非空内容。
这个方案的优点是零依赖、十分钟写完,缺点是计数不稳定、空轮询浪费性能。更麻烦的是,如果首屏元素是图片,图片的 DOM 早就插入了,图片的像素还没解码完成,用户看到的依然是“破相”状态,这个轮询方案就测不准。所以线上做规范化的首屏测速,我更建议用以下几种方式结合。
4.2 MutationObserver:等 DOM 不再变化的那一刻
MutationObserver 可以监听 DOM 树的变化。思路是:从页面开始加载,每次 DOM 变化都重新计时,如果超过一个阈值(比如 1.5 秒)没有任何 DOM 变化,就认为首屏内容稳定了,把这个稳定时间记为首屏时间。
这个思路对于大部分由数据渲染驱动的页面是靠谱的,因为首屏渲染无非是“异步数据到了 → 更新 DOM → 图片加载”,DOM 变化停止大概率意味着视觉稳定。但要注意防抖,否则数据是流式返回的(比如长列表分批加载),DOM 一直有变化,测出来的时间就无限拉长。实际项目里我做了一个上限值,超过 8 秒还没稳定就主动截断。
let timer = 0; let stableTime = 0; const observer = new MutationObserver(() => { clearTimeout(timer); timer = setTimeout(() => { stableTime = performance.now() - startTime; observer.disconnect(); }, 1500); }); observer.observe(document.body, { childList: true, subtree: true });4.3 我是怎么拟合“用户真实感受到的首屏”的
其实轮询和 MutationObserver 都有个问题:测的是“DOM 稳定”,不等于“渲染完成”。后来我做了个折中方案,接近很多商业化监控 SDK 的做法:监听首屏关键模块的图片加载状态。如果首屏区域里主要是一张大图或一组商品图,就对它们的load事件计数,当所有关键图片的load都触发后,才认定首屏完成。
做法不复杂,给首屏区域的img元素添加load监听,全部完成后再记录时间。重点在于“哪些图算首屏关键图”的判定:通常是首屏 DOM 高度范围内能看到的、宽度超过一定阈值的图片。纯图标、占位图、懒加载不可见的图要过滤掉,否则数据会虚高。
5. 手写一个简版首屏测速模块(可直接抄)
5.1 初始化与三个指标的采集实现
下面这份代码我删掉了所有业务噪声,保留了核心逻辑,你可以直接用在自己的个人项目或者小型站点上。它同时采集三类数据:基于performance.getEntriesByType('navigation')的 TTFB、PerformanceObserver 订阅的 FCP/LCP、以及自定义的业务首屏时间(DOM 稳定法)。
class ScreenSpeed { constructor({ reportUrl, sampleRate = 1, maxWait = 8000, domStableThreshold = 1500 } = {}) { this.reportUrl = reportUrl; this.sampleRate = sampleRate; // 采样率,0~1 this.maxWait = maxWait; this.domStableThreshold = domStableThreshold; this.startTime = performance.now(); } init() { if (Math.random() > this.sampleRate) return; this.collectNavigationTiming(); this.watchFCP(); this.watchLCP(); this.watchDOMStable(); this.watchLoadEvent(); } collectNavigationTiming() { const [entry] = performance.getEntriesByType('navigation'); if (!entry) return; const ttfb = entry.responseStart - entry.requestStart; const domReady = entry.domContentLoadedEventEnd - entry.startTime; this.data = { ttfb, domReady }; } watchFCP() { const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); if (entries.length > 0) { this.data.fcp = entries[0].startTime; observer.disconnect(); } }); observer.observe({ type: 'paint', buffered: true }); } watchLCP() { this.lcpCandidates = []; const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { this.lcpCandidates.push(entry.startTime); } }); observer.observe({ type: 'largest-contentful-paint', buffered: true }); ['keydown', 'click', 'touchstart'].forEach((eventType) => { window.addEventListener(eventType, () => { if (this.lcpCandidates.length > 0) { this.data.lcp = Math.max(...this.lcpCandidates); observer.disconnect(); } }, { once: true, passive: true }); }); } watchDOMStable() { let timer = 0; const observer = new MutationObserver(() => { clearTimeout(timer); timer = setTimeout(() => { this.data.domStable = performance.now() - this.startTime; observer.disconnect(); this.report(); }, this.domStableThreshold); }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => this.report(), this.maxWait); } watchLoadEvent() { window.addEventListener('load', () => { this.data.load = performance.now() - this.startTime; }); } report() { if (this.reported) return; this.reported = true; const payload = { url: location.href, timing: this.data, ua: navigator.userAgent, time: Date.now(), }; navigator.sendBeacon && navigator.sendBeacon(this.reportUrl, JSON.stringify(payload)); } } // 使用 const speed = new ScreenSpeed({ reportUrl: '/api/perf', sampleRate: 0.5 }); speed.init();5.2 上报里最容易出错的三个细节
第一个是上报时机。看上面的代码你会发现,report()被两个地方触发:MutationObserver 判定 DOM 稳定时和maxWait定时器到点时,而且用this.reported保证只上报一次。原因很简单,DOM 稳定法遇到流式接口可能导致稳定时间过长,必须加超时兜底,宁可数据偏差也不能丢失。
第二个是上报通道。这里用navigator.sendBeacon,而不是fetch或XMLHttpRequest。页面卸载时发异步请求大概率会丢,sendBeacon 能保证数据在页面关闭前送达后台,而且不阻塞页面卸载。
第三个是数据格式。t 是绝对时间戳,url 去掉了 query 参数里的敏感信息,ua 做了脱敏吗?并没有。严格来说,真实项目里要把navigator.userAgent做哈希或者交给后端解析,避免把完整 UA 塞进日志引起隐私合规问题。小站点可以先不管,但心理要有这根弦。
5.3 验证你的打点是准的,别自嗨
很多同学写完采集代码,不管准不准就发上线了。我建议至少做三步验证。第一步,打开浏览器 DevTools 的 Performance 面板,录制一次完整加载,对比 FCP/LCP 数值是否跟面板的标记点差不多。第二步,用 DevTools 的 Network 面板设置 4G 快慢速,分别跑两遍,确认数值会随网络变慢而变大——如果不变,说明你的采集代码有 bug,可能在读取早于渲染的时间戳。第三步,在真实线上随便访问三个页面,控制台手动输出performance.getEntriesByType('paint')和采集上报的数据对一下,确保中间没有丢字段、时区转换错误。
6. SPA 应用里的首屏时间,很多人的认知是错的
6.1 路由切换时,到底要不要重新数秒
单页应用(SPA)和传统多页应用最大的区别是,页面切换不经过完整刷新。不少人直接把传统页面那套首屏采集搬到 SPA 里,结果发现只有第一次进入时数据是准的,路由切换后采集到的“首屏时间”完全是上一次页面的残留值。
问题的关键在于:路由切换后的首屏,应该按“用户开始切换到新页面内容可交互”来重新计算。这里有一个好习惯:不要在全局初始化时就启动测速,应该在路由变化时重新设置一个标记和起点,并且用一个“按路由隔离”的容器来存储数据,避免串数据。
// 以 Vue Router 为例,React Router 同理 router.afterEach((to, from) => { if (to.path !== from.path) { window.__PAGE_START__ = performance.now(); // 重新监听该路由首屏容器的渲染完成 } });6.2 Vue/React 场景下怎么正确获取“可交互时间”
SPA 的“可交互时间”有个新指标叫 TTI(Time to Interactive),但 TTI 的规范实现越来越复杂。实际项目里我更喜欢用一个近似指标:首屏组件 mounted 并完成首帧渲染的时间。如果是 Vue,可以监听根组件的mounted钩子;如果是 React,可以借助useLayoutEffect或class组件的生命周期。无法访问组件内部逻辑的话,退而求其次用 MutationObserver,但要把监听目标从document.body缩小到当前路由对应的根容器,减少无关 DOM 变化的干扰。
再提醒一句:SPA 里很容易把“接口数据返回时间”当“首屏时间”。这不对。接口返回只是数据可用,页面 DOM 更新到位、图片渲染完成才算首屏结束。我自己见过太多次“接口 200ms 就返回了,但页面 2 秒才渲染完”的情况,如果你只统计接口耗时,等于掩盖了前端渲染的性能债。
6.3 一套懒加载页面的特殊处理
现在很多页面为了体积,把首屏图片设成懒加载。这给测速带来了新问题:DOM 里 img 标签早就存在,但图片是滚动到视口才加载,首屏时间会误判成未加载完成。处理方案是:把图片元素判定为“关键首屏元素”后,主动检查其complete属性和naturalWidth。如果 complete 为 true 且 naturalWidth 大于 0,说明图片已解码;否则需要等它的 load 事件。
如果你的页面用到客户端渲染框架配合预渲染或 SSR,情况更复杂。SSR 出来的页面首屏往往在“HTML 解析完 + 静态样式注入”就能看到,比 CSR 的“JS 下载完再执行再渲染”快得多。测 SSR 页面时,首屏时间应该从requestStart算,而不是从 JS bundle 执行算,否则统计出来的数字会“虚胖”。
7. 常见问题排查与盘点:我踩过的坑一次说清楚
7.1 拿到 abnormal 的数据,先别改业务,先查打点
打点上线后,最怕的就是数据曲线出现奇怪的鼓包或断崖。我遇到过的问题五花八门,列几个有代表性的:
第一个问题:PerformanceObserver 回调不触发。原因基本是三项:脚本是在 iframe 里执行的、浏览器版本不支持(Safari 旧版对largest-contentful-paint支持度差)、或者观察类型写错了字符串(比如'paint'写成了'paintings')。其中 Safari 的兼容性问题最坑,建议封装一层能力检测,不支持就让测速模块静默降级到 navigation timing。
第二个问题:FCP/LCP 数值总是偏小。最常见的原因是页面启用了back-forwardcache(往返缓存)。浏览器会直接恢复页面快照,渲染时间几乎为 0,性能指标会骤降。这时候你要么忽略这类被 bfcache 恢复的页面,要么单独存一个字段标记isRestoredFromBFCache,混在正常数据里只会拉低你的整体均值,看不出真问题。
第三个问题:同一页面在两个手机上的 LCP 差异巨大。不要慌,先看是不是网络差异导致的图片加载时长不同。如果网络都是同等的 4G,差异集中在 300ms 以内,大概率是字体加载或图片解码时机。如果差异巨大且无规律,优先怀疑图片资源有没有命中 CDN 缓存、有没有走 HTTP/2。
7.2 应对性能数据中的典型异常值
真实用户的网络不像实验室那么干净,弱网、断网重连、卡顿都会让数据飘。我通常会对首屏时间做“截断处理”:低于 100ms 的认为是异常(可能是快照页),高于 60s 的直接截断不采。同时要分桶统计,而不是只报一个平均值。举个例子,一个页面 90% 的用户是 1.5 秒,10% 的用户是 20 秒,平均值算下来可能高达 3.5 秒,看起来“中等偏慢”,但实际上 90% 的人体验很好。看 P50、P75、P90 分位更有意义。
7.3 首屏优化方向:拿到数据后你怎么定位瓶颈
拿到首屏时间之后,下一个问题必然是“怎么优化”。我通常按这样的顺序查:先看 TTFB,如果 TTFB 超过 600ms,就要排查服务器响应、接口聚合、CDN 回源;再看渲染主线程,如果 FCP 到 LCP 间隔很长,说明图片加载/解码拖了后腿;如果 DOM 稳定时间远大于 LCP,说明异步渲染逻辑太多,可能要做渲染分片。
这个查问题顺序的核心逻辑是:先分清“网络慢”还是“渲染慢”,再做精细化优化。网络慢的优化手段是缓存、CDN、预连接、压缩;渲染慢的优化手段是精简 JS、去掉无必要的依赖、延迟非关键组件、骨架屏代替白屏。不同病因下错药,越优化越乱。
| 表现 | 排查方向 |
|---|---|
| TTFB 大 | DNS 解析、网关链路、接口合并、CDN 回源 |
| FCP 到 LCP 间隔大 | 最大元素图片加载、视频 poster、图片解码 |
| DOM 稳定时间远超 LCP | 异步渲染次数多、setState 分批、列表渲染过大 |
| 弱网崩溃率升高 | 资源体积过大、并发请求太多、Broken 链接 |
| 笔画分数一致但体验差 | 缺骨架屏、首屏可见元素加载策略不一致 |
8. 数据采集之外的思考:首屏指标只是开始
首屏加载时间这个数值本身并不会让你的页面变快,它是仪表盘、是路标,真正的作用是指引你做出正确的优化决策。这也是为什么我前面反复强调“口径统一”“分位统计”和“数据验证”——这些看起来不性感的细节,恰恰决定了性能优化的方向准不准。
如果要继续往下走,建议你把首屏指标和 Web Vitals 的其他指标(CLS、INP)放在一起看,体验是立体的,单看一个数永远有盲区。再往下,可以尝试接入开源监控平台的采集 SDK,或者基于自己这套逻辑把上报数据接入到现有的日志体系里,形成一个从采集、分析、告警到回归的完整监控闭环。
踩过几次坑之后,我最大的体会是:测首屏时间这件事,难的不是写代码,而是定义“什么才算首屏”。每个人心中首屏的认知都不一样,你把这个标准立住了,工具是手到擒来的事。希望这篇文章能帮你少走点弯路,把你团队的“首屏时间”从玄学变成科学。