INP 优化搞了三个月,我把交互延迟从 380ms 砍到了 120ms

INP 优化搞了三个月,我把交互延迟从 380ms 砍到了 120ms

今年 Google 正式用 INP 替换了 FID,成为 Core Web Vitals 三大指标之一。这个改动其实挺狠的——FID 只测首次输入,INP 测的是用户在整个页面生命周期里的所有交互,取最差的那个。换句话说,只要有一次点击卡了 500ms,INP 就飙红了。

我之前接手的那个后台项目,INP 一直在 380ms 左右晃荡,属于”需要改进”的黄色区间。花了两三个月的碎片时间,做了几轮针对性优化,终于降到了 120ms,稳定绿了。今天把这些手法整理出来,希望能帮到同样被 INP 卡住的同学。

INP 到底是什么

先简单说下 INP 的计算方式。当用户点击、触摸或者敲键盘时,浏览器要经过事件处理、样式计算、布局、绘制这一整套流程才能把下一帧画出来。INP 测量的就是这个延迟,单位是毫秒。

INP 的阈值很直接:

  • :≤200ms
  • 需改进:200-500ms
  • :>500ms

核心公式可以简化成:

INP = 最长单个交互的处理时间 + 下次绘制时间

而处理时间大部分花在主线程上。所以优化 INP 的本质就一句话:别让主线程闲着等着,也别让它一次干太多活。

第一刀:找出罪魁祸首

上来就瞎优化是大忌。先拿数据说话。

Chrome DevTools 的 Performance 面板是最直接的。录制一段用户操作——点按钮、输搜索、翻页面——然后看火焰图里有没有超过 50ms 的长任务(Long Task)。

更严谨的做法是用代码监控:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn(`🔴 长任务: ${entry.duration.toFixed(0)}ms`, entry);
    }
  }
});
observer.observe({ type: 'longtask', buffered: true });

搭配 Web Vitals 库直接看 INP 数值:

import { onINP } from 'web-vitals';

onINP((metric) => {
  console.log('当前 INP:', metric.value, 'ms');
  // 上报到你的监控系统
});

我那项目测出来的结果:有个搜索表格组件,输入关键词后整页重渲染,单次交互最高 420ms。点开一看,火焰图上一大片红色长任务,全是 JS 执行。

第二刀:拆分长任务——最有效的改造

长任务超过 50ms 就得分。怎么分?最老实的做法是用 setTimeout 或者 requestIdleCallback 把大任务拆开:

// 改造前:一次性处理 1000 条数据
function processItems(items) {
  items.forEach(item => heavyWork(item)); // 直接卡死主线程
}

// 改造后:分批处理
function processItemsInBatches(items, batchSize = 50) {
  let index = 0;

  function doBatch() {
    const end = Math.min(index + batchSize, items.length);
    for (; index < end; index++) {
      heavyWork(items[index]);
    }
    if (index < items.length) {
      setTimeout(doBatch, 0); // 让浏览器喘口气
    }
  }

  doBatch();
}

这招虽然管用,但 setTimeout 的最小延迟是 4ms,对精细控制不够友好。更好的方案是用 scheduler.postTask

// 用 scheduler.postTask,支持优先级
for (const item of items) {
  scheduler.postTask(() => heavyWork(item), { priority: 'background' });
}

不过 scheduler.postTask 的浏览器兼容性还在路上。目前最推荐的是社区方案——main-thread-scheduling 库的 yieldOrContinue

npm install main-thread-scheduling
import { yieldOrContinue } from 'main-thread-scheduling';

async function processAll(items) {
  for (const item of items) {
    await yieldOrContinue('interactive');
    heavyWork(item);
  }
}

每处理一项都检查一下主线程有没有空,卡了就 yield 出去,等浏览器画完再回来继续。用这个库重构后,我那个搜索组件的交互延迟从 420ms 降到了 180ms。

第三刀:少干活才是王道

3.1 DOM 瘦身

Google 建议 DOM 元素最好不超过 1400 个。我那表格一次渲染 500 行,每行几十个 td,轻轻松松破 3000。

优化策略很简单:虚拟滚动。

// react-virtual 或 @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualTable({ rows }) {
  const parentRef = useRef(null);
  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 35,
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px` }}>
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: 0,
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            {renderRow(rows[virtualRow.index])}
          </div>
        ))}
      </div>
    </div>
  );
}

虚拟滚动只渲染可见区域的行,DOM 数量从 3000 砍到 30 左右。INP 直接降了 80ms。

3.2 内容可见性控制

对于不在视口内的区域,用 CSS 就能减少渲染开销:

.off-screen-section {
  content-visibility: auto;
  contain-intrinsic-size: 800px;
}

content-visibility: auto 告诉浏览器:这部分的渲染可以推迟到它快进入视口时再做。这个属性对长页面特别有效,我的报表页面加了之后,首次交互的 INP 降了 40ms。

3.3 防抖与节流

表面上这个很基础,但我 Review 代码时发现项目里有大量高频事件没做处理:

// 改造前:每次输入都触发表格筛选
input.addEventListener('input', (e) => {
  filterTable(e.target.value); // 高频调用
});

// 改造后:防抖 300ms
import { debounce } from 'lodash-es';

input.addEventListener('input', debounce((e) => {
  filterTable(e.target.value);
}, 300));

不仅仅是输入框。滚动、resize、mousemove——这些高频事件绑了复杂回调就是 INP 杀手。

第四刀:JS 和 CSS 的阻塞问题

4.1 第三方脚本

项目里埋了好几个第三方 SDK——统计、客服、AB 测试——全在首屏同步加载。

<!-- 改造前:阻塞渲染 -->
<script src="/sdk/analytics.js"></script>

<!-- 改造后:延迟加载 -->
<script src="/sdk/analytics.js" defer></script>
<!-- 或者 -->
<script>
  setTimeout(() => {
    const s = document.createElement('script');
    s.src = '/sdk/analytics.js';
    document.body.appendChild(s);
  }, 3000);
</script>

延迟 3 秒加载第三方脚本,主线程压力大幅减轻。INP 优化 30ms。

4.2 代码分割

路由级别的懒加载已经是标配了,但组件级别的异步加载很多人没做:

// 改造前:一次性引入大组件
import HeavyChart from './HeavyChart';

// 改造后:按需加载
const HeavyChart = lazy(() => import('./HeavyChart'));

// 只在弹窗打开时才加载
function Dashboard() {
  const [showChart, setShowChart] = useState(false);

  return (
    <>
      <button onClick={() => setShowChart(true)}>查看图表</button>
      {showChart && (
        <Suspense fallback={<div>加载中...</div>}>
          <HeavyChart />
        </Suspense>
      )}
    </>
  );
}

4.3 事件委托

如果一个列表里有 100 个按钮,每个绑一个 onClick,那光是注册事件就得几十毫秒。改成事件委托:

// 改造前:100 个监听器
items.map(item => (
  <button onClick={() => handleClick(item.id)}>{item.name}</button>
));

// 改造后:1 个监听器
<div onClick={(e) => {
  const btn = e.target.closest('button');
  if (btn) handleClick(btn.dataset.id);
}}>
  {items.map(item => (
    <button data-id={item.id}>{item.name}</button>
  ))}
</div>

减少了大量的事件绑定开销,尤其是列表重新渲染时新建监听器的成本。

效果对比

一轮优化下来,核心数据变化:

| 指标 | 优化前 | 优化后 | 降幅 |
|——|——–|——–|——|
| INP(P75) | 380ms | 120ms | 68% |
| 长任务数/次交互 | 12 个 | 2 个 | 83% |
| DOM 元素数 | 3,200 | 380 | 88% |
| JS 首屏体积 | 480KB | 180KB | 62% |
| 交互响应感知 | 明显卡顿 | 流畅瞬开 | — |

总结几条经验

INP 优化不是玄学,有章可循:

  • 先度量,再优化。 看 Performance 面板和 longtask API,找到真正的瓶颈。
  • 拆分长任务是最快的见效方式。 yieldOrContinue 这个库值得一试。
  • 少渲染 = 快交互。 虚拟滚动、content-visibility、懒加载都是高 ROI 的手段。
  • 第三方脚本是暗雷。 能 defer 就 defer,能不加载就别加载。
  • 防抖节流是基本功,但很多人没做。

如果你项目 INP 也标黄了,从上面几个点入手,200ms 以内一点也不难。

评论区

0 条评论

登录后可评论。

查看完整榜单
查看完整榜单
查看完整榜单