vault backup: 2026-04-29 22:19:51

This commit is contained in:
2026-04-29 22:19:51 +08:00
parent 0421a3c801
commit 94bc44ab8f
19 changed files with 4877 additions and 0 deletions
+495
View File
@@ -0,0 +1,495 @@
---
tags: [React, Hooks, useState, useEffect, useContext, useRef, Frontend]
create time: 2026-04-29 22:04
---
# 核心 Hooks
## 概述
Hook 是 React 16.8 引入的特性,让函数组件拥有 State、副作用管理、上下文消费等原本只有 Class 组件才具备的能力。
> [!question] 思考:在 Hook 出现之前,Class 组件有哪些痛点?
| 痛点 | 表现 | Hook 的解法 |
|------|------|-------------|
| **状态逻辑复用** | 用 HOC / Render Props 嵌套过深("回调地狱") | 自定义 Hook 直接抽离状态逻辑 |
| **职责分散** | 同一逻辑被拆分到 `componentDidMount`、`componentDidUpdate`、`componentWillUnmount` | 同一 `useEffect` 内组合关联逻辑 + cleanup |
| **this 指向混乱** | 需要手动 bind、箭头函数或类字段属性 | 函数组件没有 `this`,闭包天然解决 |
| **组件臃肿** | 大组件难以阅读和维护 | 按关注点拆分为多个 Hook |
Hook 的设计哲学可以总结为一句话:**把"组件做什么"和"组件什么时候做"解耦**。
本文聚焦四个核心 Hook —— **useState、useEffect、useContext、useRef**,并在此基础上介绍性能优化 Hook 与自定义 Hook 实战。
---
## Hook 执行规则
> [!warning] 两条铁律
> 1. **只在最顶层调用 Hook** — 不能在循环、条件、嵌套函数中调用
> 2. **只在 React 函数组件或自定义 Hook 中调用** — 不能在其他普通 JS 函数中调用
```tsx
// ❌ 违反规则:条件调用 Hook
function Component({ loaded }) {
if (loaded) {
const [data, setData] = useState(null); // 崩溃!
}
}
// ✅ 正确:条件逻辑放在 Hook 内部
function Component({ loaded }) {
const [data, setData] = useState(null);
useEffect(() => {
if (!loaded) return;
fetch("/api/data").then(setData);
}, [loaded]);
}
```
### Hook 的执行顺序依赖调用顺序
React 内部用一个数组存储每个组件的 Hook 状态。每次渲染时,**按声明顺序依次取出对应 Hook 的状态**。这就是为什么必须在顶层、固定顺序调用。
```tsx
function Counter() {
const [count, setCount] = useState(0); // Hook #0
const theme = useContext(ThemeContext); // Hook #1
const btnRef = useRef<HTMLButtonElement>(); // Hook #2
// 每次渲染都按这个顺序取状态,不能颠倒或删除
}
```
---
## useState
### 基础用法
```tsx
const [state, setState] = useState<T>(initialValue);
```
当初始值计算开销较大时,使用惰性初始化避免重复计算:
```tsx
const [items, setItems] = useState(() => loadFromStorage()); // 仅首次渲染执行
```
### 关键行为
> [!tip] 核心认知
> setState 是**异步合并**的 —— 你传给它的不是当前 state,而是基于"即将更新"的旧值计算新值。
| 场景 | 行为 |
|------|------|
| 对象/数组作为 state | **引用比较**——必须创建新对象/数组才会触发重渲染 |
| 连续多次 setState | React 18 自动合并为一次 re-render |
| 在非 React 代码中调用 | 使用 `startTransition` / `flushSync` 控制 |
```tsx
function BadCounter() {
const [count, setCount] = useState(0);
// ❌ 闭包陷阱:count 始终捕获定义时的 0
setTimeout(() => setCount(count + 1), 1000);
}
function GoodCounter() {
const [count, setCount] = useState(0);
// ✅ 使用 updater 函数,获取最新值
setTimeout(() => setCount(c => c + 1), 1000);
}
```
### 不可变更新模式
```tsx
const [list, setList] = useState([{ id: 1, done: false }]);
// ❌ 直接修改 —— 不会触发重渲染
list[0].done = true;
setList(list);
// ✅ 创建新引用 —— 正确触发更新
setList(prev => prev.map(item => item.id === 1 ? { ...item, done: true } : item));
```
> [!note] 为什么要有不可变性?
> React 通过**浅比较引用**来判断是否需要重渲染。如果原地修改了原有对象,引用地址不变,React 认为"没变化"就跳过了 DOM 更新。这正是 React 高效的原因之一。
---
## useEffect
### 基本模型
```tsx
useEffect(() => {
// 副作用逻辑
return () => { /* cleanup */ }; // 可选清理函数
}, [dependencies]); // 依赖数组
```
### 三种行为模式
```tsx
// 无依赖数组 → 每次渲染后执行(几乎不推荐)
useEffect(() => { console.log("every render"); });
// 空数组 [] → 仅挂载时执行一次
useEffect(() => {
console.log("mount once");
return () => console.log("unmount");
}, []);
// 有依赖项 → 任一依赖变化时重新执行
useEffect(() => {
const sub = api.subscribe(userId);
return () => sub.unsubscribe();
}, [userId]); // userId 变了 → 先 clean 旧的 → 再执行新的
```
### useEffect 生命周期时序
> [!abstract] useEffect 的生命周期可以用下面的时序图理解:
```mermaid
sequenceDiagram
participant R as React
participant E as Effect
participant C as Cleanup
R->>E: 渲染完成,执行 effect
Note over E: side effect 运行<br/>(数据请求、DOM 操作等)
R->>R: 再次渲染...
alt 依赖项变化
R->>C: 先执行上一次 effect 的 cleanup
R->>E: 执行新的 effect
else 依赖项未变化
R->>R: 跳过本次 effect
end
```
> [!question] 思考:cleanup 函数何时执行?
> - 组件卸载时
> - **下一次 effect 执行前**(前提是依赖项发生了变化)
> 这意味着你可以用同一个 effect 同时处理"订阅 → 取消 → 重新订阅"整个流程。
### 常见副作用类型
```tsx
// 数据获取
useEffect(() => {
let cancelled = false;
fetch("/api/data")
.then(res => res.json())
.then(data => { if (!cancelled) setData(data); });
return () => { cancelled = true; };
}, []);
// DOM 事件监听
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// 定时轮询(用 ref 避免把 fetchData 写入依赖数组导致频繁清理)
useEffect(() => {
const fetchData = () => {/* polling logic */};
let mounted = true;
const timer = setInterval(async () => {
if (!mounted) return;
try {
const res = await fetch("/api/data");
const json = await res.json();
setData(json);
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
}
}, 5000);
return () => { mounted = false; clearInterval(timer); };
}, []);
```
### 副作用分类指南
> [!summary] 哪些该放 useEffect?哪些不该?
| 应该用 useEffect | 不应该用 useEffect |
|------------------|---------------------|
| 数据获取、订阅、定时器 | JSX 渲染中的同步逻辑 |
| 操作 DOM(focus、测量) | 阻止表单提交、路由跳转 |
| 第三方库集成 | **事件处理函数体内**(直接用 onClick 即可) |
---
## useMemo & useCallback
这两个 Hook 都是**记忆化**工具,核心目标相同:**缓存计算结果,避免不必要的重新计算或子组件重渲染**。
### useMemo —— 缓存计算结果
```tsx
const sortedItems = useMemo(() => items.sort(comparator), [items]);
```
> [!important] useMemo 不等于"不用每次都算"
> `useMemo` 本身也有开销(缓存对比),它只是给开发者一个"提示 React:结果可以被复用"。**只有在计算昂贵或在渲染期间产生高成本时才有意义。**
### useCallback —— 缓存函数引用
```tsx
const handleClick = useCallback(
(id: string) => { deleteItem(id); },
[deleteItem] // deleteItem 变化时返回新函数
);
```
### 两者关系与选择
```mermaid
quadrantChart
title "useMemo vs useCallback 使用决策"
x-axis "少用" --> "常用"
y-axis "缓存计算" --> "缓存引用"
"useCallback": [0.7, 0.9]
"useMemo": [0.3, 0.9]
"纯计算,传给子组件": [0.7, 0.5]
"纯值,渲染中使用": [0.3, 0.5]
```
| Hook | 缓存什么 | 典型场景 |
|------|----------|----------|
| `useMemo` | 计算后的**值** | 列表排序、过滤、复杂运算 |
| `useCallback` | 函数**引用** | 传给 `React.memo` 包裹的子组件 |
### 三者联合:防止过度重渲染
```tsx
function Parent() {
const [text, setText] = useState("");
// 缓存 handler 引用 —— 避免 Child 因 props.fn 变化而重渲染
const handleSubmit = useCallback(
(e: React.FormEvent) => { alert(text); },
[text]
);
// 缓存 computed 值 —— 避免每次渲染都重新 sort/filter
const filteredUsers = useMemo(
() => users.filter(u => u.name.includes(text)),
[users, text]
);
return <Child data={filteredUsers} onSubmit={handleSubmit} />;
}
```
> [!example] 配合 React.memo 的效果
> ```tsx
> // Child 仅在 props 引用变化时才重渲染
> const Child = React.memo(({ data, onSubmit }) => (
> <div>{/* ... */}</div>
> ));
> ```
> 如果没有 `useCallback` 和 `useMemo`,即使 `data` 和 `onSubmit` 内容没变,每次父组件渲染都会生成新引用,导致 Child 无效重渲染。
---
## useContext
### 基本概念
Context 解决的是"跨层级传参"问题,无需逐层透传 props(又称 prop drilling)。
```tsx
const ThemeContext = createContext<"light" | "dark">("light");
function App() {
return (
<ThemeContext.Provider value="dark">
<Header /> {/* 不需要 Header 向 Button 传递 theme */}
<Main />
</ThemeContext.Provider>
);
}
function Button() {
const theme = useContext(ThemeContext); // 任意深度消费
return <button className={theme}>Click</button>;
}
```
### 最佳实践:封装自定义 Hook
```tsx
const ThemeContext = createContext<"light" | "dark">("light");
// ❌ 不要到处写 useContext(ThemeContext)
// ✅ 抽取为语义化 Hook
function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
return ctx;
}
```
### Context 的性能陷阱
> [!warning] Provider 的 value 一旦变化,**所有**消费该 Context 的组件都会重渲染
>
> 因为每次 `{ value: count }` 都是一个新对象,浅比较不等。
**解决方案:**
```tsx
// 方案1:拆分成多个小 Context
const UserContext = createContext(user);
const ThemeContext = createContext(theme);
// 方案2:用 useReducer 保持 dispatch 引用稳定
function App() {
const [state, dispatch] = useReducer(reducer, initial);
return <MyContext.Provider value={{ state, dispatch }}>
<Children />
</MyContext.Provider>;
}
```
> [!tip] 何时用 Context?
> - 适合"全局配置":主题、语言、用户信息
> - 不适合高频更新的值(频繁变化会导致大量重渲染)
> - 复杂状态管理考虑 Zustand / Jotai / Redux Toolkit
---
## useRef
### 两个用途
```tsx
// 用途1:持有可变值,修改不触发重渲染
const timerRef = useRef<number | null>(null);
const prevCountRef = useRef<string>("");
// 用途2:访问 DOM 元素
function FocusInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { inputRef.current?.focus(); }, []);
return <input ref={inputRef} />;
}
```
### ref vs state 对比
| 维度 | state | ref |
|------|-------|-----|
| 修改触发重渲染 | ✅ | ❌ |
| 跨渲染持久化 | ✅ | ✅ |
| 写入方式 | `setState(val)` | `ref.current = val` |
| 典型用途 | UI 驱动数据 | DOM 句柄 / 定时器 / 临时计数器 |
> [!note] 本质区别
> ref 的 `.current` 是一个普通的 JavaScript 属性,修改它就像改任何对象的属性一样,React 完全不知道。State 是 React 管理的响应式数据,修改后会告诉 React "请重新渲染"。
### 实用技巧:保存上一次的值
```tsx
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => { ref.current = value; });
return ref.current;
}
// 用法
function Example({ value }) {
const prev = usePrevious(value);
// prev !== undefined && prev !== value → 值变更了
}
```
---
## 自定义 Hook 实战
自定义 Hook 是 React 中最强大的抽象手段之一 —— 它是一个普通函数,名字以 `use` 开头,内部可以调用其他 Hook,从而实现**状态逻辑的跨组件复用**。
### 示例:自动失焦 Hook
```tsx
function useAutoFocus(delayMs: number = 300) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
const timer = setTimeout(() => ref.current?.focus(), delayMs);
return () => clearTimeout(timer);
}, [delayMs]);
return ref; // 暴露给组件绑定到 input
}
// 使用
function LoginForm() {
const usernameRef = useAutoFocus();
return <input ref={usernameRef} placeholder="Username" />;
}
```
### 示例:在线状态检测
```tsx
function useIsOnline() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
setOnline(navigator.onLine);
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;
}
```
### 自定义 Hook 设计原则
> [!summary] 好的自定义 Hook 应遵循以下原则
> 1. **单一职责** —— 每个 Hook 解决一个问题(如上述两个例子各自独立)
> 2. **命名清晰** —— `useXxx` 格式,读起来像动作:"监听网络"、"记住上次值"
> 3. **返回值明确** —— 返回需要的引用或状态,不要暴露过多内部细节
> 4. **可以组合** —— `useFetch` 内部可用 `useEffect` + `useRef`,外层可再用 `useAutoFocus`
---
## Hook 调试技巧
> [!tip] React DevTools 浏览器扩展
> 安装后可以:
> - 查看每个组件的 Hook 值和更新次数
> - 定位"为什么这个组件在重渲染"
> - 记录渲染时间,发现性能瓶颈
---
## 关联笔记
- [[3. 组件篇/01-组件基础.md]]
- [[3. 组件篇/03-性能优化.md]]
- [[config/REACT/react-best-practices.md]]
@@ -0,0 +1,421 @@
---
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
@@ -0,0 +1,463 @@
---
tags: [React, Custom Hooks, Frontend]
create time: 2026-04-29 22:30
---
# 自定义 Hooks
## 概述
自定义 Hook 是 React 中最强大的逻辑复用机制。它以函数形式封装可复用的副作用逻辑和状态,通过 Hook 组合实现"代码即组件"的哲学。理解如何设计良好的自定义 Hook,是从中级迈向高级 React 开发者的分水岭。
## 命名规范与设计原则
> [!question] 思考
> 为什么普通函数不能直接持有 React state?Hook 和普通函数的根本区别是什么?
> (提示:从 React 的内部执行上下文和 Fiber 架构角度理解)
### 必须以 `use` 开头
```tsx
// ✅ 正确
function useFetch(url: string) { ... }
function useLocalStorage<T>(key: string) { ... }
function useWindowSize() { ... }
// ❌ 错误:不遵循 use 前缀约定,React 无法识别为 Hook
function fetchWithRetry(url: string) { ... }
```
> [!warning] 前置依赖
> 所有自定义 Hook 示例默认已导入:
> ```tsx
> import { useState, useEffect, useCallback, useMemo, useRef, RefObject } from "react";
> ```
### 规则与常规函数不同
```mermaid
graph LR
A["自定义 Hook"] --> B["可以在任何条件/循环内调用"]
A --> C["可以嵌套调用(Hook 中再调其他 Hook)"]
A --> D["⚠️ 但必须在组件顶层或 Hook 中调用"]
D1["不能在回调/普通函数内调用"] --> D
E["普通函数"] --> F["无执行顺序约束"]
F --> G["不能直接持有 React state/effect"]
style A fill:#61DAFB,color:#000
```
## 常见 Hook 模式
### 1. useFetch — 数据获取
```tsx
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
function useFetch<T>(url: string, options?: RequestInit): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// ✅ 使用 useRef 跟踪组件挂载状态,避免卸载后 setState
const mountedRef = useRef(true);
const execute = useCallback(async () => {
if (!mountedRef.current) return;
setLoading(true);
setError(null);
try {
const res = await fetch(url, options ?? {});
if (!res.ok) throw new Error(res.statusText);
const json: T = await res.json();
if (mountedRef.current) setData(json);
} catch (err) {
if (mountedRef.current) setError(err as Error);
} finally {
if (mountedRef.current) setLoading(false);
}
}, [url, JSON.stringify(options)]);
useEffect(() => {
execute();
return () => { mountedRef.current = false; };
}, [execute]);
return { data, loading, error, refetch: execute };
}
```
### 2. useDebounce — 防抖
```tsx
interface DebounceResult<T> {
debouncedValue: T;
cancel: () => void;
}
function useDebounce<T>(value: T, delay: number): DebounceResult<T> {
const [debouncedValue, setDebouncedValue] = useState(value);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
timerRef.current = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timerRef.current);
}, [value, delay]);
// ✅ 提供 cancel,外部可在需要时主动取消待触发的回调
const cancel = useCallback(() => clearTimeout(timerRef.current), []);
return { debouncedValue, cancel };
}
// 使用
function SearchInput() {
const [query, setQuery] = useState("");
const { debouncedValue } = useDebounce(query, 300);
// 用 debouncedValue 发起 API 请求 —— 只在用户停止输入 300ms 后触发
useEffect(() => { fetchData(debouncedValue); }, [debouncedValue]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
```
### 3. useLocalStorage — 持久化状态
```tsx
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
// ✅ SSR 安全:检查 window 是否存在
if (typeof window === "undefined") return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value: T | ((prev: T) => T)) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
// ✅ SSR 安全
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
};
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) {
setStoredValue(JSON.parse(e.newValue));
}
};
// ✅ SSR 安全
if (typeof window !== "undefined") {
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}
}, [key]);
return [storedValue, setValue];
}
```
### 4. useIntersectionObserver — 视口检测
```tsx
function useIntersectionObserver(
options?: IntersectionObserverInit
): [RefObject<HTMLDivElement | null>, boolean] {
const [isVisible, setIsVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// ✅ 用 useMemo 缓存 options,避免每次渲染新建对象导致 observer 重建
const memoizedOptions = useMemo(() => ({
root: null,
rootMargin: "0px",
threshold: 0.1,
...options,
}), [JSON.stringify(options)]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => {
setIsVisible(entry.isIntersecting);
}, memoizedOptions);
observer.observe(el);
return () => observer.disconnect();
}, [memoizedOptions]);
return [ref, isVisible];
}
```
### 5. useMediaQuery — 媒体查询
```tsx
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
media.addEventListener("change", handler);
return () => media.removeEventListener("change", handler);
}, [query]);
return matches;
}
// 使用
const isMobile = useMediaQuery("(max-width: 768px)");
```
### 6. usePrevious — 记录上一次值
```tsx
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>();
// ✅ useEffect 在渲染完成后才执行,恰好获取"上一轮"的值
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// 使用:判断值是否发生变化
function Component({ count }) {
const prevCount = usePrevious(count);
return <p>{count === prevCount ? "不变" : "已变化!"}</p>;
}
```
### 7. useBoolean — 布尔状态简化器
```tsx
function useBoolean(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
// 使用:替代手动写 { checked, setChecked }
function ToggleButton() {
const { value: on, toggle } = useBoolean();
return <button onClick={toggle}>{on ? "ON" : "OFF"}</button>;
}
```
## 常见陷阱与避坑
> [!danger] Hook 设计的 5 个经典陷阱
> 以下是在生产环境中高频踩中的坑,务必警惕。
### 陷阱 1:闭包陷阱(Stale Closure)
```tsx
// ❌ 问题:count 在 useCallback 创建时被捕获,永远是初始值
function useCounter() {
const [count, setCount] = useState(0);
const double = useCallback(() => {
console.log(count); // 始终是 0!
setCount(count * 2);
}, []); // 空依赖数组 → 捕获了初始状态
}
// ✅ 解法 1:使用函数式 setState
const double = useCallback(() => {
setCount(c => c * 2); // 读取最新值
}, []);
// ✅ 解法 2:将 count 加入依赖
// const double = useCallback(() => {...}, [count]);
```
### 陷阱 2:无限循环
```tsx
// ❌ 问题:每次渲染都创建新对象,导致 useEffect 无限触发
function BadComponent({ data }) {
const [state, setState] = useState([]);
useEffect(() => {
setState([{ items: data }]); // 新数组 = 新引用
}, [{ items: data }]); // ← 每次都是新对象,永远不等价
}
// ✅ 解法:正确声明依赖项
useEffect(() => {
setState(prev => (prev[0]?.items === data ? prev : [{ items: data }]));
}, [data]);
```
### 陷阱 3:异步操作缺少清理
```tsx
// ❌ 组件卸载后仍执行 setState → React 警告
useEffect(() => {
fetch("/api").then(res => setData(res.data)); // 卸载后回调仍会执行
}, []);
// ✅ 使用 AbortController 取消请求
useEffect(() => {
const controller = new AbortController();
fetch("/api", { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => { if (err.name !== "AbortError") throw err; });
return () => controller.abort();
}, []);
```
### 陷阱 4:过度封装
```tsx
// ❌ 为简单逻辑造 Hook,反而增加复杂度
function useToggle(initial = false) {
const [val, setVal] = useState(initial);
return [val, () => setVal(v => !v), () => setVal(true), () => setVal(false)];
// 返回值太长,调用方难以理解每个位置的含义
}
// ✅ 直接内联或提取有意义的语义化 Hook
const [open, setOpen] = useState(false);
<button onClick={() => setOpen(!open)}>...</button>
```
### 陷阱 5:副作用竞态(Race Condition)
```tsx
// ❌ 快速切换搜索词时,旧请求可能晚于新请求返回
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
searchAPI(query).then(setResults);
}, [query]);
// ✅ 用 ref + 版本号追踪"当前请求"
function useDebouncedSearch<T>(query: string, delay = 300): T | null {
const [result, setResult] = useState<T | null>(null);
const requestIdRef = useRef(0);
useEffect(() => {
const timer = setTimeout(async () => {
const thisId = ++requestIdRef.current;
const data = await searchAPI(query);
if (thisId === requestIdRef.current) {
setResult(data);
}
}, delay);
return () => clearTimeout(timer);
}, [query, delay]);
return result;
}
```
> [!tip] 自检清单
> - Hook 的依赖数组是否包含了所有引用的外部变量?
> - 异步操作中是否有清理机制防止"僵尸回调"?
> - Hook 是否在条件语句中嵌套了?(违反 Rules of Hooks)
> - Hook 的返回值类型是否清晰?优先用 interface/type 标注
## 组合模式与 HOC 的对比
```mermaid
flowchart LR
subgraph Composition["Hook 组合(推荐)"]
C1["useBoolean"] --> C3["useModal"]
C2["useClickOutside"] --> C3
C3 --> C4["useForm"]
C4 --> C5["DashboardPage"]
style C3 fill:#61DAFB,color:#000
style C5 fill:#4FC08D,color:#fff
end
subgraph HOC["HOC 包装(不推荐)"]
H1[Component] --> H2[HOC1]
H2 --> H3[HOC2]
H3 --> H4[HOC3]
style H2 fill:#F5A87D,color:#000
style H3 fill:#F5A87D,color:#000
style H4 fill:#F5A87D,color:#000
end
```
### 实战:Hook 层层组合
```tsx
// Layer 1: 基础 Hook
function useBoolean(initial = false) { ... }
function useClickOutside(ref, handler) { ... }
// Layer 2: 组合基础 Hook → 业务 Hook
function useModal() {
const { value: isOpen, toggle, setFalse: close } = useBoolean();
const overlayRef = useRef<HTMLDivElement>(null);
// Hook 中可以安全调用其他 Hook
useClickOutside(overlayRef, close);
return { isOpen, open: toggle, close, overlayRef };
}
// Layer 3: 业务页面
function SettingsPanel() {
const { isOpen, open, close, overlayRef } = useModal();
return (
<div ref={overlayRef}>
<button onClick={open}>{isOpen ? "关闭设置" : "打开设置"}</button>
{isOpen && <SettingsForm onClose={close} />}
</div>
);
}
```
> [!tip] Hook 设计的 S.O.L.I.D 原则
> - **Single Responsibility**:一个 Hook 只做一件事(如 useFetch 只负责 fetch)
> - **Open/Closed**:新需求加新 Hook,而非修改已有 Hook
> - **Interface Segregation**:返回值尽量精确,不过度暴露内部细节
> - **Dependency Inversion**:Hook 应依赖抽象(接口),而非具体实现
## 抽象层级参考
```tsx
// Level 1: 基础 Hook(操作层面)
function useClickOutside(ref: RefObject<HTMLElement>, handler: () => void) {}
function useEventListener(target: any, event: string, fn: Function) {}
// Level 2: 业务 Hook(场景层面)
function useModal() { return { isOpen, open, close, overlayRef: useClickOutside(...) } }
function useForm(initialValues: FormValues) { return { values, errors, submit, reset } }
// Level 3: 领域 Hook(领域层面)
function usePermission(role: Role) {} // 权限检查
function usePagination(state: PaginationState) {} // 分页
```
## 关联笔记
- [[05-核心 Hooks]] — useState、useEffect 等基础 Hook,是理解自定义 Hook 的前置知识
- [[06-性能优化 Hooks]] — useMemo、useCallback 在自定义 Hook 中的依赖优化实践