Files
cs-note/hhs/REACT/2. Hooks 篇/05-核心 Hooks.md
T

494 lines
14 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
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]]