← 返回文章

JavaScript / 浏览器 / 前端性能

从一次点击到下一帧:前端 INP 性能优化实战

用户点击按钮后,页面没有立刻变化;输入搜索词时,字符像“黏”在键盘上;展开菜单时,过了一小会儿才出现。这些问题看起来各不相同,本质上都和交互响应有关。

INP(Interaction to Next Paint)关注的正是这段体验:用户发起交互后,浏览器需要多久才能呈现下一帧视觉反馈。它不是单纯统计某个事件处理函数执行了多久,而是把输入等待、事件处理和下一次绘制前的延迟一起纳入观察。本文不从“背指标”开始,而是沿着浏览器真正做事的路径,给出一套可以落地的排查与优化方法。

一次交互为什么会慢

把一次点击拆开,大致会经历三个阶段:

  1. 输入延迟:交互已经发生,但主线程仍在执行之前的任务,事件处理函数还不能开始。
  2. 处理耗时:浏览器开始执行这次交互关联的回调,包括框架更新、数据计算和 DOM 操作。
  3. 呈现延迟:回调已经结束,但样式计算、布局、绘制等工作尚未完成,用户还看不到变化。

这三个阶段解释了一个常见误区:事件回调只有 5ms,并不代表交互一定流畅。如果点击前正好有一个 300ms 的任务占据主线程,用户仍然要先等待它结束。同样,回调很短但一次性修改了大量 DOM,下一帧也可能迟迟无法出现。

所以排查时不要只盯着某一个函数,而要问三个问题:主线程为什么没空?回调里做了什么?提交视觉结果前还有多少渲染工作?

用真实交互数据定位问题

浏览器的 Event Timing API 会通过 PerformanceEventTiming 暴露交互相关的性能条目。可以使用 PerformanceObserver 在页面中观察事件:

单个 PerformanceEventTiming 条目并不等于页面最终的 INP。INP 是根据一次页面访问中的交互表现计算出的页面级指标;这里观察单次条目,是为了找到拖慢整体指标的具体交互。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.interactionId) continue;

console.table({
type: entry.name,
startTime: Math.round(entry.startTime),
processingStart: Math.round(entry.processingStart),
processingEnd: Math.round(entry.processingEnd),
duration: Math.round(entry.duration),
interactionId: entry.interactionId,
});
}
});

observer.observe({
type: 'event',
buffered: true,
durationThreshold: 40,
});

这里有两个细节值得注意:

  • 同一次用户交互可能产生多个事件条目,interactionId 可以帮助我们把它们关联起来。
  • duration 不是“业务函数执行时间”的别名,它覆盖从事件开始到下一次绘制之间的整体延迟,并且会按浏览器定义进行取整。

线上监控不要只保存一个孤立的数字。至少同时记录页面路由、交互目标、设备档位、交互类型和当时是否存在长任务。否则你只能知道“某些用户很慢”,却不知道慢在哪里。

长任务可以用 Long Tasks API 辅助观察。MDN 对 PerformanceLongTaskTiming 的说明是:占用 UI 主线程 50ms 或更久的任务会被报告。示例代码如下:

1
2
3
4
5
6
7
8
9
10
const longTaskObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn('Long task', {
start: Math.round(entry.startTime),
duration: Math.round(entry.duration),
});
}
});

longTaskObserver.observe({ type: 'longtask', buffered: true });

Event Timing 告诉你哪次交互慢,Long Tasks 则帮助解释交互开始前后主线程为什么被占用。两者配合,比只看平均耗时更有诊断价值。

优化一:把长任务切成可以让出主线程的小块

假设页面需要处理一批数据:

1
2
3
4
5
6
7
function processAll(items) {
for (const item of items) {
normalize(item);
calculate(item);
renderPreview(item);
}
}

如果 items 很大,这段同步循环会一直占用主线程。用户在中间点击按钮,事件也只能排队。一个朴素但有效的策略是分批处理,并在批次之间把控制权还给浏览器:

1
2
3
4
5
6
7
8
9
10
11
12
13
async function processInChunks(items, chunkSize = 50) {
for (let start = 0; start < items.length; start += chunkSize) {
const chunk = items.slice(start, start + chunkSize);

for (const item of chunk) {
normalize(item);
calculate(item);
renderPreview(item);
}

await new Promise((resolve) => setTimeout(resolve, 0));
}
}

这里使用后续 task 作为让步点。不要把它替换成 await Promise.resolve() 并期待相同效果,因为 Promise 回调属于微任务;微任务队列会在浏览器获得下一次渲染机会之前继续清空,连续追加微任务仍可能让输入和绘制“饿死”。

批次大小不是固定答案。低端设备处理 50 条可能已经太多,高端设备又可能过于保守。更稳妥的做法是先测量每批耗时,再根据业务场景调整。计算量非常大的纯 CPU 工作,也可以考虑移到 Web Worker,但 Worker 不能直接操作 DOM,数据传输成本同样要计入设计。

优化二:事件处理函数先完成反馈,再做重活

点击“加入收藏”时,最重要的不是立刻完成所有列表重排,而是让用户先看到按钮状态变化:

1
2
3
4
5
6
7
8
9
button.addEventListener('click', () => {
button.classList.add('is-active');
button.setAttribute('aria-pressed', 'true');

setTimeout(() => {
rebuildRecommendations();
persistPreference();
}, 0);
});

这段代码表达的是优先级,而不是鼓励把所有逻辑都塞进 setTimeout。真正的原则是:

  • 当前交互必须产生的视觉状态,尽快完成;
  • 可以稍后执行的派生计算,移出关键路径;
  • 网络请求异步化后,也不要在成功回调里一次性渲染成百上千个节点。

如果使用 React,可以把“立即反馈”和“非紧急更新”拆开:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { startTransition, useState } from 'react';

function ProductSearch({ products }) {
const [input, setInput] = useState('');
const [query, setQuery] = useState('');

function handleChange(event) {
const nextValue = event.target.value;
setInput(nextValue);

startTransition(() => {
setQuery(nextValue);
});
}

const result = products.filter((item) => item.name.includes(query));

return (
<>
<input value={input} onChange={handleChange} />
<ProductList items={result} />
</>
);
}

输入框状态是紧急更新,筛选结果可以作为过渡更新。注意:startTransition 不是让昂贵 JavaScript 自动变快,它只是帮助 React 调度更新。真正耗时的同步计算仍应缓存、缩小数据范围、分块,或移出主线程。

优化三:减少下一帧前的渲染工作

下面这种“读一下,改一下,再读一下”的循环很容易触发重复布局:

1
2
3
4
for (const card of cards) {
const width = card.offsetWidth;
card.style.height = `${width * 0.75}px`;
}

更好的方式是批量读取,再批量写入:

1
2
3
4
5
6
7
const widths = cards.map((card) => card.offsetWidth);

requestAnimationFrame(() => {
cards.forEach((card, index) => {
card.style.height = `${widths[index] * 0.75}px`;
});
});

更进一步,如果布局关系能用 CSS 表达,就不应交给 JavaScript。例如固定比例可以直接使用 aspect-ratio。这样既减少主线程工作,也避免窗口变化时重复运行脚本。

列表场景还要关注 DOM 规模。虚拟列表、分页和按需渲染通常比“优化循环写法”收益更大。性能问题经常不是某行代码太慢,而是我们让浏览器处理了本不该同时出现的几千个节点。

优化四:控制第三方脚本和启动任务

埋点、客服、广告、A/B 实验和富文本编辑器都可能在主线程执行。它们不一定在首屏阶段造成问题,却可能恰好挡在第一次点击之前。

建议给第三方脚本建立清单:负责人是谁、何时加载、失败是否影响主流程、能否在用户需要时再初始化。对非关键脚本使用延迟加载只是起点,还要通过性能记录确认它初始化后做了什么。一个带 defer 的脚本仍然可以在执行时产生长任务。

一套可重复的排查顺序

遇到“点击卡顿”,可以按下面的顺序处理:

  1. 在性能面板录制一次能够稳定复现的交互。
  2. 找到交互前后的长任务,确认是自有代码、框架更新还是第三方脚本。
  3. 把延迟拆成输入等待、处理和呈现三个阶段,不凭感觉下结论。
  4. 先移除不必要的工作,再考虑缓存、分块、Worker 或框架调度。
  5. 检查 DOM 数量、同步布局和大面积样式失效。
  6. 在真实低端设备和真实数据量下复测。
  7. 上线后继续用真实用户数据观察分布,而不是只看本机的一次最佳结果。

INP 优化的核心不是寻找一个万能 API,而是尊重主线程的时间预算:任何连续占用都会延迟输入,任何多余渲染都会推迟下一帧。把反馈放在前面,把非关键工作拆开,把能够交给 CSS 或 Worker 的事情移走,交互体验通常就会出现可感知的改善。

参考资料