JavaScript / 浏览器 / 前端性能
从一次点击到下一帧:前端 INP 性能优化实战
用户点击按钮后,页面没有立刻变化;输入搜索词时,字符像“黏”在键盘上;展开菜单时,过了一小会儿才出现。这些问题看起来各不相同,本质上都和交互响应有关。
INP(Interaction to Next Paint)关注的正是这段体验:用户发起交互后,浏览器需要多久才能呈现下一帧视觉反馈。它不是单纯统计某个事件处理函数执行了多久,而是把输入等待、事件处理和下一次绘制前的延迟一起纳入观察。本文不从“背指标”开始,而是沿着浏览器真正做事的路径,给出一套可以落地的排查与优化方法。
一次交互为什么会慢
把一次点击拆开,大致会经历三个阶段:
- 输入延迟:交互已经发生,但主线程仍在执行之前的任务,事件处理函数还不能开始。
- 处理耗时:浏览器开始执行这次交互关联的回调,包括框架更新、数据计算和 DOM 操作。
- 呈现延迟:回调已经结束,但样式计算、布局、绘制等工作尚未完成,用户还看不到变化。
这三个阶段解释了一个常见误区:事件回调只有 5ms,并不代表交互一定流畅。如果点击前正好有一个 300ms 的任务占据主线程,用户仍然要先等待它结束。同样,回调很短但一次性修改了大量 DOM,下一帧也可能迟迟无法出现。
所以排查时不要只盯着某一个函数,而要问三个问题:主线程为什么没空?回调里做了什么?提交视觉结果前还有多少渲染工作?
用真实交互数据定位问题
浏览器的 Event Timing API 会通过 PerformanceEventTiming 暴露交互相关的性能条目。可以使用 PerformanceObserver 在页面中观察事件:
单个 PerformanceEventTiming 条目并不等于页面最终的 INP。INP 是根据一次页面访问中的交互表现计算出的页面级指标;这里观察单次条目,是为了找到拖慢整体指标的具体交互。
1 | const observer = new PerformanceObserver((list) => { |
这里有两个细节值得注意:
- 同一次用户交互可能产生多个事件条目,
interactionId可以帮助我们把它们关联起来。 duration不是“业务函数执行时间”的别名,它覆盖从事件开始到下一次绘制之间的整体延迟,并且会按浏览器定义进行取整。
线上监控不要只保存一个孤立的数字。至少同时记录页面路由、交互目标、设备档位、交互类型和当时是否存在长任务。否则你只能知道“某些用户很慢”,却不知道慢在哪里。
长任务可以用 Long Tasks API 辅助观察。MDN 对 PerformanceLongTaskTiming 的说明是:占用 UI 主线程 50ms 或更久的任务会被报告。示例代码如下:
1 | const longTaskObserver = new PerformanceObserver((list) => { |
Event Timing 告诉你哪次交互慢,Long Tasks 则帮助解释交互开始前后主线程为什么被占用。两者配合,比只看平均耗时更有诊断价值。
优化一:把长任务切成可以让出主线程的小块
假设页面需要处理一批数据:
1 | function processAll(items) { |
如果 items 很大,这段同步循环会一直占用主线程。用户在中间点击按钮,事件也只能排队。一个朴素但有效的策略是分批处理,并在批次之间把控制权还给浏览器:
1 | async function processInChunks(items, chunkSize = 50) { |
这里使用后续 task 作为让步点。不要把它替换成 await Promise.resolve() 并期待相同效果,因为 Promise 回调属于微任务;微任务队列会在浏览器获得下一次渲染机会之前继续清空,连续追加微任务仍可能让输入和绘制“饿死”。
批次大小不是固定答案。低端设备处理 50 条可能已经太多,高端设备又可能过于保守。更稳妥的做法是先测量每批耗时,再根据业务场景调整。计算量非常大的纯 CPU 工作,也可以考虑移到 Web Worker,但 Worker 不能直接操作 DOM,数据传输成本同样要计入设计。
优化二:事件处理函数先完成反馈,再做重活
点击“加入收藏”时,最重要的不是立刻完成所有列表重排,而是让用户先看到按钮状态变化:
1 | button.addEventListener('click', () => { |
这段代码表达的是优先级,而不是鼓励把所有逻辑都塞进 setTimeout。真正的原则是:
- 当前交互必须产生的视觉状态,尽快完成;
- 可以稍后执行的派生计算,移出关键路径;
- 网络请求异步化后,也不要在成功回调里一次性渲染成百上千个节点。
如果使用 React,可以把“立即反馈”和“非紧急更新”拆开:
1 | import { startTransition, useState } from 'react'; |
输入框状态是紧急更新,筛选结果可以作为过渡更新。注意:startTransition 不是让昂贵 JavaScript 自动变快,它只是帮助 React 调度更新。真正耗时的同步计算仍应缓存、缩小数据范围、分块,或移出主线程。
优化三:减少下一帧前的渲染工作
下面这种“读一下,改一下,再读一下”的循环很容易触发重复布局:
1 | for (const card of cards) { |
更好的方式是批量读取,再批量写入:
1 | const widths = cards.map((card) => card.offsetWidth); |
更进一步,如果布局关系能用 CSS 表达,就不应交给 JavaScript。例如固定比例可以直接使用 aspect-ratio。这样既减少主线程工作,也避免窗口变化时重复运行脚本。
列表场景还要关注 DOM 规模。虚拟列表、分页和按需渲染通常比“优化循环写法”收益更大。性能问题经常不是某行代码太慢,而是我们让浏览器处理了本不该同时出现的几千个节点。
优化四:控制第三方脚本和启动任务
埋点、客服、广告、A/B 实验和富文本编辑器都可能在主线程执行。它们不一定在首屏阶段造成问题,却可能恰好挡在第一次点击之前。
建议给第三方脚本建立清单:负责人是谁、何时加载、失败是否影响主流程、能否在用户需要时再初始化。对非关键脚本使用延迟加载只是起点,还要通过性能记录确认它初始化后做了什么。一个带 defer 的脚本仍然可以在执行时产生长任务。
一套可重复的排查顺序
遇到“点击卡顿”,可以按下面的顺序处理:
- 在性能面板录制一次能够稳定复现的交互。
- 找到交互前后的长任务,确认是自有代码、框架更新还是第三方脚本。
- 把延迟拆成输入等待、处理和呈现三个阶段,不凭感觉下结论。
- 先移除不必要的工作,再考虑缓存、分块、Worker 或框架调度。
- 检查 DOM 数量、同步布局和大面积样式失效。
- 在真实低端设备和真实数据量下复测。
- 上线后继续用真实用户数据观察分布,而不是只看本机的一次最佳结果。
INP 优化的核心不是寻找一个万能 API,而是尊重主线程的时间预算:任何连续占用都会延迟输入,任何多余渲染都会推迟下一帧。把反馈放在前面,把非关键工作拆开,把能够交给 CSS 或 Worker 的事情移走,交互体验通常就会出现可感知的改善。
参考资料
- MDN:PerformanceEventTiming — https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEventTiming
- MDN:PerformanceObserver — https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver
- MDN:PerformanceLongTaskTiming — https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongTaskTiming
- W3C:Event Timing — https://www.w3.org/TR/event-timing/