Files
cs-note/hhs/REACT/2. Hooks 篇/06-性能优化 Hooks.md
T

422 lines
13 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
tags: [React, Hooks, Performance, useMemo, useCallback, Frontend]
create time: 2026-04-29 22:05
---
# 性能优化 Hooks
## 概述
当 React 应用出现不必要的重渲染时,开发者最先想到的是 `useMemo` 和 `useCallback`。但它们也有认知成本——**不恰当的使用反而会让代码更慢更乱**。理解何时该用、何时不该用是关键。
> [!question] 思考:为什么大部分 React 应用根本不需要 useMemo?
> 现代浏览器处理一个普通 JavaScript 函数只需几微秒,而 `useMemo` 自身就有缓存对比 + 闭包引用的开销。在没有测量(measure)之前就加记忆化,本质上是猜。
```mermaid
flowchart TD
A["发现卡顿"] --> B{"是否在 DevTools<br/>Profiler 中定位?"}
B -->|否| C["先安装 React DevTools<br/>定位瓶颈组件"]
B -->|是| D{"子组件是否因<br/>props 引用变化重渲染?"}
D -->|是| E["给子组件加 React.memo"]
D -->|否| F{"计算是否真的昂贵?"}
F -->|是| G["用 useMemo 缓存结果"]
F -->|否| H["不要优化 — 保持简单"]
E --> I{"回调引用是否<br/>每次都被替换?"}
I -->|是| J["用 useCallback 稳定引用"]
I -->|否| K["不要优化"]
style C fill:#F5A87D,color:#000
style H fill:#F5A87D,color:#000
style K fill:#F5A87D,color:#000
style G fill:#4FC08D,color:#fff
style J fill:#4FC08D,color:#fff
```
本文聚焦以下 Hooks,按实用场景排序:
| Hook | 作用 | React 版本 |
|------|------|------------|
| `useMemo` | 缓存计算结果 | 16.8+ |
| `useCallback` | 缓存函数引用 | 16.8+ |
| `useTransition` | 标记低优先级更新 | 18+ |
| `useDeferredValue` | 产生延迟值副本 | 18+ |
| `useDebugValue` | 自定义 Hook 调试标记 | 16.8+ |
| `useId` | 服务端一致的唯一 ID | 18.3+ |
> [!note] 前置知识
> 这些 Hooks 建立在 `useState` / `useEffect` 的基础上。如果你还不熟悉核心 Hooks,请先阅读 [[05-核心 Hooks]]。
---
## useMemo
```tsx
const memoizedValue = useMemo(() => computeExpensive(a, b), [a, b]);
```
### 执行时机
| 时机 | 行为 |
|------|------|
| 依赖未变 | 返回上次缓存的值(不调用计算函数) |
| 任一依赖变化 | 重新执行计算函数并缓存新结果 |
| 组件首次渲染 | 执行计算(无缓存可用) |
### 适用场景
```tsx
// ✅ 适用:昂贵计算 + 频繁 re-render
const sortedUsers = useMemo(() => {
return users.sort((a, b) => a.name.localeCompare(b.name));
}, [users]);
// ✅ 适用:复杂对象/数组衍生值
const userStats = useMemo(() => ({
total: users.length,
active: users.filter(u => u.active).length,
avgAge: users.reduce((sum, u) => sum + u.age, 0) / users.length,
}), [users]);
// ❌ 不适用:简单运算(开销比 useMemo 本身还大)
const double = useMemo(() => count * 2, [count]);
```
> [!tip] 判断标准
> - 如果表达式只包含 `+ - * /` 或简单的 `.filter()` / `.map()` → 不需要 useMemo
> - 如果涉及 API 调用、深层遍历、DOM 测量、大量排序 → 考虑 useMemo
> - 如果传给了 `React.memo` 包裹的子组件作为 prop → 优先考虑 useMemo
### 常见陷阱
> [!warning] 依赖陷阱:useMemo 的第二个参数决定了一切
> ```tsx
> // ❌ 忘记放入依赖 —— 闭包捕获旧值
> const memo = useMemo(() => items.filter(i => i.price > minPrice), [items]);
> // minPrice 变了但 memo 没刷新!
>
> // ✅ 列出所有外部引用
> const memo = useMemo(() => items.filter(i => i.price > minPrice), [items, minPrice]);
> ```
---
## useCallback
```tsx
const memoizedCallback = useCallback(
(arg1: string, arg2: number) => doSomething(arg1, arg2),
[dep1, dep2],
);
```
### 本质
```mermaid
flowchart LR
A["每次渲染创建新函数"] --> B["子组件收到新引用"]
B --> C["子组件 re-render<br/>即使 props 内容没变"]
D["useCallback 包装"] --> E["保持同一函数引用"]
E --> F["memo / React.memo<br/>拦截重渲染"]
style C fill:#F5A87D,color:#000
style F fill:#4FC08D,color:#fff
```
### 典型使用模式
```tsx
// 父组件:稳定 callback 引用传给子组件
function Parent() {
const [query, setQuery] = useState("");
// 不包装:每次 render 都是新函数 → Child 永远 re-render
// const handleChange = (e) => setQuery(e.target.value);
// 包装:引用稳定,Child 在 query 不变时不重渲染
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
}, []); // setQuery 引用稳定(setState),可不列入依赖
return <SearchInput value={query} onChange={handleChange} />;
}
// 子组件:配合 React.memo 生效
const SearchInput = React.memo(({ value, onChange }: { value: string; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void }) => {
return <input value={value} onChange={onChange} />;
});
```
> [!example] useCallback + React.memo 联合效果
> 见 [[05-核心 Hooks]] 中"**三者联合:防止过度重渲染**"章节,有完整示例。
### 依赖数组设计原则
> [!summary] 不要把所有局部变量都塞进依赖数组
> ```tsx
> // ❌ 错误思路:把所有变量都列上
> const handler = useCallback(() => {
> doSomething(a, b, c, d, e, f);
> }, [a, b, c, d, e, f]);
> // 几乎所有变量都会变 → 几乎每次都生成新函数 → 失去意义
>
> // ✅ 正确思路:思考"哪些真正影响这个回调的行为"
> const handler = useCallback(() => {
> fetchData(keyword); // keyword 是唯一外部依赖
> }, [keyword]);
> ```
>
> **关键经验**:`setState` setter 函数和 `useRef.current` 引用是稳定的,不需要放入依赖数组。
---
## useTransition(React 18)
```tsx
const [isPending, startTransition] = useTransition();
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const handleChange = (q: string) => {
setQuery(q); // 同步更新(立即渲染,高优先级)
// 标记为 transition:低优先级更新
startTransition(() => {
setResults(performSearch(q)); // 延迟渲染,不阻塞 UI
});
};
return (
<>
<input value={query} onChange={e => handleChange(e.target.value)} />
{isPending && <Spinner />}
<Results list={results} />
</>
);
}
```
### 语义解读
`useTransition` 的核心思想是:**将一批状态更新分为"马上显示"和"稍后显示"**。`startTransition` 内部的 setState 会被降级为后台优先级,React 可以随时中断它去响应用户交互。
### 使用场景区别
```mermaid
quadrantChart
title "更新优先级划分"
x-axis "高优先级" --> "低优先级"
y-axis "数据驱动" --> "UI 展示"
"onClick → setState": [0.8, 0.9]
"路由切换": [0.7, 0.7]
"search → results": [0.3, 0.8]
"scroll animation": [0.2, 0.3]
"typing in input": [0.9, 0.5]
```
### useTransition vs useDeferredValue
| 维度 | useTransition | useDeferredValue |
|------|---------------|-------------------|
| 控制粒度 | 包裹特定 setState | 包裹整个值 |
| 语义 | 启动一个低优先级事务 | 产生一个延迟副本 |
| 适合场景 | 表单提交后加载详情 | 搜索框前后分屏 |
| 使用复杂度 | 需手动选择哪些更新降级 | 一行封装,自动推导 |
> [!tip] 如何选择?
> - 你想让 UI 看起来流畅(输入立刻响应,结果稍后出来)→ `useDeferredValue`
> - 你有一组相关更新想让它们一起降级 → `useTransition`
> - 不确定?先用 `useTransition`,它在大多数场景下都能工作
---
## useDeferredValue(React 18)
```tsx
function SearchPage() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query); // query 即时更新,deferredQuery 延迟更新
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
{/* 快速响应用户输入 */}
<QuickSuggestions keyword={query} />
{/* 耗时操作延迟渲染 */}
<HeavyResultsList keyword={deferredQuery} />
</>
);
}
```
### 内部工作原理
```mermaid
sequenceDiagram
participant U as 用户输入
participant S as useState (query)
participant D as useDeferredValue
participant R as React Scheduler
U->>S: setQuery("abc")
S-->>R: 高优先级更新(立即渲染)
Note over R: 输入框立刻显示 "abc"
R->>D: query 已更新
D->>R: 请求延迟值(可被抢占)
Note over R: 如果有更高优先级事件<br/>会暂停 deferred 渲染
R->>R: 空闲时渲染 HeavyResultsList
```
> [!question] 思考:deferred 值延迟多久?
> React 没有固定的延迟时间。它的策略是:"如果有更高优先级的工作要处理(比如用户正在打字),就暂缓 deferred 渲染;等浏览器空闲了再补上。"这使得它既能保证流畅性,又能最终呈现结果。
---
## useDebugValue(自定义 Hook 调试)
> [!abstract] 当你编写自定义 Hook 时,如何在 React DevTools 中看到有意义的值?
> 这就是 `useDebugValue` 的用武之地 —— 它为自定义 Hook 添加自定义显示标签。
```tsx
function useOnlineStatus() {
const [online, setOnline] = useState(navigator.onLine);
// 👇 在 DevTools 中显示可读的 "🟢 Online" 或 "🔴 Offline"
useDebugValue(online ? "🟢 Online" : "🔴 Offline");
useEffect(() => {
const onOnline = () => setOnline(true);
const onOffline = () => setOnline(false);
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
return () => {
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
};
}, []);
return online;
}
```
### 延迟格式化(高性能场景)
```tsx
function useFetch(url: string) {
const [data, setData] = useState(null);
// 延迟格式化:只在 DevTools 展开时才执行 format 函数
// 避免在开发环境中对大型数据集造成额外开销
useDebugValue(data, d =>
d ? `${d.items?.length || 0} items loaded` : "loading..."
);
// ... fetch logic
return data;
}
```
> [!tip] 生产环境安全
> `useDebugValue` 在生产构建中会被自动忽略,不会产生任何运行时开销。放心在自定义 Hook 中使用。
---
## useId(React 18.3+)
> [!important] SSR 兼容的唯一 ID
> `useId` 专为 accessibility 属性设计(如 `aria-labelledby`、`htmlFor`)。它保证了服务端和客户端生成的 ID 完全一致,解决了 hydrate mismatch 问题。
```tsx
function FormField() {
// 每次渲染生成唯一且稳定的 ID
const labelId = useId();
const inputId = useId();
return (
<>
<label id={labelId}>Username</label>
<input id={inputId} aria-labelledby={labelId} />
</>
);
}
```
### useId vs Math.random() vs 手动字符串
| 方式 | SSR 一致 | Hydrate 匹配 | 确定性 |
|------|----------|-------------|--------|
| `useId()` | ✅ | ✅ | ✅ 基于树结构 |
| `Math.random()` | ❌ | ❌ | ❌ 每次随机 |
| 手动 `"field-1"` | ✅ | ⚠️ 需人工维护 | ⚠️ 易重复 |
> [!warning] useId 不适合用于 key
> useId 生成的 ID 是稳定的但不一定是唯一的(同一组件多次渲染可能返回相同 ID)。列表的 `key` 仍应使用业务标识符。
---
## 性能调优 Checklist
> [!summary] 优化顺序
> 不要一开始就用 useMemo/useCallback!按以下顺序排查:
1. **减少不必要的 state** — 能派生的从 state 中移除(derived state)
2. **拆分巨型组件** — 子组件独立维护自己的 state
3. **React DevTools Profiler** — 定位哪个组件在多余 re-render
4. **给高频子组件加 React.memo**
5. **最后才考虑 useMemo / useCallback**
```tsx
// Step 1: 先移除所有 useMemo/useCallback 做基准测试
// Step 2: 加上 React.memo 包裹频繁更新的子组件
// Step 3: 仅针对仍存在的瓶颈点添加 useMemo/useCallback
```
### React DevTools Profiler 实操
```tsx
import { Profiler } from "react";
function onRenderCallback(
id: string, // "MyComponent"
phase: "mount" | "update",
actualDuration: number // 本次渲染耗时(ms)
) {
console.log(`${id} ${phase}: ${actualDuration.toFixed(2)}ms`);
}
// 用法:包裹需要监控的 subtree
<Profiler id="App" onRender={onRenderCallback}>
<App />
</Profiler>
```
> [!tip] Profiler API vs DevTools 界面
> - **Profiler API**:适合持续记录生产环境中的性能数据
> - **DevTools 界面**:适合开发阶段交互式点击分析哪个组件导致了重渲染
> 两者可以结合使用。
---
## 何时绝对不要用这些 Hook?
> [!question] 思考:哪些场景加了优化反而适得其反?
| 场景 | 原因 |
|------|------|
| 简单算术运算 | `count * 2` 的开销远小于闭包比较 |
| 低频渲染组件 | 一年才重渲染一次的东西不需要优化 |
| 顶层容器组件 | 容器本身很少子组件,优化收益为零 |
| 嵌套过深的依赖 | `useCallback(() => fn(a,b,c,d,e), [a,b,c,d,e])` → 等价于不用 |
> [!note] Google 的前端性能研究结论
> 在他们的实际项目度量中,超过 90% 的重渲染问题通过合理的 `React.memo` 就能解决,真正需要 `useMemo` / `useCallback` 的场景不足 5%。
---
## 关联笔记
- [[05-核心 Hooks]] — useState / useEffect / useContext / useRef 详解
- [[07-自定义 Hooks]] — 如何设计和组合可复用的自定义 Hook