422 lines
13 KiB
Markdown
422 lines
13 KiB
Markdown
---
|
||
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
|