交互性能与 INP 优化
问题
如何系统优化点击、输入、筛选、拖拽和路由切换等交互性能?INP 应该如何测量、拆解和定位?
INP 衡量的是用户点击、触摸或按键开始,到浏览器呈现下一帧反馈的延迟。单次交互分为三段:
- Input Delay:交互发生后,事件回调为什么迟迟不能开始,常见原因是前一个长任务或第三方脚本占着主线程。
- Processing Duration:事件回调、同步计算、状态更新本身执行了多久。
- Presentation Delay:回调结束后,样式、布局、绘制和框架提交为什么还没产出下一帧。
优化顺序是:先用 RUM 找到慢页面和具体交互,再在相同设备与操作路径下录制 Performance trace;先给用户即时反馈,然后减少同步工作、拆分长任务、缩小渲染范围,最后才考虑 Worker。生产采集优先用 web-vitals/attribution,不要自己用事件数组粗略计算 INP。
一、INP 到底衡量什么?
INP 观察页面生命周期内的点击、触摸和键盘交互,并为一次页面访问给出一个接近最慢交互的值。滚动、悬停和缩放本身不属于 INP 交互,但它们引发的点击或按键仍会被测量。
| 评级 | INP | 使用方式 |
|---|---|---|
| 良好 | ≤ 200ms | 分别查看移动端、桌面端页面访问样本的 P75 |
| 需要改进 | 200–500ms | 按路由、设备、版本和交互目标分群 |
| 较差 | > 500ms | 优先定位最慢交互和主线程长任务 |
一次页面访问通常报告最慢交互;交互数量很多时会按规则忽略极少量最高异常值。监控平台再把大量页面访问聚合成 P75。不能把“单页内部的最慢交互”和“站点样本 P75”混成同一个统计过程。
二、一次交互的三段延迟
2.1 Input Delay
输入已经到达,但主线程正在执行其他任务,事件回调只能排队。
常见原因:
- 页面启动阶段下载和执行大量 JavaScript。
- 定时器、埋点、广告或 A/B 脚本在交互前执行长任务。
- 一个大任务虽然与当前按钮无关,却占满了主线程。
- 频繁的小任务连续排队,留不出浏览器处理输入的机会。
优化重点:延后非关键脚本、减少启动期 JavaScript、把工作拆成可中断的任务,并在交互前预计算真正必要的数据。
2.2 Processing Duration
事件回调开始后,同一逻辑交互关联的回调执行时间。
button.addEventListener('click', async () => {
// 先提交用户能看到的反馈
button.dataset.state = 'processing';
button.textContent = '处理中…';
await yieldToMain();
// 再执行可延后的业务工作
const result = calculateResult(formState);
renderResult(result);
});
处理阶段常见问题包括同步 JSON 解析、大数组排序、复杂校验、循环 setState、同步读写存储和一次性创建大量对象。
2.3 Presentation Delay
事件回调已经结束,但浏览器还需要完成样式计算、布局、绘制和合成,下一帧才能显示。
常见原因:
- 一次状态更新使大面积组件树重新渲染。
- DOM 过大,样式计算或 Layout 成本高。
- 在同一任务中交替读取几何属性和写样式,产生布局抖动。
- 大图片解码、Canvas 绘制、阴影/模糊和大面积 Paint。
- 框架提交阶段同步挂载大量节点。
三、生产环境如何采集与归因?
- npm
- Yarn
- pnpm
- Bun
npm install web-vitals
yarn add web-vitals
pnpm add web-vitals
bun add web-vitals
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const payload = {
name: metric.name,
id: metric.id,
value: metric.value,
delta: metric.delta,
rating: metric.rating,
navigationType: metric.navigationType,
route: toRouteTemplate(location.pathname),
buildId: window.__BUILD_ID__,
target: sanitizeTarget(metric.attribution.interactionTarget),
inputDelay: metric.attribution.inputDelay,
processingDuration: metric.attribution.processingDuration,
presentationDelay: metric.attribution.presentationDelay,
};
navigator.sendBeacon('/rum', JSON.stringify(payload));
}, { reportAllChanges: true });
推荐同时记录:
- 路由模板、页面类型、构建版本和灰度组。
- 设备档位、视口、有效网络类型和是否处于启动期。
- 慢交互目标、事件类型和三个阶段耗时。
- 同一用户流程的业务标记,例如搜索输入、打开弹窗、切换 Tab。
不要上报输入内容、完整 DOM、token 和带敏感参数的 URL。后端按 metric id 去重并保留最终值。
四、如何在实验室复现慢交互?
- 从 RUM 选择真实慢路由、设备档位、浏览器版本和交互目标。
- 使用 Performance 面板开启与现场接近的 CPU/网络限制。
- 页面加载期间和加载完成后都执行一次真实用户流程。
- 在 Interactions/Timings 中选中慢交互,检查 Main 火焰图。
- 将脚本执行、Recalculate Style、Layout、Paint 与交互三阶段对齐。
- 修改后用同一流程重复录制,并回到现场数据验证 P75。
没有执行真实交互的 Lighthouse 加载审计不会得到有代表性的 INP。TBT 可以提示加载期主线程阻塞,但不是 INP 的替代品。复杂交互需要脚本化用户流程或人工录制。
五、拆分长任务并主动让出主线程
5.1 scheduler.yield() 与降级
interface TaskScheduler {
yield?: () => Promise<void>;
}
const taskScheduler = (globalThis as typeof globalThis & {
scheduler?: TaskScheduler;
}).scheduler;
export async function yieldToMain(): Promise<void> {
if (taskScheduler?.yield) {
await taskScheduler.yield();
return;
}
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
export async function processInChunks<T>(
items: T[],
processItem: (item: T) => void,
chunkSize = 100,
): Promise<void> {
for (let start = 0; start < items.length; start += chunkSize) {
const chunk = items.slice(start, start + chunkSize);
chunk.forEach(processItem);
await yieldToMain();
}
}
scheduler.yield() 仍需做特性检测。示例中的 100 条只是普通中端设备的初始值,真正的批大小应根据单批执行时间校准,通常希望在需要响应交互的阶段显著低于 50ms。
5.2 不要把任务切得过碎
每条数据都创建一个宏任务会增加调度、闭包和消息开销。合理做法是按时间或批量切片:每批处理足够多的数据,达到时间预算后再 yield。
async function processByDeadline<T>(
items: T[],
processItem: (item: T) => void,
budgetMs = 8,
): Promise<void> {
let index = 0;
while (index < items.length) {
const start = performance.now();
while (index < items.length && performance.now() - start < budgetMs) {
processItem(items[index]);
index += 1;
}
if (index < items.length) await yieldToMain();
}
}
这里的 8ms 假设目标设备至少为 60Hz,并为渲染保留余量;120Hz、低端设备或工作更重时需要重新测量。
六、先反馈,再完成重工作
用户最关心的是操作有没有被接收。按钮按下、复选框切换、拖拽开始时应尽快产生视觉反馈,后续计算可以分批完成。
async function applyFilters(filters: Filters): Promise<void> {
showFilterPendingState();
await yieldToMain();
const result = await filterLargeDataset(filters);
renderFilteredResult(result);
}
注意:展示 spinner 后立刻在同一任务中执行 300ms 同步计算,spinner 仍然画不出来。必须在反馈 DOM 更新后真正让出主线程。
七、缩小渲染和提交范围
- 状态尽量放在真正消费它的子树,避免一个输入框让整个页面重渲染。
- 大列表使用分页或虚拟化;一次交互只更新可见区域。
- 先批量读取布局,再批量写入,避免 Layout Thrashing。
- 独立区域可使用
contain;长页面屏外区域可评估content-visibility: auto。 - React/Vue 的 memo、computed、
useTransition等都应以 Profiler 为依据,不要全局盲目缓存。
import { useDeferredValue, useMemo, useState } from 'react';
function SearchPanel({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const filtered = useMemo(
() => filterItems(items, deferredQuery),
[items, deferredQuery],
);
return (
<>
<input value={query} onChange={(event) => setQuery(event.target.value)} />
<ResultList items={filtered} />
</>
);
}
延迟更新可以保持输入反馈,但不会减少计算总量。数据量非常大时还需要索引、服务端查询或 Worker。
八、什么时候使用 Web Worker?
适合 Worker 的任务通常具备三个条件:CPU 密集、可与 DOM 分离、单次或累计耗时足以覆盖通信成本。
| 任务 | 优先方案 |
|---|---|
| 大文件哈希、图片处理、复杂排序 | Worker + Transferable |
| 大量纯数据聚合 | Worker 或服务端计算 |
| 每次只需 1–2ms 的简单校验 | 留在主线程,避免通信开销 |
| 必须频繁读写 DOM | 优化渲染范围,Worker 不能直接操作 DOM |
Worker 仍要设计任务 id、取消、超时、错误和背压。把计算移到 Worker 只是不阻塞主线程,并不会减少设备的总 CPU、耗电和内存。
九、Long Task 与 Long Animation Frame
Long Task 关注超过 50ms 的主线程任务,适合发现输入延迟来源;Long Animation Frame(LoAF)关注一整帧为什么超过目标时间,能关联脚本与渲染阶段,更适合解释 presentation delay。
if (PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn('LoAF:', {
startTime: entry.startTime,
duration: entry.duration,
entry,
});
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
}
LoAF 仍需渐进增强,生产监控应记录浏览器支持情况,不能因为没有 LoAF entry 就认定页面没有卡顿。
十、交互性能检查清单
- RUM 能定位到路由、版本、设备和交互目标
- INP 使用官方库采集,并记录三个阶段归因
- 页面加载期间也测试常见交互
- 先展示反馈,再让出主线程执行后续工作
- 长任务按时间预算拆分,而不是机械按条数拆分
- 大计算评估 Worker,消息支持取消、超时和背压
- 状态更新和 DOM 变化控制在最小必要范围
- 第三方脚本纳入 CPU 和交互预算
- 低端设备、高刷新率设备和真实长会话均经过验证
- 优化后同时验证实验室 trace 与现场 P75
常见面试问题
Q1: INP 和 FID 有什么区别?
答案:
FID 只测第一次交互在事件回调开始前的输入延迟;INP 观察整个页面生命周期,并覆盖输入延迟、事件处理和下一帧呈现。FID 可能很好,但后续筛选或打开弹窗仍然很卡,因此 INP 更接近真实交互体验。
Q2: INP 为什么不是所有交互的平均值?
答案:
平均值会掩盖少数但严重影响用户的慢交互。INP 希望保证页面几乎一直能快速响应,所以一次页面访问通常关注最慢交互;交互非常多时才按规则忽略少量最高异常值。站点层面再查看页面访问样本的 P75。
Q3: INP 高应该先看哪一段?
答案:
先看 attribution 的 input delay、processing duration、presentation delay。输入延迟高查交互前长任务;处理高查事件回调和同步计算;呈现高查框架提交、DOM、样式、布局和绘制。三段的优化手段不同,不能统一回答“上 Worker”。
Q4: 为什么更新了 Loading,用户还是看不到?
答案:
因为 DOM 更新和后面的同步重计算在同一个任务中,浏览器要等任务结束才有机会绘制。应先更新 Loading,再通过 scheduler.yield() 或降级任务真正让出主线程,然后执行重工作。
Q5: setTimeout(fn, 0) 能解决所有长任务吗?
答案:
不能。它只能建立任务边界,仍有最小延迟、优先级和调度开销,也不保证下一次一定先处理用户输入。应按时间预算拆分任务,支持时优先 scheduler.yield(),同时减少总工作量。
Q6: TBT 能代替 INP 吗?
答案:
不能。TBT 是加载期实验室指标,没有执行真实交互也能计算;INP 是现场交互指标,覆盖页面整个生命周期。TBT 可以提示启动期主线程是否繁忙,但无法证明搜索、弹窗、拖拽等业务交互足够快。
Q7: 什么情况适合使用 Web Worker?
答案:
当任务是明显的 CPU 密集纯计算、不能再显著减少工作量、又不依赖 DOM 时适合 Worker,例如哈希、图像处理和大数据聚合。小任务或频繁传输复杂对象可能得不偿失,必须把启动和通信成本一起压测。
Q8: React 的 useTransition 能直接降低 INP 吗?
答案:
它可以降低非紧急更新的优先级,让输入等紧急反馈先发生,但不会减少计算和渲染总量。同步事件回调本身很重、DOM 很大或第三方脚本阻塞时,仍要拆任务、缩小更新范围或调整架构。
Q9: Long Task 和 LoAF 有什么区别?
答案:
Long Task 找超过 50ms 的主线程任务,擅长解释输入为什么迟迟不能处理;LoAF 从一整帧出发,能同时看到脚本和渲染成本,更适合分析下一帧为什么呈现得晚。两者结合比只看 FPS 更有效。
Q10: 为什么不能只在高性能电脑上测交互?
答案:
主线程任务在低端 CPU 上会被放大,高端电脑上的 20ms 任务在普通手机上可能变成长任务。应从 RUM 得到真实设备分布,在代表性中低端设备上复现,并分别观察移动端和桌面端 P75。
Q11: 如何治理第三方脚本对 INP 的影响?
答案:
先按域名和脚本归属统计下载、执行和长任务;按同意状态、页面类型与交互阶段延迟加载;给第三方设置数量、体积和 CPU 预算;无法优化的脚本通过 Worker/iframe 隔离或替换供应商。async 只解决下载阻塞,不解决执行时占主线程。
Q12: 优化 INP 后如何证明有效?
答案:
实验室中用同一设备、页面状态和操作脚本比较交互三阶段与 trace;线上按版本、路由、设备做灰度对比,观察 INP P75、慢交互比例和业务成功率。只看一次 DevTools 录制或 Lighthouse 总分都不够。