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 工具,但思路一样:不要凭本地电脑感觉判断线上用户体验。
我现在的排查顺序
如果一个页面被反馈“点起来卡”,我一般按这个顺序看:
- 先确认是哪一个交互慢,而不是泛泛说页面慢。
- 用 Performance 看主线程长任务。
- 区分是 JS 计算、React 渲染、布局绘制,还是第三方脚本。
- 看状态是否提升过度,是否导致无关组件更新。
- 对重计算做缓存、拆分或 Worker 化。
- 上线后用真实用户数据验证。
这套流程不复杂,但能避免一上来乱优化。
INP 让我重新看待前端性能。
以前我更关心用户多久能看到页面,现在我更关心用户点击之后页面有没有及时回应。尤其是复杂中后台,性能问题常常不在首屏,而在一次又一次高频操作里。
页面打开得快只是第一印象。筛选不卡、输入顺、保存有反馈,才是用户愿意继续用的原因。