先从一个有点尴尬的优化场景说起
很多团队在过去两年里,把 First Input Delay(FID)优化到了非常漂亮的程度。页面加载后,用户第一次点击的响应延迟,长期保持在 100ms 以内,实验室数据甚至更低。但奇怪的是,用户反馈里的“卡顿”“点了没反应”并没有消失。

这种体验上的错位,其实暴露了 FID 这个指标的一个关键盲区:它只测量了事件处理线程被阻塞的时间,并没有覆盖整个交互过程。用户点了一下,浏览器确实很快执行了事件回调,但界面什么时候真正完成画面更新,FID 根本不知道。
这也正是 Interaction to Next Paint(INP)出现的背景。作为 Core Web Vitals 体系里新一代的交互响应指标,INP 从 2024 年 3 月开始正式取代 FID,成为衡量页面响应速度的核心指标。这一变化不是简单的指标替换,而是评估思路的一次实质转向。
FID 到底漏掉了什么
FID 的全称是 First Input Delay,它衡量的是从用户第一次与页面交互,到浏览器主线程能够开始处理事件回调的时间。这个定义的落脚点非常明确:加载阶段的主线程阻塞情况。
问题就出在“第一次”和“开始处理”这两个限定词上。
FID 只关心页面加载后的第一次交互。用户后续的点击、键盘输入、拖拽,都不在它的观测范围内。如果一个页面加载很顺畅,但后续交互里频繁出现长时间的任务占用主线程,FID 的分数依然可以是绿色的。
另外,FID 只统计到“开始处理”事件为止。即使事件回调本身很慢,或者回调里触发的同步渲染把后续帧都卡住了,FID 也一概不管。换句话说,FID 衡量的是饥饿时间,而不是整个交互的完成时间。
于是就会出现开头说的那个场景:FID 分数很好,用户实际体验却依然拖沓。因为真正让用户感觉迟钝的,往往是事件处理、布局计算、渲染合成这一整条链路的耗时,而不是最初的排队等待。
INP 衡量的是完整的交互生命周期
INP 的全称是 Interaction to Next Paint,它把测量范围从“第一次输入”扩展到了“所有有意义的交互”,并且把终点从“事件开始处理”延长到了“下一帧画面呈现”。
一次交互的时间线,在 INP 的模型里大致是:用户触发事件,浏览器派发事件,事件回调执行,可能触发样式计算和布局,最后渲染到屏幕。INP 记录的是从用户输入开始,到屏幕完成下一次绘制为止的完整耗时。
这里有几个和 FID 很不一样的细节:
- INP 观测整个页面生命周期内所有符合条件的交互,不只是第一次。
- INP 统计的是交互到下次绘制的时间,而不是事件回调开始执行的时间。
- INP 在报告时取的是所有交互里比较差的 P98 分位数,而不是平均值或最大值。
这意味着一个页面里只要存在少数几次特别慢的交互,INP 分数就会被明显拉低。这种评估方式和用户实际感受是更接近的:用户不会因为“平均表现不错”就忽略那几次明显的卡顿。
为什么是 P98,而不是平均值或者最大值
有人会问:既然要反映真实体验,为什么不直接看最大值?
从工程角度看,最大值往往意味着异常,比如用户设备处于极端低电量状态,或者后台线程正在执行系统级的任务。这些情况不是页面本身能完全控制的,把最大值作为核心指标容易让团队陷入无意义的优化。
平均值又太容易被“大多数轻量交互”稀释。一个页面上可能有 90% 的交互是滚动、悬停这类轻量操作,只有 10% 的交互是点击按钮、提交表单这种重量操作。平均值会把重量交互的慢吞没在轻量交互的快速里。
P98 意味着保留 2% 的最差体验作为关注对象,既避免了最大值的偶发噪声,又不会让平均值掩盖问题。对大多数内容型站点来说,P98 的 INP 能比较准确地指出真实用户遇到的最明显的交互延迟。
INP 的判定标准与核心机制
按照 Google 给出的建议,INP 的评估区间分为三档:
| 评级 | INP 数值 | 说明 |
|---|---|---|
| 良好 | ≤ 200ms | 大多数交互能即时反馈,体验流畅 |
| 需要改进 | 200ms – 500ms | 部分交互存在可感知延迟 |
| 较差 | 超过 500ms | 交互响应有明显卡顿,用户容易流失 |
200ms 这个阈值并不是拍脑袋定的。从人类感知心理学的角度看,100ms 以内的响应被认为是即时反馈,300ms 以上用户就会明确感知到延迟。INP 把良好线定在 200ms,留给页面一定的处理余量,同时也对性能提出了硬性要求。
要理解 INP 为什么难以优化,需要先理解它的测量机制。INP 不是通过 PerformanceObserver 直接读取一个现成的时间戳,而是需要结合 Event Timing API 来计算。下面是一段用 web-vitals 库采集 INP 的典型写法:
import {onINP} from 'web-vitals';
onINP((metric) => {
// 上报 INP 分数以及对应的交互类型
console.log('INP:', metric.value);
console.log('来源:', metric.entries.map((e) => e.name));
// 也可以把 attribution 信息上报,便于定位问题
if (metric.attribution) {
console.log('耗时最长的交互:', metric.attribution.eventEntry?.name);
console.log('目标元素:', metric.attribution.eventEntry?.target);
}
});
web-vitals 库在计算 INP 时,内部会通过 PerformanceObserver 监听事件类型为 first-input、pointerdown 和 keydown 的条目。最终报告的 INP 值,是所有这些交互样本中 P98 位置的耗时。
值得注意的是,INP 观测的交互类型里,点击(click)、触摸(tap)、键盘输入(keydown)是被重点关注的。纯滚动和悬停这类交互虽然也会产生事件,但不会被计入 INP 的主要样本,因为它们的响应方式不同,对用户感知的影响也不一样。
INP 优化:真正的难点在哪里
如果说 FID 的优化重点是“减少加载阶段的长任务”,那么 INP 的优化范围就宽得多。它要求页面在任意时刻、任意交互上都保持主线程的相对空闲。
很多团队一开始会陷入一个误区:以为把 JavaScript 包体减小、加载速度变快,INP 就会自动变好。实际上,INP 和加载速度的关系并不直接。一个图片很多、脚本很大的页面,只要交互回调足够轻,INP 依然可以很好;反过来,一个加载很快的页面,如果点击按钮后触发了一个复杂的同步计算,INP 照样会红。
真正影响 INP 的,是主线程上的任务拆分方式。
比如下面这段代码,点击按钮后执行一个很重的数据处理任务,同步阻塞主线程 500ms:
button.addEventListener('click', () => {
// 同步处理大量数据,阻塞主线程
const result = processLargeData();
renderResult(result);
});
在 INP 的模型下,用户点击按钮后,从事件触发到画面完成下一次绘制,可能已经过去了 600ms 甚至更多。即使整个任务最终执行完成,用户感受到的仍然是“点了没反应”。
把任务拆分成异步分片,是常见的优化手段:
button.addEventListener('click', async () => {
// 先让出主线程,让浏览器有机会绘制点击反馈
await new Promise((r) => setTimeout(r, 0));
// 分片处理数据,避免长时间占用主线程
for (const chunk of splitData()) {
await nextFrame();
processChunk(chunk);
}
renderResult();
});
这种做法的本质,是把一个长任务拆成多个短任务,在任务之间让浏览器有机会完成渲染。但这里有个非常容易踩的坑:不是所有任务都能被随意拆分。如果拆分后的每一片仍然有 100ms,并且中间没有真正的空闲帧,INP 依然不会改善。
关键不在“拆分”这个动作本身,而在“是否真的让出了渲染机会”。有些团队把任务用 setTimeout 包了一层,但内部仍然是同步大循环,这等于换了个马甲继续阻塞主线程。
常见误区:只看耗时,不看交互频率
INP 优化的另一个常见误区,是只关注单次交互的耗时,忽略交互频率对 P98 的影响。
假设一个页面有 100 次交互,其中 98 次是快速点击,2 次是慢速的键盘输入。如果这 2 次慢交互都发生在同一个输入框的实时搜索联想上,那么 INP 的 P98 就会被拉得很差。而实时搜索联想这种功能,本身又很容易成为性能重灾区:每次按键都触发请求,响应回来后又同步更新 DOM,频繁的输入事件让主线程疲于奔命。
对于这类场景,常见的优化策略是 debounce 或 throttle,但只做 debounce 并不够。debounce 只能减少请求频率,如果每次请求回调里的 DOM 更新仍然很重,INP 还是会被拖累。
更好的做法是把输入处理和渲染更新解耦:输入事件只负责更新一个简单的状态,真正的数据请求和列表渲染放到异步任务里,并且在渲染时考虑虚拟列表或分片渲染。这样即使输入频率很高,每一次交互的响应时间也能保持稳定。
方案对比:INP 落地时常见的几种技术路线
在实际优化 INP 的过程中,不同团队会走上不同的技术路线。每种路线都有其适用场景,也有明显的代价。
| 优化方案 | 核心思路 | 适用场景 | 主要代价 |
|---|---|---|---|
| 减少主线程长任务 | 拆分重任务,避免长同步计算 | 重计算逻辑较多的业务页面 | 代码复杂度提升,需要重构任务调度 |
| Web Worker 迁移 | 把数据处理放到独立线程 | 数据量大的表格、富文本编辑器 | 通信成本,不适合所有计算 |
| 降低渲染成本 | 减少 DOM 节点,优化样式计算 | 列表页面、仪表盘 | 需要前端架构层面调整 |
| 优化事件回调 | 精简回调逻辑,延迟非关键操作 | 交互密集的表单、搜索框 | 需要细致的业务分析 |
Web Worker 是很多人第一时间想到的方案,但它并不是万能药。把数据计算放到 Worker 里,确实能释放主线程的压力,但 Worker 和主线程之间的消息传递有成本。如果交互本身需要频繁从 Worker 拿数据,消息通信的延迟反而可能让 INP 变差。
更稳妥的做法是:先通过 Performance 面板定位到耗时最长的交互,再判断这个交互里的耗时主要花在 JavaScript 执行、渲染还是布局上,最后针对性地选择优化路线。
落地实践:从监控到优化的三步走
对于正准备开始做 INP 优化的团队,我建议分三步走。
第一步,先在真实环境里收集 INP 数据。实验室环境很难模拟出真实用户设备的千奇百怪,必须依赖 Field Data。可以通过 Chrome 用户体验报告(CrUX)查看线上域名的 INP 分布,也可以在自己的前端监控平台里接入 web-vitals 库,把 INP 作为独立指标上报。
第二步,建立 INP 与业务场景的关联。INP 是一个综合指标,单一数值很难直接告诉你该改哪里。需要配合 attribution 信息,把耗时最长的交互类型、目标元素、发生时间一起上报,才能定位到具体的功能模块。比如你发现“点击商品卡片”的交互 INP 明显偏高,就可以顺着这条线索去查卡片点击后的逻辑。
第三步,针对慢交互做逐项优化。一个比较实用的排查方法是在本地用 Performance 面板录制交互过程,把帧时间线打开,观察从事件触发到下一帧绘制之间,主线程上到底执行了什么。这里有一个判断原则:如果一个交互的耗时主要花在长任务上,优先考虑拆分任务;如果耗时主要花在布局和绘制上,优先考虑减少 DOM 规模和样式复杂度。
一个容易被忽略的点:第三方脚本往往会在用户交互时同步执行大量逻辑。广告脚本、监控脚本、A/B 测试脚本,都有可能成为 INP 的隐形杀手。排查时如果发现主线程时间线上有来自第三方域名的长任务,不要犹豫,先尝试延迟加载或者移除。
INP 不是性能优化的终点
从 FID 到 INP 的转变,反映的是整个行业对“响应体验”理解的深化。FID 是加载性能的子集,而 INP 更像一个持续运行的健康监测器,它覆盖了整个用户旅程中的交互响应质量。
当然,INP 也不是银弹。它更偏向衡量“交互响应是否及时”,而不太关注视觉流畅度。一个页面如果交互响应很快,但动画掉帧严重,INP 依然可能表现良好。这也是为什么 Core Web Vitals 体系里还保留了 LCP(加载性能)和 CLS(视觉稳定性),三者各有分工。
对前端团队来说,与其纠结“INP 是不是比 FID 更科学”,不如早点把监控体系切换过去。毕竟 INP 已经正式进入 Core Web Vitals,搜索引擎的排名机制也在逐步调整。更重要的是,以 INP 为目标做优化,能让团队从“加载快”走向“用起来也快”,这才是用户真正能感知到的体验提升。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/467/