阅读视图

发现新文章,点击刷新页面。

深入理解 INP:从原理到实战的前端交互性能优化

背景

最近有做一些INP优化相关,简单记录一下。

INP高的重灾区就是手机端, 手机 CPU 弱、线程紧张,INP 高基本都出在移动端。

什么是 INP?

INP(Interaction to Next Paint,交互到下一次绘制) 是 Google Core Web Vitals 中评估网页交互响应性的核心指标。自 2024 年 3 月起,INP 正式取代了旧的 FID(First Input Delay),成为衡量用户交互体验的主要标准。

优化INP有什么用?

  • 📱 体验层面:解决移动端点击卡顿、无响应,让交互秒反馈,大幅提升流畅度。

  • 🔍 SEO 层面:INP 是 Google Core Web Vitals 核心指标,达标有利于搜索排名与流量。

  • 💰 业务层面:交互更流畅,用户流失更少,留存、转化率更高。

  • 🛠 技术层面:拆分长任务、优化主线程,让项目整体架构更轻、更快、更易维护。

INP vs FID:为什么要换?

对比维度 FID (旧指标) INP (新指标)
测量范围 仅第一次交互 全生命周期所有交互
测量内容 仅输入延迟 输入 + 处理 + 渲染 全链路
真实性 片面 更全面、更真实
优化指导 只关注首次交互 关注所有交互场景

简单来说:FID 只是"开胃菜",INP 才是"全席宴"——它能真正反映用户在使用过程中的完整体验。


INP 测量的是什么?

INP 测量的是:用户进行一次有效交互(点击、点按、键盘输入)后,到浏览器完成下一次视觉更新(paint)所花费的总时间。

时序图解

用户交互(click/tap/keypress)
        │
        ▼
   ┌─────────────┐    ┌─────────────┐    ┌─────────────────────────┐
   │  输入延迟   │ → │  事件处理   │ → │ 样式计算 + 布局 + 绘制  │ → 下一帧 paint
   └─────────────┘    └─────────────┘    └─────────────────────────┘
   ◀──────────────────── INP 完整耗时 ────────────────────────────▶


📌 关键点:INP 的最终值 = 页面生命周期中所有有效交互里最慢的那一次(取第 75 百分位)

评分标准(2025–2026)

根据 Google 官方标准,INP 以第 75 百分位(P75)进行评估:

等级 阈值 含义
🟢 良好 (Good) ≤ 200 ms 响应迅速,用户体验优秀
🟡 需改进 (Needs Improvement) 200–500 ms 有明显延迟感,需要优化
🔴 较差 (Poor) > 500 ms 严重卡顿,体验糟糕

优化目标:确保 75% 以上的用户交互响应时间控制在 200 ms 以内

1770779737686_00af473a3546431c8782fc39930c2adb.png

为什么 INP 会变差?

核心原因

主线程上存在长任务(Long Task > 50 ms),阻塞了浏览器处理后续交互和渲染。

典型场景

用户点击按钮 
    → setLoading(true) 
    → 立即执行 200–500 ms 的重计算/网络请求/复杂 DOM 操作 
    → 用户迟迟看不到 loading 状态
    → 感觉卡顿、无响应


问题在于:setLoading(true) 虽然调用了,但由于主线程被长任务占用,浏览器根本没机会渲染这个状态变化!

image.png

三个阶段的问题分布

根据实际项目经验,三个阶段的问题分布大致如下:

  • 输入延迟:30% - 通常是第三方脚本、初始化任务

  • 事件处理:50% - 最常见的问题,业务逻辑复杂

  • 呈现延迟:20% - DOM 操作、布局计算


INP 的三个阶段详解

INP 的测量范围涵盖三个关键阶段,每个阶段都可能成为性能瓶颈。深入理解这三个阶段,是优化 INP 的基础。

image.png

阶段一:输入延迟(Input Delay)

定义:从用户触发交互到事件处理器开始执行之间的等待时间。

原因:主线程被其他任务占用(长任务 > 50ms、同步渲染、第三方脚本、垃圾回收),浏览器无法立即响应交互。

案例比如如下,输入延迟348ms,脚本加载阻塞了交互 导致用户交互卡顿

f41ca817c123575b3394dbeab12da896.png

优化策略

  • 拆分长任务:使用 scheduler.yield() 或 scheduler.postTask 让出主线程

  • 延迟非关键任务:使用 requestIdleCallback 在空闲时执行

  • 优化第三方脚本:异步加载、延迟执行


阶段二:事件处理(Processing Time)

定义:事件处理器从开始执行到执行完成所花费的时间。

原因:处理器内部执行耗时操作(复杂计算、大量 DOM 操作、同步网络请求、强制同步布局),阻塞主线程。

实际案例

// ❌ 问题:用户点击更换背景,但画布操作耗时,看不到即时反馈
// 来源:src/core/FTCanvasRenderer.ts - updateBackground
async updateBackground({ bgUrl, bgColor, size, callback }) {
  FTBgremoveStore.changeBackgroundLoading = true;  // 状态已更新,但浏览器还没渲染
  
  // 同步执行耗时操作,阻塞主线程
  const blobUrl = await FTBlobCacheManager.getInstance().addCache(bgUrl);  // 100-200ms
  bg.setSource(blobUrl, "imageUrl", () => {
    this.blurBackground(currentPageData.blurValue || 0);  // 50-100ms 模糊处理
    fabricCanvas.requestRenderAll();  // 触发重绘
    FTBgremoveStore.changeBackgroundLoading = false;
  });
}

// ✅ 优化:先让浏览器渲染反馈,再执行耗时操作
import { nextTick } from 'src/utils';

async updateBackground({ bgUrl, bgColor, size, callback }) {
  FTBgremoveStore.changeBackgroundLoading = true;
  
  // 让出主线程,确保浏览器先渲染 loading 状态
  await nextTick();
  
  // 现在执行耗时操作
  const blobUrl = await FTBlobCacheManager.getInstance().addCache(bgUrl);
  bg.setSource(blobUrl, "imageUrl", async () => {
    await nextTick();  // 再次让出,确保模糊处理不阻塞
    this.blurBackground(currentPageData.blurValue || 0);
    fabricCanvas.requestRenderAll();
    FTBgremoveStore.changeBackgroundLoading = false;
  });
}


优化策略

  • 先反馈再处理:使用 nextTick() 让浏览器先渲染状态变化

  • 拆分长任务:将耗时操作拆成多个小任务

  • 使用 Web Worker:将计算密集型任务移到 Worker 线程


阶段三:呈现延迟(Presentation Delay)

定义:从事件处理器执行完成,到浏览器完成下一次 paint(绘制)之间的时间。

原因:浏览器渲染管道(样式计算 → 布局 → 绘制 → 合成)耗时过长,常见问题包括强制同步布局、大量重排、复杂 CSS 选择器。

优化策略

  • 避免强制同步布局:先批量读取布局属性,再批量写入 DOM

  • 减少重排重绘

  • 优化 CSS 选择器:避免深层嵌套,使用类选择器


INP优化核心思路

🎯 核心原则让用户交互后的第一个 paint 尽快发生,把重的、非必须立即执行的工作延迟或拆分

这样做能让你推迟执行的任务不算在INP时间计算内

❌ 错误示范:阻塞渲染

// 来源:src/store/FTBgremoveStore.tsx - handleLoadSimplifyCanvas
async handleLoadSimplifyCanvas(imgUrl, id, closeLoading = true) {
  this.cutoutLoading = true;  // 调用了,但...
  
  // 立即执行耗时操作,阻塞主线程
  const img = await loadImageByUrl(imgUrl);  // 100ms 加载图片
  await FTSimpleCanvasRenderInstance.uploadTop(imgUrl);  // 80ms 上传到画布
  await FTSimpleCanvasRenderInstance.uploadBack(page.backgroundColor);  // 50ms 设置背景
  this.setCurrentPage({ cutOriginUrl: imgUrl, cropImage: image, ... });  // 同步更新状态
  
  this.cutoutLoading = false;
}


✅ 正确示范:先反馈,再干活

import { nextTick } from 'src/utils';

async handleLoadSimplifyCanvas(imgUrl, id, closeLoading = true) {
  // 1. 同步更新状态
  this.cutoutLoading = true;

  // 2. 关键:让出主线程,给浏览器渲染机会
  await nextTick();  // 浏览器有机会渲染 loading 状态

  // 3. 现在才开始执行重任务
  const img = await loadImageByUrl(imgUrl);
  await FTSimpleCanvasRenderInstance.uploadTop(imgUrl);
  await FTSimpleCanvasRenderInstance.uploadBack(page.backgroundColor);
  
  // 再次让出,确保状态更新能及时渲染
  await nextTick();
  this.setCurrentPage({ cutOriginUrl: imgUrl, cropImage: image, ... });
  this.cutoutLoading = false;
}


拆分和调度任务、让事件能快速响应。

image.png

工具函数:nextTick 实现

项目中已经实现了优化的 nextTick 函数(src/utils/index.ts),通过让出主线程控制权,确保浏览器有机会完成渲染和响应用户交互:

/**
 * nextTick - 将任务推迟执行,优化 INP (Interaction to Next Paint)
 *
 * 改进点:
 * 1. 优先使用 scheduler.postTask (如果可用) 来更好地控制任务优先级
 * 2. 使用 MessageChannel 确保任务在下一个事件循环执行,不阻塞渲染
 * 3. 双重 requestAnimationFrame 作为降级方案,确保 DOM 更新后执行
 *
 * @param callBack 要执行的回调函数
 * @returns Promise<void>
 */
export function nextTick(callBack?: () => void) {
  return new Promise<void>((resolve) => {
    // 优先使用 scheduler.postTask (Chrome 94+, 更好的任务调度)
    if (typeof (window as any).scheduler !== 'undefined' && (window as any).scheduler.postTask) {
      (window as any).scheduler.postTask(() => {
        callBack?.();
        resolve();
      }, { priority: 'user-blocking' });
      return;
    }

    // 使用 MessageChannel 确保在下一个事件循环执行,不阻塞渲染
    // 这对于 INP 优化很重要,因为它让浏览器有机会处理用户交互
    if (typeof MessageChannel !== 'undefined') {
      const channel = new MessageChannel();
      channel.port1.onmessage = () => {
        callBack?.();
        resolve();
      };
      channel.port2.postMessage(null);
      return;
    }

    // 降级方案:双重 requestAnimationFrame
    // 第一个 rAF 确保在浏览器重绘之前,第二个 rAF 确保在重绘之后
    // 这样可以确保 DOM 更新已经完成
    if (requestAnimationFrame) {
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          callBack?.();
          resolve();
        });
      });
      return;
    }

    // 最后的降级方案
    if (setImmediate) {
      setImmediate(() => {
        callBack?.();
        resolve();
      });
    } else {
      setTimeout(() => {
        callBack?.();
        resolve();
      }, 0);
    }
  });
}


使用示例

使用 nextTick() 主动让步。

// 使用项目中的 nextTick 函数
import { nextTick } from 'src/utils';

async function processHeavyCanvas() {
  doLightWork();
  
  // 让浏览器有机会处理待渲染的帧和用户交互
  await nextTick();
  // 这样doHeavyWork会被拆分成单独任务延迟执行
  doHeavyWork();
}


为什么选择这些 API?

API 优点 兼容性
scheduler.postTask 支持优先级控制,专为任务调度设计 Chrome 94+
MessageChannel 宏任务,确保在渲染后执行 广泛支持
双重 rAF 确保在下一帧渲染完成后执行 广泛支持
setTimeout(0) 最终兜底方案 全平台

实战检测与调试

使用 Chrome DevTools 分析 INP

cpu 4 - 6倍降速,模拟低性能设备。 对比本地和线上

image.png

点点功能,观察Performace面板实时的INP

image.png

每次交互的实时INP都会记录在这里。 多点点自己的项目,顺着INP变高的那一步操作找问题即可。

使用 Web Vitals 库监控

import { onINP } from 'web-vitals';

onINP((metric) => {
  console.log('INP:', metric.value);
  console.log('Entries:', metric.entries);
  
  // 上报到分析平台
  if (metric.value > 200) {
    analytics.track('slow_interaction', {
      value: metric.value,
      entries: metric.entries,
      target: metric.entries[0]?.target,
    });
  }
});


分析线上用户真实指标

查看线上真实用户指标

pagespeed.web.dev/

Chrome 用户体验报告(crux)

cruxvis.withgoogle.com/


总结

核心要点

  1. INP 测量三个阶段:输入延迟、事件处理、呈现延迟

  2. 优化目标:确保 75% 的交互在 200ms 内完成

  3. 核心策略:先反馈,再处理;拆分长任务;让出主线程

  4. 工具支持:使用 nextTickscheduler.yield()、Web Worker

  5. 真实用户INP观察: crux查看用户真实INP变化,持续优化。

INP 优化的本质是:让用户"感觉"交互是即时响应的,哪怕后台还在默默干活。

参考资料

❌