你的页面很流畅,但 INP 还是不合格?我用 3 个 API 把交互延迟压到 50ms 以内

3 阅读5分钟

2026 年了,Lighthouse 跑分 100,但用户说"按钮点了没反应"?问题可能出在 INP 上。记录一次真实项目的 INP 治理过程。

一、背景:为什么 INP 比 FPS 更值得关注

上周产品反馈:"这个按钮点了经常没反应,要狂点好几次才生效。"

我打开 Performance 面板一看,FPS 稳定在 60,主线程也没明显阻塞。

但用 Web Vitals Extension 测了一下 INP,居然 380ms,远超 Google 建议的 200ms 优秀线。

INP(Interaction to Next Paint) 衡量的是:用户交互(点击、输入、键盘)到页面下一次视觉反馈的时间。它不像 FPS 只看帧率,而是直接反映用户"点了之后多久看到结果"的体感延迟。 我的项目 INP 380ms,属于"需要改进"区间。

但主线程明明不堵,问题出在哪?

二、定位:用 Performance API 精准捕捉慢交互

Chrome DevTools 的 Performance 面板能看整体,但 INP 的问题往往藏在单次交互里。

我用了一个原生 API 来精准定位: javascript // 监听所有交互,记录耗时 new PerformanceObserver((list) => { for (const entry of list.getEntries()) { // entry.duration 就是这次交互的 INP 候选值 if (entry.duration > 100) { console.warn('慢交互 detected:', { type: entry.name, // 'click', 'keydown', etc. duration: entry.duration, // 单位:ms target: entry.target?.nodeName, startTime: entry.startTime, }); } } }).observe({ type: 'event', buffered: true, durationThreshold: 100 });

跑了一圈后,发现三个重灾区:

  1. 搜索框输入 — 每次输入都触发全量列表过滤,INP 280ms
  2. 表格排序 — 点击表头排序时,INP 450ms
  3. 模态框打开 — 点击按钮到模态框出现,INP 520ms

三、方案一:Scheduler.yield() 解决输入卡顿 搜索框的问题最典型:用户输入时,React 状态更新 + 列表过滤 + 重新渲染,全挤在一次事件循环里。 传统做法是用 setTimeout 拆分,但优先级不可控。2026 年的现代方案是 scheduler.yield() : javascript import { useTransition, useDeferredValue } from 'react';

function SearchList({ items }) { const [query, setQuery] = useState(''); const [isPending, startTransition] = useTransition();

// 延迟值: urgent update 优先,过滤逻辑延后 const deferredQuery = useDeferredValue(query);

const filtered = useMemo(() => { if (!deferredQuery) return items; return items.filter(item => item.name.includes(deferredQuery) ); }, [deferredQuery, items]);

return ( <> <input value={query} onChange={(e) => { // 高优:更新输入框(用户立刻看到字符) setQuery(e.target.value); // 低优:过滤列表(可以延迟,不阻塞输入) startTransition(() => { // 过滤逻辑在这里执行 }); }} /> {isPending && } </> ); }

但 useTransition 是 React 专属。对于原生 JS 或 Vue 场景,可以用 scheduler.yield() (Chrome 96+ 支持): javascript // 原生 JS 版本:让出主线程,让浏览器先处理输入和渲染 async function filterLargeList(query, items) { const chunkSize = 100; const results = [];

for (let i = 0; i < items.length; i += chunkSize) { // 每处理 100 条,让出主线程一次 if (i > 0 && 'scheduler' in window) { await scheduler.yield(); // ← 关键 API }

const chunk = items.slice(i, i + chunkSize);
results.push(...chunk.filter(item => 
  item.name.includes(query)
));

}

return results; }

效果:搜索框 INP 从 280ms 降到 45ms,用户输入时不再"粘手"。

四、方案二:content-visibility 解决表格排序卡顿

表格排序的 450ms 延迟,根源是 DOM 节点太多。表格有 2000 行,排序时全量重新渲染,浏览器要计算 2000 个节点的布局。 传统方案是虚拟滚动,但需要手动计算高度、监听滚动,代码复杂。2026 年的一行 CSS 方案: css /* 只渲染视口内的行,视口外的直接跳过布局和绘制 / .table-row { content-visibility: auto; / 预设高度,避免滚动条抖动 */ contain-intrinsic-size: 0 48px;
}

配合 ResizeObserver 动态调整: javascript // 如果行高不固定,用 ResizeObserver 缓存真实高度 const rowHeightCache = new Map();

const observer = new ResizeObserver((entries) => { for (const entry of entries) { const height = entry.borderBoxSize[0].blockSize; rowHeightCache.set(entry.target.dataset.id, height); // 更新 CSS 变量,让 contain-intrinsic-size 更精准 entry.target.style.setProperty('--cached-height', ${height}px); } });

// 只观察视口内的行 document.querySelectorAll('.table-row').forEach(row => { if (isInViewport(row)) observer.observe(row); });

css .table-row { content-visibility: auto; contain-intrinsic-size: 0 var(--cached-height, 48px); }

效果:表格排序 INP 从 450ms 降到 85ms,且代码量比虚拟滚动少了 80%。

五、方案三:AbortController 解决模态框内存泄漏

模态框的 520ms 最诡异。排查后发现:每次打开模态框都重新请求数据,但旧请求没取消,导致多个 fetch 竞态 + 重复渲染。 用 AbortController 一键清理: javascript function useModalData(url) { const [data, setData] = useState(null); const abortRef = useRef(new AbortController());

const openModal = useCallback(async () => { // 打开新模态框时,取消旧请求 abortRef.current.abort(); abortRef.current = new AbortController();

try {
  const res = await fetch(url, {
    signal: abortRef.current.signal,  // ← 绑定取消信号
  });
  const json = await res.json();
  setData(json);
} catch (err) {
  if (err.name === 'AbortError') return; // 正常取消,忽略
  console.error('Fetch failed:', err);
}

}, [url]);

// 组件卸载时自动清理 useEffect(() => { return () => abortRef.current.abort(); }, []);

return { data, openModal }; }

更进阶:用 AbortController 统一管理所有副作用 javascript // 一个 Controller 管所有:fetch、事件监听、动画 function useCleanupManager() { const controllerRef = useRef(new AbortController());

const register = useCallback((cleanupFn) => { // 把清理函数注册到 signal 上 const signal = controllerRef.current.signal; const handler = () => cleanupFn(); signal.addEventListener('abort', handler, { once: true }); return () => signal.removeEventListener('abort', handler); }, []);

const reset = useCallback(() => { controllerRef.current.abort(); controllerRef.current = new AbortController(); }, []);

return { register, reset, signal: controllerRef.current.signal }; }

// 使用:模态框打开时重置,关闭时自动清理所有副作用 const { register, reset } = useCleanupManager();

useEffect(() => { if (!isOpen) return;

reset(); // 新生命周期开始

const timeout = setTimeout(() => loadData(), 100); register(() => clearTimeout(timeout)); // 自动清理

const listener = () => handleEsc(); document.addEventListener('keydown', listener); register(() => document.removeEventListener('keydown', listener));

}, [isOpen]);

效果:模态框 INP 从 520ms 降到 60ms,且彻底解决了请求竞态和内存泄漏。

六、数据汇总:优化前后对比

搜索框输入:280ms → 45ms,核心 API:useDeferredValue + scheduler.yield() 表格排序:450ms → 85ms,核心 API:content-visibility + ResizeObserver 模态框打开:520ms → 60ms,核心 API:AbortController 整体 INP:380ms → 52ms 用 Web Vitals JS 库上报真实用户数据(RUM): javascript import { onINP } from 'web-vitals';

onINP((metric) => { // 发送到监控平台 analytics.track('Web Vitals', { name: 'INP', value: metric.value, rating: metric.rating, // 'good' | 'needs-improvement' | 'poor' entries: metric.entries.map(e => ({ type: e.name, duration: e.duration, })), });

// 如果 INP > 200ms,自动上报详细诊断信息 if (metric.rating !== 'good') { sendToSentry(metric); } }, { reportAllChanges: true });

七、总结:INP 优化的核心思路

  1. 拆分长任务 — 用 scheduler.yield() 或 useTransition 让出主线程
  2. 减少渲染量 — 用 content-visibility 替代复杂虚拟滚动
  3. 清理副作用 — 用 AbortController 统一管理异步和事件

这三个 API 都是浏览器原生支持,不需要额外依赖,且能覆盖 90% 的 INP 问题。

2026 年了,别再只盯着 Lighthouse 跑分了。INP 才是用户真正感知的"快"。

你的项目 INP 是多少?有没有遇到过"FPS 很高但用户觉得卡"的情况?

欢迎在评论区分享。