Files
cs-note/hhs/REACT/2. Hooks 篇/05-核心 Hooks.md
T
2026-05-24 11:42:38 +08:00

494 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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()); // 仅首次渲染执行
```
### 闭包陷阱(Closure Trap)
> [!warning] 高频踩坑点:React 每次渲染都创建全新的作用域和变量副本
```tsx
const [count, setCount] = useState(0);
setTimeout(() => setCount(count + 1), 1000); // ❌ count 始终为首次渲染时的快照值
```
**为什么?** React 的 `setCount` 不是异步函数,而是 **闭包捕获了渲染时的变量快照** —— 第一次渲染后,`handleClick` 里的 `count` 就被锁死在 `0` 上了。
> [!tip] 如何修复?
> 有两种思路:让 React 自己提供最新状态(updater),或者用 ref 持有一个始终更新的引用。具体方案与对比见 → [[05-核心 Hooks/01-闭包陷阱|详解]]
### 关键行为
> [!tip] 核心认知
> setState 是**异步合并**的 —— 你传给它的不是当前 state,而是基于"即将更新"的旧值计算新值。
| 场景 | 行为 |
|------|------|
| 对象/数组作为 state | **引用比较**——必须创建新对象/数组才会触发重渲染 |
| 连续多次 setState | React 18 自动合并为一次 re-render |
| 在非 React 代码中调用 | 使用 `startTransition` / `flushSync` 控制 |
### 不可变更新模式
```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]]