如何在 React 应用中高效分析与优化 INP

1 阅读20分钟

1.背景

INP 是 Web Vitals 中的一个关键指标,用于衡量用户执行交互(如点击、键盘输入等)到页面发生视觉更新(下一次 Keyframe Paint)的时间。简单来说,INP 评估用户感觉页面是否响应流畅。INP 的最终目标是为了提升用户体验。

image.png

  • INP 低于或等于 200 毫秒表示网页响应速度良好
  • 如果 INP 高于 200 毫秒,但低于或等于 500 毫秒,则表示网页的响应能力需要改进
  • INP 超过 500 毫秒表示网页响应缓慢

关于 INP(Interaction to Next Paint)的详细概念介绍,请参考 官方技术文档

本文聚焦于将页面 INP 指标的 tp75 值降低至 200ms 以下这一具体目标,阐述 React 场景下 INP 的性能分析方法和优化方式

2.收集现场数据

性能优化数据分为实验室数据和用户现场数据。针对前期分析 INP 的场景,相比实验室数据,现场数据更能找出 INP 的问题:

  • 现场数据更能找出用户高频操作:根据木桶效应,优化高频且 INP 较高的操作性价比是最高的,实验室数据很那找出这些操作
  • 用户真实行为复杂且不可预测,现场数据才能反映用户真实情况

注:笔者第一次看完 官方技术文档,完全不知道如何优化现有的页面。经过了收集现网数据后才真正知道优化的方向,因此收集现场数据是一个很重要的过程

2.1 使用 web-vitals JavaScript 库收集字段数据

web-vitals JavaScript 库可用于收集 INP 数据,其中的 web-vitals/attributiononINP 方法能收集大部分用户数据:

import { onINP } from "web-vitals/attribution";

onINP((data) => {
  const { name, value, rating, attribution } = data;

  console.log(name); // 'INP'
  console.log(value); // 56
  console.log(rating); // 'good'
  console.log(attribution); // Attribution data object
});

data 对象有很多字段,下面的字段是笔者认为有用的部分:

// web-vitals attribution onINP 的 data 对象
{
  "value": 1304, // inp 值
  "rating": "poor", // 操作评级,包括:"good", "needs improvement", "poor"
  "id": "v4-1750583836339-8884992623010", // 用户操作 id
  "attribution": {
    "interactionTarget": "div#container", // 操作 DOM 的选择器字符串
    "interactionType": "pointer", // 互动类型,值包括:pointer, keyboard, drag, scroll等
    "inputDelay": 1257, // 输入延迟
    "processingDuration": 10, // 处理用时
    "presentationDelay": 36, // 展示延迟
    "longAnimationFrameEntries": [
      {
        "scripts": [
          {
            // 调用方。这可能会因下行中所述的调用方类型而异。调用方的示例可以是 'IMG#id.onload'、'Window.requestAnimationFrame' 或 'Response.json.then' 等值。
            "invoker": "Response.json.then",
            // 调用方的类型。可以是 'user-callback'、'event-listener'、'resolve-promise'、'reject-promise'、'classic-script' 或 'module-script'。
            "invokerType": "resolve-promise",
            // 长任务的脚本来源
            "sourceURL": "https://www.someassets.cn/webassets/xxx.js"
          }
        ]
      }
    ]
  }
}

2.2 inp 数据上报

笔者所在公司的上报平台支持分析 tp75 和上报数量的分析,上报代码如下:

import { onINP } from "web-vitals/attribution";
import { reportSomething } from "your-report-api";

// 获取操作元素字符串
const handleSpecialInteractionTarget = (data) => {
  const { interactionTarget, interactionType } = data?.attribution || {};

  // 元素 + 操作类型,便于聚合
  return `${interactionTarget}(${interactionType})`;
};

// 上报主 INP 时间
const reportMainValue = (data) => {
  const { id, value, rating } = data;

  reportSomething({
    field1: "inpValue",
    field2: handleSpecialInteractionTarget(data), // 操作 DOM 的选择器字符串
    field3: id, // 用户操作 id
    field4: rating, // 操作评级,包括:"good", "needs improvement", "poor"
    value, // inp 值
  });
};

// 上报输入延迟、处理时间、渲染时间
const reportSplitDelay = (data) => {
  const { id, rating, attribution } = data;
  const { inputDelay, processingDuration, presentationDelay } = attribution;

  const splitDelayList = [
    { duration: inputDelay, category: "inpInputDelay" },
    { duration: processingDuration, category: "inpProcessingDuration" },
    { duration: presentationDelay, category: "inpPresentationDelay" },
  ];

  splitDelayList.forEach(({ duration, category }) => {
    const durationValueParams = {
      field1: category,
      field2: handleSpecialInteractionTarget(data), // 操作 DOM 的选择器字符串
      field3: id, // 用户操作 id
      field4: rating, // 操作评级,包括:"good", "needs improvement", "poor"
      value: duration,
    };

    reportSomething(durationValueParams);
  });
};

// 获取长任务列表
const getLoadScripts = (data) => {
  const longAnimationFrameEntries = data.attribution.longAnimationFrameEntries;
  const loaf =
    !!longAnimationFrameEntries && longAnimationFrameEntries.length > 0
      ? longAnimationFrameEntries.at(-1)
      : null;
  return loaf?.scripts || [];
};

// 上报长任务的中特定的属性数量,包括 sourceURL, invoker, invokerType
const reportLongTask = (data) => {
  const longTaskMainList = [
    {
      longTaskKey: "sourceURL",
      category: "inpSourceURL",
    },
    {
      longTaskKey: "invoker",
      category: "inpInvoker",
    },
    {
      longTaskKey: "invokerType",
      category: "inpInvokerType",
    },
  ];

  const loafScripts = getLoadScripts(data);

  longTaskMainList.forEach(({ longTaskKey, category }) => {
    const resultMap = loafScripts.reduce((resultMap, loafScript) => {
      const scriptItemValue = loafScript[longTaskKey];

      if (typeof resultMap[scriptItemValue] === "undefined") {
        resultMap[scriptItemValue] = 0;
      }

      resultMap[scriptItemValue] += 1;

      return resultMap;
    }, {});

    const resultKeys = Object.keys(resultMap);
    resultKeys.forEach((scriptItemValue) => {
      const count = resultMap[scriptItemValue];

      reportSomething({
        field1: category,
        field2: handleSpecialInteractionTarget(data), // 操作 DOM 的选择器字符串
        field3: scriptItemValue,
        value: count,
      });
    });
  });
};

// 监听 inp 的变化,并上报 inp 的值
export const onReportInp = () => {
  onINP(
    (data) => {
      reportMainValue(data);
      reportSplitDelay(data);
      reportLongTask(data);
    },
    // 开启所有上报模式,尽量收集上报
    { reportAllChanges: true }
  );
};

2.3 整理 onINP 数据

通过上面的上报代码,整理出如下的表格

元素元素说明总量总量比例inp tp75输入延迟 tp75处理用时 tp75展示延迟 tp75sourceURLinvokerinvokerType
#container(pointer)容器点击20959742.95%26510166544www.assets.cn/some/react-… 86816, www.googletagmanager.com/gtag/js?id=…: 28737DIV#container.onclick: 85900, #document.onclick: 39736event-listener: 95288, resolve-promise: 70881

以上的 INP 数据快照表格是按照 HTML 元素名聚合,并按照上报总量比例倒序排列的,通过该表格可以分析出:

  • 找出用户高频且 INP 较高的操作。根据木桶效应,优化这部分操作性价比最高。
  • 知道元素操作的输入延迟,处理用时,展示时间和长任务信息。使用这些信息可为下一章的模拟用户场景提供参考。

3.模拟用户操作 + Performance 分析

根据现场数据整合需要优化的操作后,建议在测试环境页面模拟这些操作,并使用 Chrome Performance 的火焰图数据定位出有问题的代码。例子如下:

image.png

4.优化方式

互动由三个部分组成:输入延迟、处理用时和展示延迟。经实践发现导致这三部分延迟较高的原因有:

互动类型延迟高的原因
输入延迟React 重复渲染、布局抖动、DOM 元素过多、其他渲染流程阻塞
处理用时React 重复渲染、布局抖动、DOM 元素过多、下一帧渲染内容过多
展示延迟目前尚未找到针对展示延迟的特别有效的优化方法。

针对上述延迟高的情况,这里列举一些在实践过程中较为有用的一些优化方式。

注:如果以下代码的坏例子不能模拟出很卡的感觉,那就打开CPU节流:

image.png

4.1 减少 React 的重复渲染

减少 React 的重复渲染能直接降低了主线程的工作负载,从而加速用户交互的响应速度。针对 Input 输入框的场景尤为有效。

React 一般会配合 Redux 之类的 Store 库使用。本文的 Store 库主要使用 zustand 为例子,使用了 zustand 后,组件不需要 React.memo 也能减少 React 渲染

react-redux connect + memo 的例子:

import { memo } from "react";
import { connect } from "react-redux";

const View = (props) => {
  const { count } = props;
  return <div>{count}</div>;
};

// 使用 connect + memo ,代码会很啰嗦
export default connect((state) => {
  return state.count;
})(memo(View));

zustand相同效果的例子:

import { useStore } from "../store";

const View = () => {
  // 只需很少的代码就能拿到数据
  const count = useStore((state) => state.count);

  return <div>{count}</div>;
};

export default View;

4.1.1 React组件传入的参数越少越好

根据设计原则中的接口隔离原则,外部的参数传入给 React 组件的数量越少越好,这要做能减少 React 的重复渲染。例子如下:

image.png

坏例子:codedemo

好例子:codedemo

坏例子:ListView 组件传入了 count 数据,count 每次变化时,ListView组件都会重新渲染,即使它只需要知道 count 是否加到 5

/* ------------------------ 坏例子 ------------------------*/
import { useState } from "react";
import { getRandomList } from "../../../utils";
import { useStore } from "./store";

const ListView = () => {
  const [list] = useState(getRandomList(999));
  const count = useStore((state) => state.count);

  // count 每次变化都会触发 ListView 的重新渲染。其实 ListView 只需要知道 count 是否大于 5
  const isFull = count >= 5;

  return (
    <ul>
      {list.map((item, index) => (
        <li key={index}>
          <span>content: {item.content} </span>
          <span>是否加到5: {isFull ? "是" : "否"}</span>
        </li>
      ))}
    </ul>
  );
};

好例子:ListView 只需要知道 count 是否加到 5。优化后 count 数据没有变化到 5,ListView 组件都不会重复渲染:

/* ------------------------ 好例子 ------------------------*/
import { useState } from "react";
import { getRandomList } from "../../../utils";
import { useStore } from "./store";

const ListView = () => {
  const [list] = useState(getRandomList(999));
  //  ListView 只需要知道 count 是否大于5,这样做可以减少渲染
  const isFull = useStore((state) => state.count >= 5);

  return (
    <ul>
      {list.map((item, index) => (
        <li key={index}>
          <span>content: {item.content} </span>
          <span>是否加到5: {isFull ? "是" : "否"}</span>
        </li>
      ))}
    </ul>
  );
};

4.1.2 React 组件触发 action

React 组件经常需要在点击事件中,改变 store 的数据。但是不一小心就可能传入并不需要参与渲染的 store 数据,导致没必要的重复渲染。例子如下:

image.png

坏例子:codedemo

好例子:codedemo

坏例子:zustand store 中有一个 setState 方法,使用这个方法可以很自由地设置 store 的任意数据。但是在 ListView 组件中使用这个 setState 方法一不小心就会引入它并不需要知道的 queue 数据:

坏例子 zustand store.js:

/* ------------------------ 坏例子 ------------------------*/
import { create } from "zustand";

export const useStore = create((set) => ({
  queue: [],

  // 一个很无敌的 setState 方法,使用它可以任意设置 store 里面任何值
  setState: (params) => {
    set({
      ...params,
    });
  },
}));

坏例子 ListView 组件:

/* ------------------------ 坏例子 ------------------------*/
import { useState } from "react";
import { useStore } from "../../store";
import { getRandomList } from "../../../../../utils";

const ListView = () => {
  const [list] = useState(getRandomList(1500));
  // ListView 组件并不需要 queue 数据渲染 UI,导致了重复渲染
  const queue = useStore((state) => state.queue);

  const add = (item) => {
    useStore.getState().setState({
      // 只要在 click 事件中才需要知道 queue 数据
      queue: [...queue, item.content],
    });
  };

  return (
    <ul>
      {list.map((item, index) => {
        return (
          <li key={index}>
            <span>content: {item.content}</span>
            <button
              onClick={() => {
                add(item);
              }}
            >
              add
            </button>
          </li>
        );
      })}
    </ul>
  );
};

export default ListView;

好例子:zustand store 不应该暴露一个无敌的 setState 方法,可以修改成暴露一个不需要知道 queue 数据的 addQueue方法:

/* ------------------------ 好例子 ------------------------*/
import { create } from "zustand";

export const useStore = create((set, get) => ({
  queue: [],

  addQueue: (content) => {
    const { queue } = get();
    set({
      queue: [...queue, content],
    });
  },
}));

这样好例子的 ListView 组件并不需要知道 queue 数据了,减少了 React 重复渲染

/* ------------------------ 好例子 ------------------------*/
import { useState } from "react";
import { useStore } from "../../store";
import { getRandomList } from "../../../../../utils";

const ListView = () => {
  const [list] = useState(getRandomList(1500));

  const add = (item) => {
    // ListView 组件不需要知道 queue 数据了,减少了渲染
    useStore.getState().addQueue(item.content);
  };

  return (
    <ul>
      {list.map((item, index) => {
        return (
          <li key={index}>
            <span>content: {item.content}</span>
            <button
              onClick={() => {
                add(item);
              }}
            >
              add
            </button>
          </li>
        );
      })}
    </ul>
  );
};

export default ListView;

4.1.3 场景:Spin 组件包裹用法导致重复渲染

Shineout Spin 组件支持“对容器使用”的用法,该用法可以很方便地用一个加载态包裹容器:

image.png

但是使用这种包裹用法会导致重复渲染,例子如下:

image.png

坏例子:codedemo

好例子:codedemo

坏例子:Shineout Spin 组件包裹了一个很耗时的 CardList 组件,在 loading 变化时,这个 CardList 组件立即渲染,导致 INP 变高:

/* ------------------------ 坏例子 ------------------------*/
import { useEffect } from "react";
import { Button, Spin } from "shineout";
import CardList from "./components/card-list";
import { useStore } from "./store";
import "./index.css";

const View = () => {
  const loading = useStore((state) => state.loading);

  useEffect(() => {
    useStore.getState()?.load();
  }, []);

  // Spin 组件使用包裹的方式,会导致内部的组件频繁渲染
  return (
    <Spin className="container" loading={loading}>
      <Button
        onClick={() => {
          useStore.getState()?.load();
        }}
      >
        刷新数据
      </Button>
      {/* 这是一个渲染很耗时的List组件,loading变化时会立即重新渲染 */}
      <CardList />
    </Spin>
  );
};

export default View;

好例子:不使用 Spin 组件包裹容器的用法,而是将在一个单独的组件中渲染 Spin,这样就不会立即渲染耗时的 CardList 组件:

/* ------------------------ 好例子 ------------------------*/
import { useEffect } from "react";
import { Button, Spin } from "shineout";
import CardList from "./components/card-list";
import { useStore } from "./store";
import "./index.css";

// 单独的组件渲染 Spin
const CardLoading = () => {
  const loading = useStore((state) => state.loading);

  if (!loading) return null;

  return (
    <div
      style={{
        position: "absolute",
        zIndex: 2,
        width: "100%",
        height: "100%",
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
      }}
    >
      <Spin loading />
    </div>
  );
};

const View = () => {
  useEffect(() => {
    useStore.getState()?.load();
  }, []);

  // View 组件不需要知道 loading 参数了
  return (
    <div className="container">
      <CardLoading />
      <Button
        onClick={() => {
          useStore.getState()?.load();
        }}
      >
        刷新数据
      </Button>
      {/* 这个渲染很耗时的List组件不会立即重新渲染 */}
      <CardList />
    </div>
  );
};

export default View;

好例子虽然优化了代码,但是代码写得不好看了

4.1.4 场景:异步数据分开渲染

如果一个页面有多个异步的远程数组渲染,这些异步数据应该影响尽量少的组件。例子如下:

image.png

坏例子:codedemo

好例子:codedemo

坏例子:View 组件引入了 list 和 labelList,因此 View 组件的 list 部分会渲染两次

/* ------------------------ 坏例子 ------------------------*/
import { useEffect } from "react";
import { useStore } from "./store";
import { Input } from "shineout";

const View = () => {
  const list = useStore((state) => state.list);
  // View 组件引入了 list 和 labelList,因此 View 组件的 list 部分会渲染两次
  const labelList = useStore((state) => state.labelList);
  const init = useStore((state) => state.init);

  useEffect(() => {
    init();
  }, []);

  return (
    <div>
      <Input />
      <ul>
        {list.map((item) => {
          const targetLabel = labelList.find(
            (labelItem) => labelItem.id === item.id
          );

          return (
            <li>
              <div>content: {item.content}</div>
              {/* list 和 labelList 的数据并不同步。labelList的数据来到后会让 ListView 组件重新请求一次 */}
              <div>label: {targetLabel?.label}</div>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default View;

好例子:新建一个 LabelView 组件,让 labelList 数据值影响 LabelView 的重复渲染,而不会影响 ListView 的重复渲染

/* ------------------------ 好例子 ------------------------*/
import { useEffect } from "react";
import { useStore } from "./store";
import { Input } from "shineout";

// labelList 数据变化只会影响 LabelView 的重复渲染
const LabelView = (props) => {
  const labelList = useStore((state) => state.labelList);

  const targetLabel = labelList.find(
    (labelItem) => labelItem.id === props.itemId
  );

  return <div>label: {targetLabel?.label}</div>;
};

// labelList 数据变化不会影响 ListView 的重复渲染
const ListView = () => {
  const list = useStore((state) => state.list);
  const init = useStore((state) => state.init);

  useEffect(() => {
    init();
  }, []);

  return (
    <div>
      <Input />
      <ul>
        {list.map((item) => {
          return (
            <li>
              <div>content: {item.content}</div>
              <LabelView itemId={item.id} />
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default ListView;

4.2 强制同步布局(Forced Synchronous Layout)

多个 React 组件在 useEffect 中读取 DOM clientWidth 等布局属性会影响 INP值,核心原因是它会触发 强制同步布局(Forced Synchronous Layout) ,导致主线程阻塞和渲染流水线中断。一个强制同步布局的流程图如下:

image.png

上述流程图中“布局过期”表示 DOM 有未处理的修改(样式变更、内容更新等),React 组件的 render 过程通常会将布局标记为过期。

页面中 DOM 的数据越多时,上述流程的“执行样式计算”和“执行布局计算”的耗时会越高。因此应该尽量减少在 React 组件中读取 DOM clientWidth 等布局属性,从而减少强制同步布局操作。

4.2.1 布局抖动 —— @shein-components/Ellipsis 例子

布局抖动是指 JavaScript 代码中交替执行 DOM 写操作和布局属性读操作的模式。下面的坏例子就是典型的布局抖动例子:

image.png

坏例子:codedemo

好例子:codedemo

坏例子:

/* ------------------------ 坏例子 ------------------------*/
import { useState, useEffect, useRef } from "react";
import { getRandomList } from "../../../utils";

const Ellipsis = (props) => {
  const myRef = useRef();

  useEffect(() => {
    // 读取 width 属性会触发强制同步布局,执行耗时较长的样式计算和布局计算
    const width = myRef?.current?.getBoundingClientRect().width;

    console.log("width", width);
  }, []);

  // React 组件的 render 操作让布局过期,读取 width 属性不能使用缓存数据,而是要执行强制同步布局
  return <div ref={myRef}>{props.children}</div>;
};

const View = () => {
  const [list, setList] = useState([]);

  return (
    <div>
      <button
        onClick={() => {
          setList(getRandomList(700));
        }}
      >
        刷新数据
      </button>
      <ul>
        {/* 多次执行读取(强制布局)=> 写入DOM => 读取(强制布局) => 写入DOM。导致 INP 升高 */}
        {list.map((item, index) => (
          <Ellipsis key={index}>{item.content}</Ellipsis>
        ))}
      </ul>
    </div>
  );
};

export default View;

坏例子中的 Ellipsis 组件在 useEffect 中读取 width 属性,会触发强制同步布局。Ellipsis 组件又会渲染多个,导致触发了多次无谓的强制同步布局,导致 INP 升高。主线程流程如下:

image.png

好例子:

const Ellipsis = (props) => {
  const myRef = useRef();

  // 去除读取 width 的逻辑,不再多次出发强制同步布局
  // useEffect(() => {
  //   const width = myRef?.current?.getBoundingClientRect().width;
  //   console.log("width", width);
  // }, []);

  return <div ref={myRef}>{props.children}</div>;
};

去除了读取 width 的逻辑后,不再多次出发强制同步布局。主线程流程变正常:

image.png

4.3 下一帧优化

"下一帧优化"是指通过技术手段,确保用户交互后浏览器下一帧(16ms内)就能显示视觉反馈的优化策略。“下一帧优化”的关键是减少操作后渲染,可将渲染分为高优先级渲染和低优先级渲染,在下一帧中优先渲染高优先级渲染,延迟渲染低优先级渲染。

4.3.1 React 的 useTransition 和 useDeferredValue

useTransition:用来标记“低优先级”状态更新,把一些耗时的、非紧急的渲染任务延后执行,主线程优先响应用户输入。

useDefereredValue:延迟一个快速变化的值的更新,以减少高频率状态更新导致的渲染压力。

下面的例子使用 useTransition 优化下一帧:

image.png

坏例子:codedemo

好例子:codedemo

坏例子:点击“展开”时,下一帧既要渲染展开/收缩按钮,又要渲染耗时很高的list内容,所以处理用时很高

/* ------------------------ 坏例子 ------------------------*/
import { useState } from "react";
import { getRandomList } from "../../../utils";

const View = () => {
  const [isOpen, setIsOpen] = useState(false);
  const [list] = useState(getRandomList(3000));

  const finalList = isOpen ? list : [];

  return (
    <div>
      <button
        onClick={() => {
          setIsOpen(!isOpen);
        }}
      >
        {isOpen ? "收缩" : "展开"}
      </button>
      <ul>
        {finalList.map((item, index) => (
          <div key={index}>{item.content}</div>
        ))}
      </ul>
    </div>
  );
};

export default View;

好例子:区分高优先级渲染——展开/收缩按钮,和低优先级渲染——list内容。通过 startTransition 延迟渲染低优先级内容

/* ------------------------ 好例子 ------------------------*/
import { useState, useTransition } from "react";
import { getRandomList } from "../../../utils";

const View = () => {
  const [isOpen, setIsOpen] = useState(false);
  const [list] = useState(getRandomList(3000));
  const [finalList, setFinalList] = useState([]);
  const [isPending, startTransition] = useTransition();

  const toggleOpen = () => {
    setIsOpen((open) => !open);
    // 延迟渲染list内容
    startTransition(() => {
      setFinalList(() => (isOpen ? [] : list));
    });
  };
  return (
    <div>
      <button
        onClick={() => {
          toggleOpen();
        }}
      >
        {isOpen ? "收缩" : "展开"}
      </button>
      <ul>
        {isPending && <div>加载中...</div>}
        {!isPending
          ? finalList.map((item, index) => (
              <div key={index}>{item.content}</div>
            ))
          : null}
      </ul>
    </div>
  );
};

export default View;

注:该场景下如果用户频繁点击,INP还是会很高,可使用节流/防抖优化。

4.3.2 场景:同一个 store 数据渲染多个视图

经常会遇到的 React + Store 的场景是,有同一个 store 数据传入到多个 React 组件中,下一帧的操作让该 store 数据变化时,所有的 React 视图也渲染,导致 INP 升高。例子如下:

image.png

该例子中分为3个视图:全选Checkbox(AllSelectCheckbox)、按钮组(ButtonGroup)、列表(ListView),这三个视图都会被 zustand store 中的 selected 数组影响。

坏例子:codedemo

好例子:codedemo

坏例子:点击“全选”会改变 selected 的值,让3个视图都渲染,导致下一帧的处理用时升高:

/* ------------------------ 坏例子 ------------------------*/
/*-------------------- components/all-select-checkbox/index.jsx ------------------*/
import { useStore } from "../../store";
import { Checkbox } from "shineout";

const AllSelectCheckbox = () => {
  const allChecked = useStore((state) => {
    const list = state.list;
    const selected = state.selected;

    return selected?.length > 0 && list.length === selected.length;
  });

  return (
    <Checkbox
      checked={allChecked}
      onClick={() => {
        useStore.getState().switchSelectAll();
      }}
    >
      全选
    </Checkbox>
  );
};

export default AllSelectCheckbox;


/*-------------------------- index.jsx ------------------*/
// AllSelectCheckbox、ButtonGroup、ListView 都会获取 store 中的 selected 数据
import AllSelectCheckbox from "./components/all-select-checkbox";
import ButtonGroup from "./components/button-group";
import ListView from "./components/list-view";

const View = () => {
  return (
    <div>
      <AllSelectCheckbox />
      <ButtonGroup />
      <ListView />
    </div>
  );
};

export default View;

好例子:好例子也使用 useTransition 延迟渲染低优先级渲染。与上个例子不同的是,startTransition 的回调函数内调用的是 zustand action 而不是 react 的 setState,但是 useTransition 只能管理 React 自身的状态更新队列,它无法控制 zustand 外部状态 的更新机制。因此使用一个中间 state trigger 延迟 zustand action 操作

/* ------------------------ 坏例子 ------------------------*/
import { useState, useTransition, useEffect } from "react";
import { useStore } from "../../store";
import { Checkbox } from "shineout";

const AllSelectCheckbox = () => {
  // 乐观UI:优先渲染全选的 checked 状态
  const [optimisticChecked, setOptimisticChecked] = useState(null);
  const [isPending, startTransition] = useTransition();
  const [trigger, setTrigger] = useState(0); // 触发重新渲染

  const allCheckedOuter = useStore((state) => {
    const list = state.list;
    const selected = state.selected;

    return selected?.length > 0 && list.length === selected.length;
  });

  const allChecked =
    optimisticChecked !== null ? optimisticChecked : allCheckedOuter;

  // 监听 trigger 变化,执行真正的全选/取消全选
  useEffect(() => {
    if (trigger === 0) return;
    useStore.getState().switchSelectAll();
    setOptimisticChecked(null);
  }, [trigger]);

  return (
    <Checkbox
      disabled={isPending}
      checked={allChecked}
      onClick={() => {
        setOptimisticChecked(!allChecked); // 先乐观更新

        // useTransition 只能管理 React 自身的状态更新队列。它无法控制 zustand 外部状态 的更新机制
        startTransition(() => {
          setTrigger((t) => t + 1); // 触发副作用
        });
      }}
    >
      全选
    </Checkbox>
  );
};

export default AllSelectCheckbox;

4.3.3 场景:下一帧是异步请求但先展示缓存数据

另外一个常见的场景是,点击某个按钮时先远程请求数据,等请求数据返回后才渲染。但是有可能为了让用户先看到内容,操作后会先渲染缓存数据,等请求数据返回后重新渲染,这样下一帧渲染的内容过大,导致 INP 升高,例子如下:

image.png

坏例子:codedemo

好例子:codedemo

坏例子:切换 tab会先展示缓存 list 数据,异步数据返回后再展示新的 list 数据,导致下一帧处理用时较高

/* ------------------------ 坏例子 ------------------------*/
import { useEffect, useState } from "react";
import ListView from "./components/list-view";
import { getRandomList } from "../../../utils";

const TAB_STATUS = [
  {
    id: "id1",
    label: "状态1",
  },
  {
    id: "id2",
    label: "状态2",
  },
];

const fetchData = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(getRandomList(3000));
    }, 1000);
  });
};

const View = () => {
  const [statusId, setStatusId] = useState("id1");
  // list map 数据,可以缓存每个状态的 list 数据
  const [dataMap, setDataMap] = useState({});

  useEffect(() => {
    const init = async () => {
      const res = await fetchData();
      setDataMap({
        ...dataMap,
        [statusId]: res,
      });
    };
    init();
  }, [statusId]);

  return (
    <div>
      <ul>
        {TAB_STATUS.map(({ id, label }, index) => (
          <button
            key={index}
            onClick={() => {
              setStatusId(id);
            }}
          >
            {label}
          </button>
        ))}
      </ul>

      <div>currentStatusId: {statusId}</div>

      {/* 切换tab时,立即展示缓存数据,远程请求回来后再展示新的数据 */}
      <ListView list={dataMap[statusId]} />
    </div>
  );
};

export default View;

好例子:优化后只保存一份 list 数据,等异步数据返回后才设置这个 list,保证下一帧渲染尽量少的内容

/* ------------------------ 好例子 ------------------------*/
import { useEffect, useState, memo } from "react";

import ListView from "./components/list-view";
import { getRandomList } from "../../../utils";

const TAB_STATUS = [
  {
    id: "id1",
    label: "状态1",
  },
  {
    id: "id2",
    label: "状态2",
  },
];

const fetchData = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(getRandomList(3000));
    }, 1000);
  });
};

const View = () => {
  const [statusId, setStatusId] = useState("id1");
  // 只保存一份 list 数据,不记录缓存
  const [list, setList] = useState([]);

  useEffect(() => {
    const init = async () => {
      const res = await fetchData();
      // 等异步数据请求后才设置唯一一份 list 数据
      setList(res);
    };
    init();
  }, [statusId]);

  return (
    <div>
      <ul>
        {TAB_STATUS.map(({ id, label }, index) => (
          <button
            key={index}
            onClick={() => {
              setStatusId(id);
            }}
          >
            {label}
          </button>
        ))}
      </ul>

      <div>currentStatusId: {statusId}</div>

      {/* 切换tab时,原来的数据不变,远程请求回来后再展示新的数据 */}
      <ListView list={list} />
    </div>
  );
};

export default View;

4.3.4 链接跳转下一帧优化

笔者收集数据时发现 a标签跳转和 window.open 的 INP 值异常地高,通过 requestAnimationFrame 能有效降低跳转操作的 INP:

const View = () => {
  // 直接调用 window.open INP 会异常高
  // const handleLinkTo = () => {
  //   window.open("https://www.baidu.com");
  // };

  // 使用 requestAnimationFrame 包裹一层后,INP 有所下降
  const handleLinkTo = (e) => {
    // 1. 阻止浏览器默认立即导航
    e.preventDefault();

    // 2. 下一帧再手动打开
    requestAnimationFrame(() => {
      window.open("https://www.baidu.com", "_blank", "noopener,noreferrer");
    });
  };

  return <span onClick={handleLinkTo}>跳转到</span>;
};

export default View;

4.4 减少 DOM

页面的 DOM 越少,浏览器读写 DOM 的时间就越快,INP 就会越低。

4.4.1 懒加载

建议懒加载非可见范围内的UI,其中一种懒加载方式是用 IntersectionObserver,例子如下:

image.png

坏例子:codedemo

好例子:codedemo

坏例子:

/* ------------------------ 坏例子 ------------------------*/
import { Card } from "shineout";

const CardList = (props) => {
  const { list } = props;

  return (
    <div>
      {list?.map((item, index) => {
        // 所有 Card 直接渲染,下一帧的需要渲染的内容较多
        return (
          <Card
            title="Card title"
            key={index}
            variant="borderless"
            style={{
              width: 300,
              height: 150,
              padding: 12,
              marginBottom: 20,
            }}
          >
            <p>{item.content}</p>
          </Card>
        );
      })}
    </div>
  );
};

export default CardList;

好例子:新建一个 Lazyload 组件,该组件使用 IntersectionObserver,让 DOM 在可见范围内才渲染实际的内容,否则展示 placeholder

/* ------------------------ 好例子 ------------------------*/
import { useState, useRef, useEffect } from "react";
import { Card } from "shineout";

const Lazyload = ({ placeholder, children }) => {
  const [isInView, setIsInView] = useState(false);
  const containerRef = useRef(null);

  useEffect(() => {
    if (!containerRef.current) return;

    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setIsInView(true);
            observer.disconnect(); // 进入视口后停止观察
          }
        });
      },
      {
        root: null, // 相对于视口
        rootMargin: "0px",
        threshold: 0.1, // 元素至少10%可见触发
      }
    );

    observer.observe(containerRef.current);

    return () => {
      observer.disconnect();
    };
  }, []);

  return <div ref={containerRef}>{isInView ? children : placeholder}</div>;
};

const placeholder = <div>loading</div>;

const CardList = (props) => {
  const { list } = props;
  // 是否范围内可见的卡片数
  const [visibleCount, _] = useState(Math.ceil(window.innerHeight / 170));

  return (
    <div>
      {list?.map((item, index) => {
        const content = (
          <Card
            title="Card title"
            key={index}
            variant="borderless"
            style={{
              width: 300,
              height: 150,
              padding: 12,
              marginBottom: 20,
            }}
          >
            <p>{item.content}</p>
          </Card>
        );

        // 如果超出首屏可视范围,则添加懒加载组件
        if (index >= visibleCount) {
          return (
            <Lazyload key={index} placeholder={placeholder}>
              {content}
            </Lazyload>
          );
        }

        return content;
      })}
    </div>
  );
};

export default CardList;

4.5 其他优化

4.5.1 限制用户操作

如果 react 正在渲染,此时对输入框和按钮进行操作,INP 会很高,可以在渲染期间限制用户操作:

import { useState } from "react";
import { Button, Input } from "shineout";

const View = () => {
  const [loading, setLoading] = useState(false);

  return (
    <div>
      <Input type="text" disabled={loading} />
      <Button
        loading={loading}
        onClick={async () => {
          setLoading(true);

          // 远程请求数据
          await fetchData();

          setLoading(false);
        }}
      >
        刷新数据
      </Button>
    </div>
  );
};

export default View;

4.5.2 长任务延迟

有一些耗时比较大的逻辑,可以 setTimeout 延迟操作,减少对下一帧的影响

import { useState } from "react";
import { getRandomList } from "../../../utils";

const randomListStr = JSON.stringify(getRandomList(999999));

const View = () => {
  const [isOpen, setIsOpen] = useState(false);

  const toggleOpen = () => {
    const targetIsOpen = !isOpen;

    setIsOpen(targetIsOpen);

    if (targetIsOpen) {
      setTimeout(() => {
        // 这是个长任务,延迟操作
        const newList = JSON.parse(randomListStr);
        console.log("newList---", newList);
      }, 0);
    }
  };

  return (
    <div>
      <button onClick={toggleOpen}>{isOpen ? "收缩" : "展开"}</button>
    </div>
  );
};

export default View;

4.5.3 其他优化

  • 懒加载非首屏所需的渲染和长任务
  • 慎用 MutaitionObserver 等耗时的 API 和 google gtag.js 等耗时的第三方包

5.对 INP 的思考

INP 的核心价值在于提升用户操作页面的流畅度,从而优化用户体验。但作为开发者,我们需理性看待 INP 数值的下降是否真正转化为用户体验的提升,这值得深入探讨。

5.1 代码复杂度的增加

优化 INP 往往伴随代码复杂度的上升,例如引入 useTransition 或懒加载逻辑,会显著增加维护成本。这些优化可能降低代码可读性,影响后续功能开发效率。

5.2 性能劣化的风险

随着代码复杂度增加,后续迭代中优化方案可能被弃用。更关键的是,INP 指标极易波动,若无防劣化监控机制,优化成果可能迅速瓦解。

5.3 用户体验的真实提升

过度关注 INP 数值可能导致"指标异化"。例如:

  • "下一帧优化" 将渲染拆分为多帧,与一次性渲染的卡顿相比,实际体验差异是否显著?
  • 操作限制策略(如禁用输入框)虽降低 INP,但牺牲了用户操作自由,是否真正优化了体验?

5.4 监控代码的取舍

为追求 INP 降低到 200ms 的优化,可能需移除 APM 插件或 gtag.js 等监控代码(因其常使用耗时的 MutationObserver)。但此举会削弱系统可观测性,需谨慎权衡可控性与性能。