跳到主要内容

交互性能与 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优先定位最慢交互和主线程长任务
页面级 INP 与站点 P75 不是一回事

一次页面访问通常报告最慢交互;交互数量很多时会按规则忽略极少量最高异常值。监控平台再把大量页面访问聚合成 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 install web-vitals
report-inp.ts
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 去重并保留最终值。

四、如何在实验室复现慢交互?

  1. 从 RUM 选择真实慢路由、设备档位、浏览器版本和交互目标。
  2. 使用 Performance 面板开启与现场接近的 CPU/网络限制。
  3. 页面加载期间和加载完成后都执行一次真实用户流程。
  4. 在 Interactions/Timings 中选中慢交互,检查 Main 火焰图。
  5. 将脚本执行、Recalculate Style、Layout、Paint 与交互三阶段对齐。
  6. 修改后用同一流程重复录制,并回到现场数据验证 P75。
Lighthouse 不能自动覆盖所有 INP 问题

没有执行真实交互的 Lighthouse 加载审计不会得到有代表性的 INP。TBT 可以提示加载期主线程阻塞,但不是 INP 的替代品。复杂交互需要脚本化用户流程或人工录制。

五、拆分长任务并主动让出主线程

5.1 scheduler.yield() 与降级

yield-to-main.ts
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 总分都不够。

相关链接