2024年3月,Google 正式用 INP (Interaction to Next Paint) 取代了运行了多年的 FID (First Input Delay)。我在维护一个日活 50 万的 SaaS 后台时,第一时间感受到了这个变化。之前 FID 一直绿油油的 20ms,但用户还是抱怨“表格筛选卡顿”、“保存按钮点了没反应”。切换到 INP 测量后,数据直接红给看:INP 高达 450ms。
FID 只测量“第一次”交互的延迟,且只计算从输入到主线程开始处理的时间,不关心处理结果渲染出来的耗时。INP 则是全周期监控,从点击开始,到浏览器把下一帧画出来为止。对于 SPA 应用,FID 完全掩盖了复杂组件更新时的渲染阻塞问题。
在我们的订单管理系统中,有一个包含 200 个表单项(动态渲染)的配置页。用户点击“保存”后,由于主线程被大量的 React 状态计算和 DOM 更新阻塞,导致下一帧迟迟画不出来。
优化前,INP 测量逻辑(问题复现):
优化方案:使用 scheduler.yield() 或 setTimeout 分片
Chrome 115+ 支持 scheduler.yield(),这是目前处理长任务(Long Tasks)的最佳实践。我重构了保存逻辑,把同步的大任务拆散。
数据对比:
在 Moto G Power (Android 10) 这种中低端设备上测试:
有一次线上反馈,用户进入后台首页,还没操作,INP 就已经报红。我通过 Chrome DevTools 的 Performance 面板录制发现,一个埋点脚本在 window.onload 后发起了大量的同步 XHR 请求,并且执行了一段非常重的 DOM 查询脚本,占用了主线程 600ms。
解决方式:
async 并延迟到 load 事件之后执行。这次改动后,首页的 INP 从 320ms 直接掉到了 90ms。INP 时代,任何阻塞主线程的第三方脚本都是性能杀手,必须零容忍。
去年双十一大促前,我接手了一个核心的电商落地页项目。当时线上数据显示,移动端 LCP 中位数高达 4.2s,远超 2.5s 的及格线。这意味着超过一半的用户看到商品主图需要等 4 秒多,转化率自然惨不忍睹。
经过两周的死磕,LCP 压到了 1.8s。以下是具体的实战步骤。
用 WebPageTest 跑了一下,发现 LCP 元素不是图片,而是首屏那个巨大的促销标题文本块(因为字体加载慢,或者被巨大的 CSS 阻塞了)。
排查代码:
我们的商品图之前是 1200x1200 的 WebP,虽然格式对了,但尺寸没控制好。我写了一个 Node.js 脚本,配合 Sharp 库,在服务端实时生成 640w 的 AVIF 格式(比 WebP 再小 20%)。
关键代码:服务端动态裁剪与 CDN 配合
前端配合:
排查发现,首屏的 CSS 文件有 180KB,且是同步加载的。LCP 元素(那个大标题)的渲染被 CSSOM 构建阻塞了。
解决方案:
标签里。。结果:
Cache-Control: immutable,回源率降低了 40%,服务器 CPU 从 70% 降到了 35%。CLS (Cumulative Layout Shift) 是 2021 年定稿的指标,但在 2024 年的 SPA 里依然是个大麻烦。我们那个新闻资讯页,因为顶部有个动态插入的广告位,导致文章标题经常“跳一下”,CLS 高达 0.25(标准是 <0.1)。
最典型的 CLS 杀手就是没有 width 和 height 属性的图片,以及加载后才计算高度的广告 iframe。
排查过程:
我打开 Chrome DevTools 的 Performance 面板,勾选 "Web Vitals"。发现每次页面加载,广告脚本执行后,会插入一个 1. 给广告位预留空间(骨架屏) 不能等广告加载完才撑开高度,必须在 HTML 解析阶段就占好位。 2. 修复图片导致的 CLS 我们文章里的图片是用户上传的,尺寸不一。我写了一个指令,强制给所有文章内的图片加上 3. 解决第三方广告的“二次注入”问题 有些广告脚本很流氓,它会在页面加载后 2 秒再次调整高度。我通过 MutationObserver 监控 DOM 变化,并强制锁定高度。 4. 真实数据反馈 修复前,由于广告加载导致的 CLS 波动很大,有时 0.05,有时 0.3。 修复后,通过埋点上报 最终,全站 CLS 稳定在 0.02 左右。对于电商和媒体站,CLS 优化的核心就是:永远不要在没有预设尺寸(或宽高比)的容器里加载动态内容。 哪怕多一个像素的跳动,都是对用户体验的谋杀。 去年我们团队接手了一个跨境电商后台的重构项目,当时面临一个很现实的问题:原来的后台首屏加载要 3.2 秒,LCP 更是飙到了 4.1 秒,用户经常抱怨点个商品列表要等半天。我们纠结了很久,到底是用 Next.js 13.4 的 App Router 还是 Remix 2.0。最后选了 Next.js,主要就是冲着 React Server Components(RSC)去的,现在回头看这个决定对 Core Web Vitals 的影响确实挺有意思。 先说说 RSC 对 LCP 的影响。我们那个后台的首屏最大元素是个商品数据表格,原来用 Client Component 的时候,得先下载 120KB 的 JS bundle,再发请求拉数据,最后渲染。LCP 基本卡在 3.5 秒左右。换成 RSC 之后,服务器直接把渲染好的 HTML 吐给浏览器,包含表格数据,JS 只负责 hydration。实测下来,LCP 直接从 3.5 秒掉到了 1.8 秒,完全进了“好”的阈值(<2.5 秒)。这里有个细节,RSC 的流式响应很关键,我们用了 不过 RSC 也不是万能的,对 INP 的影响就比较复杂。我们后台有个“批量修改价格”的功能,原来用 Client Component 的时候,点击按钮到响应的延迟是 85ms,挺流畅。换成 RSC 之后,因为交互逻辑还是得靠客户端 JS 处理,结果 INP 反而涨到了 150ms 左右。后来排查发现,RSC 虽然减少了首屏 JS,但 hydration 过程会占用主线程,导致交互响应变慢。我们最后把那个按钮的逻辑拆成了很小的 Client Component,只负责交互,数据展示还是用 RSC,INP 才降回 110ms。这里得注意,RSC 不是让你把所有组件都放服务器,交互密集的部分还是得留一部分客户端代码。 再对比下 Remix。我们同期有个营销页项目用了 Remix 2.0,它的数据加载方式和 Next.js 不太一样,是通过 这里给个我们实际用的 Next.js RSC 代码示例,就是那个商品表格的优化片段: 这段代码里,表格数据直接在服务器获取并渲染,所以 LCP 很快。但编辑按钮单独做成 Client Component,避免整个表格都 hydration。我们当时踩过一个坑,一开始把整个表格都标成 'use client',结果 hydration 时间多了 400ms,INP 直接超标。后来拆出来之后,INP 才达标。 还有个 CLS 的问题,我们那个后台原来有个动态插入的通知栏,经常导致 CLS 到 0.25。后来用 RSC 的时候,直接在服务器渲染的时候就预留了通知栏的高度,给它设了固定的 今年 3 月 Google 把 INP 正式取代 FID 作为核心指标的时候,我们也同步更新了性能优化策略。其中最明显的趋势就是 Edge Computing 和 AI 预加载开始真正落地到项目里,不是以前那种停留在概念层面的东西了。我上个月刚把我们那个新闻资讯网站的首页 LCP 从 2.8 秒优化到 1.9 秒,靠的就是这两招。 先说 Edge Computing。我们那个新闻站原来用的是传统 CDN,静态资源缓存在边缘,但 HTML 还是得回源到新加坡的服务器生成,国内用户访问的话,光 HTML 的 TTFB 就要 600ms 以上。后来我们迁到了 Cloudflare Workers(2024 年最新版本支持 128MB 内存),把 HTML 生成逻辑也放到了边缘节点。具体做法是,边缘节点根据用户 IP 判断地区,预加载对应地区的头条新闻图片,然后直接生成 HTML 返回。实测下来,国内用户的 TTFB 降到了 80ms,LCP 直接少了 500ms。这里有个细节,Edge Computing 不是把所有逻辑都放边缘,我们只放了首屏需要的 HTML 生成,用户评论、相关推荐这些还是回源,因为边缘函数的执行时间有限制(Cloudflare Workers 免费版是 10ms CPU 时间)。 再说说 AI 预加载。我们原来优化 LCP 的时候,都是手动在 这里给个我们在 Cloudflare Workers 里实现边缘渲染的代码片段,就是那个新闻站的首页: 这段代码里,边缘节点直接生成 HTML,并且预加载了头条图片。我们当时遇到一个问题,就是边缘函数的冷启动时间,一开始每次冷启动要 200ms,后来用了 Cloudflare 的 Smart Placement 功能,把 Worker 部署在离用户最近的节点,冷启动时间降到了 30ms 以内。另外,内联关键 CSS 也很重要,我们原来把 CSS 放在外部文件,LCP 要多 200ms,内联之后立马改善。 AI 预加载的部分,我们用了 TensorFlow.js 在客户端跑一个轻量模型,训练数据是过去 30 天的用户访问日志(大概 50 万条记录)。模型输入是页面的 URL、用户设备类型、网络状况(通过 这个模型现在预测准确率是 92%,比我们手动配置的时候高多了。不过有个坑,一开始我们在模型里用了太多特征,导致模型体积有 500KB,反而影响了 LCP。后来精简了特征,只保留了三个最关键,体积降到 120KB,才解决了这个问题。 去年黑五大促的时候,我们电商网站突然收到一堆用户投诉,说加购按钮点了没反应。我登上去看监控,发现 INP 突然飙到了 350ms,早就超过了 200ms 的“好”阈值。但当时我们的告警体系只监控了服务器 CPU 和接口响应时间,完全没覆盖到 Core Web Vitals,导致问题发现晚了半小时,直接影响到了订单转化率。那次之后,我们花了一个月时间搭了一套基于 RUM 的性能告警体系,现在基本能提前 10 分钟发现指标异常。 我们的 RUM 数据是用 Google Analytics 4 的 Web Vitals 插件采集的,每个用户访问的时候,都会上报 LCP、INP、CLS 的数据,还有用户的设备、网络、地理位置信息。数据量大概每天 200 万条,存在 BigQuery 里。告警体系的核心是“分层阈值”,不是一超标就发告警,而是分三个等级: 这里有个关键,我们不是只看平均值,而是看 P75 和 P95 分位。因为平均值很容易被少数高速用户拉低,比如我们原来平均 LCP 是 2.2 秒,看起来挺好,但 P95 是 4.5 秒,说明有 5% 的用户体验很差。现在告警都是基于 P95 设置的,LCP P95 > 3.5s 就预警,> 4.5s 就告警。 告警触发之后,复盘的流程也很重要。我们每次告警都会自动生成一个复盘报告,包含几个部分:指标趋势图(过去 24 小时的 LCP/INP/CLS 变化)、影响的用户范围(多少用户、哪些地区、什么设备)、可能的原因(比如某个新上线的功能、CDN 节点故障)、修复方案。上个月有一次告警,INP 突然涨到了 250ms,复盘报告里显示是 10 分钟前上线的一个新的促销弹窗导致的。那个弹窗的关闭按钮用了很复杂的动画,占用了主线程。我们立刻回滚了那个弹窗,INP 马上降回 120ms。 给个我们采集 RUM 数据的代码示例,就是前端埋点的部分: 这段代码的 告警的规则是用 BigQuery 的定时查询实现的,每 5 分钟跑一次,计算过去 15 分钟的 P95 LCP 是否超标。如果超标,就调用 Slack API 发消息。查询语句大概是: 这个查询每次运行大概 2 秒,5 分钟一次完全没问题。我们之前试过用 1 分钟一次的查询,结果 BigQuery 的费用涨了不少,后来发现 5 分钟足够了,因为性能问题一般不会在 1 分钟内突然消失。 还有个实际遇到的问题,就是第三方广告导致的 CLS 告警。有一次我们的 CLS 突然涨到 0.2,复盘发现是一个新的广告 SDK 动态插入了广告,没有预留空间。我们在 RUM 数据里加了广告加载的事件上报,发现广告加载完成后的 500ms 内 CLS 飙升。后来和广告团队沟通,要求他们所有广告都必须在加载前设置固定的尺寸,CLS 马上降回 0.05 以下。这个案例也说明,RUM 数据不仅能监控,还能帮我们定位具体是哪个模块导致的问题。 去年我接手了一个工具类 SaaS 后台的重构,业务场景比较特殊:用户登录后第一件事就是看数据看板,但那个页面的 LCP 常年卡在 3.5s 以上,用户抱怨加载慢,流失率也不低。 我第一反应是堆资源优化:换成 我最后做的决定其实挺“反潮流”:把那个看板页改成 边缘渲染(Edge SSR),只做首屏骨架和关键数据,非核心权限逻辑全部挪到客户端 hydration 后再补。技术选型上我选了 Next.js 的 Edge Runtime,没上 Remix,因为当时 Remix 对边缘函数的支持还没那么顺手。 上线后 LCP 直接掉到 1.6s,但我也付出了代价:边缘函数的冷启动和内存限制让我不得不重写了一部分重度依赖 Node API 的逻辑,那一周我几乎天天在修 优化 Core Web Vitals 最怕的不是技术难,而是你还没搞清楚到底是哪一段路径在拖慢用户,就开始照着清单无脑堆方案。先测、再想、最后动代码,比看十篇指南都管用。实战修复:Aspect Ratio Box 与 Session Storage
width 和 height 属性,或者统一用 CSS 锁定宽高比。layout-shift 值:Next.js App Router vs Remix:RSC对Core Web Vitals的实战影响
loading.tsx 做 Suspense 边界,服务器先发一个骨架屏,再慢慢把表格数据推过来,用户感知到的加载速度快了不止一点。loader 函数在服务端获取数据,然后渲染页面。LCP 表现也不错,大概 2.1 秒,但比 Next.js 的 RSC 慢了 300ms 左右。原因是 Remix 的 HTML 是完整生成的,没有流式传输,得等所有数据都拿到才发回浏览器。不过 Remix 对 INP 的优化更直观,因为它的路由级数据加载和客户端导航很顺畅,交互响应基本稳定在 90ms 以内。CLS 方面两者差不多,只要处理好图片尺寸和动态内容,都能控制在 0.05 以下。min-height: 40px,CLS 立马降到 0.02。这个比在客户端用 JS 算高度靠谱多了,因为服务器渲染的 HTML 已经包含了正确的布局。2024趋势:Edge Computing与AI预加载在LCP优化中的落地
里加 ,但经常预加载错了元素。比如有一次首页改版,最大元素从大图变成了视频,我们没及时更新预加载,结果 LCP 反而涨了 1 秒。后来我们接入了一个基于机器学习的预加载工具,它会在用户访问的时候,收集页面的 LCP 元素特征(比如元素类型、位置、大小),然后训练模型预测下一个用户可能看到的 LCP 元素。现在我们的预加载逻辑是这样的:模型预测当前页面 85% 概率 LCP 是头条图片,就自动预加载那张图,准确率比手动配置高多了。实测下来,预加载的命中率从原来的 60% 提升到了 92%,LCP 又降了 300ms。navigator.connection.effectiveType 获取),输出是 LCP 元素的 URL。模型训练好之后,体积只有 120KB,加载不影响性能。我们在 里加了一段代码,先加载模型,然后预测并预加载:从监控到复盘:基于RUM数据的性能告警体系搭建
web-vitals 版本是 3.5.0,是当前最新的稳定版。reportAllChanges: false 很重要,不然 LCP 会报告每次变化,数据量太大。我们后端接收这个数据的接口,每天能处理 200 万条请求,用了 Node.js 18.19 写的,配合 Redis 做缓冲,QPS 峰值能到 3000,没出过问题。 站长实战手记
一次让我印象最深的 LCP 优化经历
、拆 vendor、把首屏用到的 JS 全部 inline。折腾了一圈,LCP 只降了 0.3s,收益极小。后来我直接开 Chrome DevTools 的 Performance 面板一条条看,才发现问题根本不在 JS,而是 服务端渲染时,首屏 HTML 里塞了太多权限校验和个性化数据接口的结果,导致 TTFB 一直在 1.8s 左右徘徊。Edge runtime not support 的报错。我真实的取舍看法
给读者的一句话