--- 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(); // Hook #2 // 每次渲染都按这个顺序取状态,不能颠倒或删除 } ``` --- ## useState ### 基础用法 ```tsx const [state, setState] = useState(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 运行
(数据请求、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 ; } ``` > [!example] 配合 React.memo 的效果 > ```tsx > // Child 仅在 props 引用变化时才重渲染 > const Child = React.memo(({ data, onSubmit }) => ( >
{/* ... */}
> )); > ``` > 如果没有 `useCallback` 和 `useMemo`,即使 `data` 和 `onSubmit` 内容没变,每次父组件渲染都会生成新引用,导致 Child 无效重渲染。 --- ## useContext ### 基本概念 Context 解决的是"跨层级传参"问题,无需逐层透传 props(又称 prop drilling)。 ```tsx const ThemeContext = createContext<"light" | "dark">("light"); function App() { return (
{/* 不需要 Header 向 Button 传递 theme */}
); } function Button() { const theme = useContext(ThemeContext); // 任意深度消费 return ; } ``` ### 最佳实践:封装自定义 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 ; } ``` > [!tip] 何时用 Context? > - 适合"全局配置":主题、语言、用户信息 > - 不适合高频更新的值(频繁变化会导致大量重渲染) > - 复杂状态管理考虑 Zustand / Jotai / Redux Toolkit --- ## useRef ### 两个用途 ```tsx // 用途1:持有可变值,修改不触发重渲染 const timerRef = useRef(null); const prevCountRef = useRef(""); // 用途2:访问 DOM 元素 function FocusInput() { const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); return ; } ``` ### ref vs state 对比 | 维度 | state | ref | |------|-------|-----| | 修改触发重渲染 | ✅ | ❌ | | 跨渲染持久化 | ✅ | ✅ | | 写入方式 | `setState(val)` | `ref.current = val` | | 典型用途 | UI 驱动数据 | DOM 句柄 / 定时器 / 临时计数器 | > [!note] 本质区别 > ref 的 `.current` 是一个普通的 JavaScript 属性,修改它就像改任何对象的属性一样,React 完全不知道。State 是 React 管理的响应式数据,修改后会告诉 React "请重新渲染"。 ### 实用技巧:保存上一次的值 ```tsx function usePrevious(value: T): T | undefined { const ref = useRef(); 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(null); useEffect(() => { const timer = setTimeout(() => ref.current?.focus(), delayMs); return () => clearTimeout(timer); }, [delayMs]); return ref; // 暴露给组件绑定到 input } // 使用 function LoginForm() { const usernameRef = useAutoFocus(); return ; } ``` ### 示例:在线状态检测 ```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]]