INP 之后的性能复盘:我开始认真看用户点击后的那几百毫秒

我以前做性能优化,注意力大多放在首屏。

包体积、LCP、图片懒加载、接口并发、骨架屏,这些我都很熟。页面打开得快,Lighthouse 分数看起来不错,就觉得性能这关差不多了。

直到有一次中后台项目做筛选改造,用户反馈了一句很具体的话:“页面打开挺快,但每次点筛选都像卡住了。”

这句话让我印象很深。因为从我们那套首屏指标看,页面并不差。真正的问题发生在用户开始使用之后:输入筛选条件、切换状态、展开详情、批量勾选。每一次交互都不算崩,但都会慢半拍。

INP 被更多团队重视之后,我才真正把性能从“页面多久能看到”扩展到“用户操作以后多久能得到反馈”。

说点背景。INP(Interaction to Next Paint)在 2024 年 3 月正式取代了 FID 成为 Core Web Vitals 的一员。它和 FID 最大的区别是:FID 只量第一次交互的输入延迟,而且只量“延迟”不量处理时间,所以它的数值通常好看得离谱,很多页面 FID 几十毫秒,体验却很糟。INP 不一样,它会统计整个页面生命周期里所有交互,取一个接近最差的值(具体是按交互次数浮动,超过 50 次后大致取倒数第 N 个高位值),而且量的是“从用户操作到下一帧渲染出来”的完整链路:输入延迟 + 事件处理时间 + 渲染呈现延迟。换句话说,你点一下按钮,浏览器要排队、要跑你的 handler、要重新布局绘制,这一整段都算进去。

官方给的及格线是:好 ≤ 200ms,需要改进 200–500ms,差 > 500ms。我自己的体感是,200ms 以内基本无感,到 300ms 就开始“觉得这页面有点黏”,500ms 以上用户会怀疑是不是没点上、然后再点一次——这一点很要命,重复点击会进一步加重主线程负担,体验雪崩。

首屏快,不代表系统好用

中后台页面尤其容易出现这种反差。

页面刚打开时,框架、菜单、表格容器都出来了,看起来很快。但用户真正开始操作时,问题才出现:

  • 搜索框输入一个字,表格和图表一起重算。
  • 切换筛选条件,整个页面重新渲染。
  • 勾选一行数据,右侧统计和批量栏都跟着更新。
  • 展开详情时,同步解析了一大段配置 JSON。
  • 第三方监控脚本在用户操作阶段还在初始化。

这些体验不一定会体现在传统首屏优化里,但用户会直接感受到。

我后来给自己定了一个简单标准:性能不是“页面加载完”就结束了。用户能顺畅完成一次操作,才算性能真的过关。

还有一点反直觉:首屏指标和交互指标的优化目标经常是冲突的。为了首屏快,我们会把代码拆包、把数据预取、把组件做大做全;但这些大组件一旦挂上去,每次状态变化的重渲染成本反而更高。我见过一个极端例子,团队为了 LCP 好看,把整个筛选区和表格都塞进一个组件里同步渲染,首屏确实达标了,可一旦用户开始操作,每次都是整块重算。首屏和交互不是一条优化路径,得分开看、分开测。

我先从长任务查起

那次排查筛选卡顿,我先录了 Chrome Performance。

很快看到几个长任务:点击筛选后,主线程连续忙了几百毫秒。里面混着三类东西:筛选计算、React 渲染、表格组件布局。

一开始我想直接优化 React 组件,后来发现更上游的问题是筛选逻辑太重。每次输入都对整份数据做过滤、排序、聚合,还顺手生成图表数据。

原来的代码大概是这样:

1function handleKeywordChange(keyword) {
2  setKeyword(keyword);
3
4  const filtered = filterRows(allRows, keyword);
5  const sorted = sortRows(filtered);
6  const chartData = buildChartData(sorted);
7
8  setRows(sorted);
9  setChartData(chartData);
10}

这段代码的问题不是逻辑错误,而是把“用户输入”这件高优先级交互,和“重算整页数据”绑在了一起。用户每敲一个字,主线程都要跑完整套流程。

先让输入活着,再让结果更新

后来我把输入值和查询值拆开。

用户输入时,输入框状态立即更新;重列表更新可以稍微晚一点。React 的 useTransition 在这种场景里很实用,因为它能把非紧急更新标出来。

1const [keyword, setKeyword] = useState("");
2const [query, setQuery] = useState("");
3const [isPending, startTransition] = useTransition();
4
5function handleChange(event) {
6  const value = event.target.value;
7  setKeyword(value);
8
9  startTransition(() => {
10    setQuery(value);
11  });
12}
13
14const filteredRows = useMemo(() => {
15  return filterRows(allRows, query);
16}, [allRows, query]);

这不是银弹。如果 filterRows 本身很重,还是要继续优化。但至少它把“用户正在输入”放到了更高优先级。用户手感会先回来。

对 B 端系统来说,这个差别很重要。用户不是来欣赏动画的,是来处理任务的。输入、筛选、保存这些动作一旦卡顿,会直接影响他对系统可靠性的判断。

重计算要么拆小,要么挪走

有些计算没法靠 useMemo 解决,因为它本来就很重。

比如本地导入几千行 Excel 后做校验、解析复杂配置、前端聚合大表数据。这些任务如果一次性跑完,主线程就会被占住。

轻一点的任务,我会拆成小块,让浏览器有机会处理输入和绘制:

1async function validateRows(rows) {
2  const errors = [];
3
4  for (let index = 0; index < rows.length; index += 1) {
5    const error = validateRow(rows[index]);
6    if (error) {
7      errors.push(error);
8    }
9
10    if (index % 100 === 0) {
11      await new Promise((resolve) => setTimeout(resolve, 0));
12    }
13  }
14
15  return errors;
16}

这段代码不算优雅,但思路直接:不要连续占住主线程。

更重的任务,我会考虑 Web Worker。它不能操作 DOM,但很适合做纯计算:

1const worker = new Worker("/workers/validate.js");
2
3worker.postMessage({
4  type: "validate_rows",
5  rows,
6});
7
8worker.onmessage = (event) => {
9  setValidationResult(event.data);
10};

以前我很少在业务后台里用 Worker,觉得它有点重。现在遇到导入校验、规则计算、复杂过滤这类场景,我会更早把它放进候选方案里。

React 重渲染不是唯一问题,但经常参与作案

筛选卡顿那次,React 重渲染也有责任。

我们把筛选条件放得太高,导致输入变化时,表格、图表、详情栏、批量操作栏都跟着渲染。后来做了几件很朴素的事:

  • 把输入框局部状态留在筛选组件里。
  • 表格只接收真正会影响数据的 query
  • 图表数据单独 memo,不跟着无关 UI 状态变化。
  • 重组件加 memo,但前提是先把 props 稳定下来。

我现在不太喜欢一上来就到处加 memo。如果状态边界是乱的,memo 只是在给乱结构打补丁。先把“谁真的需要知道这个状态”想清楚,优化会更稳。

第三方脚本也要纳入性能预算

还有一个容易忽略的问题:第三方脚本。

监控、埋点、客服、A/B 实验、可视化 SDK,都可能在用户交互阶段占用主线程。我们当时就发现一个监控脚本在页面加载后继续扫描 DOM,刚好和用户第一次筛选撞在一起。

后来我会把第三方脚本也当成性能预算的一部分:

  • 是否必须首屏加载?
  • 是否能延迟到空闲时段?
  • 是否只在特定页面启用?
  • 初始化是否能拆小?
  • 出问题时能否快速关闭?

性能优化不是只优化自己写的业务代码。页面上所有会执行的 JavaScript,最后都会和用户的点击抢主线程。

指标要落到具体交互

INP 这类指标真正有用的地方,是逼我们从“平均页面性能”转向“具体交互性能”。

我现在更愿意记录关键动作:

  • 搜索输入到结果更新。
  • 筛选切换到表格稳定。
  • 点击保存到按钮恢复。
  • 展开详情到内容可见。
  • 批量选择到操作栏更新。

如果有前端监控,可以按页面、设备、交互类型、数据量拆开看。不要只看平均值,平均值很容易掩盖低端设备和大数据量用户的痛苦。

一个简单埋点可以先长这样:

1function reportInteraction(name, startedAt) {
2  const duration = performance.now() - startedAt;
3
4  navigator.sendBeacon(
5    "/analytics",
6    JSON.stringify({
7      type: "interaction",
8      name,
9      duration,
10      page: location.pathname,
11    })
12  );
13}

真实项目里可以接更完整的 RUM 工具,但思路一样:不要凭本地电脑感觉判断线上用户体验。

我现在的排查顺序

如果一个页面被反馈“点起来卡”,我一般按这个顺序看:

  1. 先确认是哪一个交互慢,而不是泛泛说页面慢。
  2. 用 Performance 看主线程长任务。
  3. 区分是 JS 计算、React 渲染、布局绘制,还是第三方脚本。
  4. 看状态是否提升过度,是否导致无关组件更新。
  5. 对重计算做缓存、拆分或 Worker 化。
  6. 上线后用真实用户数据验证。

这套流程不复杂,但能避免一上来乱优化。

INP 让我重新看待前端性能。

以前我更关心用户多久能看到页面,现在我更关心用户点击之后页面有没有及时回应。尤其是复杂中后台,性能问题常常不在首屏,而在一次又一次高频操作里。

页面打开得快只是第一印象。筛选不卡、输入顺、保存有反馈,才是用户愿意继续用的原因。