1.背景
INP 是 Web Vitals 中的一个关键指标,用于衡量用户执行交互(如点击、键盘输入等)到页面发生视觉更新(下一次 Keyframe Paint)的时间。简单来说,INP 评估用户感觉页面是否响应流畅。INP 的最终目标是为了提升用户体验。
- 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/attribution 的 onINP 方法能收集大部分用户数据:
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 | 展示延迟 tp75 | sourceURL | invoker | invokerType |
---|---|---|---|---|---|---|---|---|---|---|
#container(pointer) | 容器点击 | 209597 | 42.95% | 265 | 10 | 166 | 544 | www.assets.cn/some/react-… 86816, www.googletagmanager.com/gtag/js?id=…: 28737 | DIV#container.onclick: 85900, #document.onclick: 39736 | event-listener: 95288, resolve-promise: 70881 |
以上的 INP 数据快照表格是按照 HTML 元素名聚合,并按照上报总量比例倒序排列的,通过该表格可以分析出:
- 找出用户高频且 INP 较高的操作。根据木桶效应,优化这部分操作性价比最高。
- 知道元素操作的输入延迟,处理用时,展示时间和长任务信息。使用这些信息可为下一章的模拟用户场景提供参考。
3.模拟用户操作 + Performance 分析
根据现场数据整合需要优化的操作后,建议在测试环境页面模拟这些操作,并使用 Chrome Performance 的火焰图数据定位出有问题的代码。例子如下:
4.优化方式
互动由三个部分组成:输入延迟、处理用时和展示延迟。经实践发现导致这三部分延迟较高的原因有:
互动类型 | 延迟高的原因 |
---|---|
输入延迟 | React 重复渲染、布局抖动、DOM 元素过多、其他渲染流程阻塞 |
处理用时 | React 重复渲染、布局抖动、DOM 元素过多、下一帧渲染内容过多 |
展示延迟 | 目前尚未找到针对展示延迟的特别有效的优化方法。 |
针对上述延迟高的情况,这里列举一些在实践过程中较为有用的一些优化方式。
注:如果以下代码的坏例子不能模拟出很卡的感觉,那就打开CPU节流:
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 的重复渲染。例子如下:
坏例子: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 数据,导致没必要的重复渲染。例子如下:
坏例子: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 组件支持“对容器使用”的用法,该用法可以很方便地用一个加载态包裹容器:
但是使用这种包裹用法会导致重复渲染,例子如下:
坏例子: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 场景:异步数据分开渲染
如果一个页面有多个异步的远程数组渲染,这些异步数据应该影响尽量少的组件。例子如下:
坏例子: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) ,导致主线程阻塞和渲染流水线中断。一个强制同步布局的流程图如下:
上述流程图中“布局过期”表示 DOM 有未处理的修改(样式变更、内容更新等),React 组件的 render 过程通常会将布局标记为过期。
页面中 DOM 的数据越多时,上述流程的“执行样式计算”和“执行布局计算”的耗时会越高。因此应该尽量减少在 React 组件中读取 DOM clientWidth 等布局属性,从而减少强制同步布局操作。
4.2.1 布局抖动 —— @shein-components/Ellipsis 例子
布局抖动是指 JavaScript 代码中交替执行 DOM 写操作和布局属性读操作的模式。下面的坏例子就是典型的布局抖动例子:
坏例子:
/* ------------------------ 坏例子 ------------------------*/
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 升高。主线程流程如下:
好例子:
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 的逻辑后,不再多次出发强制同步布局。主线程流程变正常:
4.3 下一帧优化
"下一帧优化"是指通过技术手段,确保用户交互后浏览器下一帧(16ms内)就能显示视觉反馈的优化策略。“下一帧优化”的关键是减少操作后渲染,可将渲染分为高优先级渲染和低优先级渲染,在下一帧中优先渲染高优先级渲染,延迟渲染低优先级渲染。
4.3.1 React 的 useTransition 和 useDeferredValue
useTransition:用来标记“低优先级”状态更新,把一些耗时的、非紧急的渲染任务延后执行,主线程优先响应用户输入。
useDefereredValue:延迟一个快速变化的值的更新,以减少高频率状态更新导致的渲染压力。
下面的例子使用 useTransition 优化下一帧:
坏例子:点击“展开”时,下一帧既要渲染展开/收缩按钮,又要渲染耗时很高的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 升高。例子如下:
该例子中分为3个视图:全选Checkbox(AllSelectCheckbox)、按钮组(ButtonGroup)、列表(ListView),这三个视图都会被 zustand store 中的 selected 数组影响。
坏例子:点击“全选”会改变 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 升高,例子如下:
坏例子:切换 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,例子如下:
坏例子:
/* ------------------------ 坏例子 ------------------------*/
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)。但此举会削弱系统可观测性,需谨慎权衡可控性与性能。