Hooks 深度剖析:状态、副作用与并发模式 / Hooks Deep Dive: State, Effects, and Concurrency
📅 创建时间:2026-07-28 🏷️ 标签:#React #Hooks #useState #useEffect #useReducer #useRef #useMemo #React19 📚 前置知识:[[01-react-components-and-rendering]]
📋 本章目标
- 理解 Hook 底层存储机制(链表)以及"只在顶层调用"的真正原因
- 掌握 useState 惰性初始化、函数式更新和不可变替换三件套
- 能用 useReducer + 判别联合构建复杂状态机,并与 Context 组合做轻量状态管理
- 区分 useRef 的三种使用模式:DOM 引用、可变值容器、实例变量
- 把 useEffect 从"生命周期思维"转为"同步外部系统"思维,并识别不需要 Effect 的场景
- 理解 useEffect / useLayoutEffect / useInsertionEffect 的执行时序差异和各自适用场景
- 掌握 useMemo / useCallback 的性能权衡原则(不是默认就加)
- 理解 useTransition / useDeferredValue 的并发更新机制和 Suspense 集成
- 熟悉 React 19 四个新 Hook:use()、useActionState、useFormStatus、useOptimistic
- 掌握自定义 Hook 的命名约定、返回值设计和稳定引用策略
第1部分:Hook 调用规则 —— 为什么"只在顶层调用"
1.1 链表:Hook 的底层存储模型
React 在内部用单向链表保存每个组件实例的所有 Hook 状态。组件首次渲染时,每遇到一个 Hook 调用就在链表末尾追加一个节点;后续渲染按相同顺序遍历链表,将节点数据读出。这意味着 React 完全依赖调用顺序来匹配 Hook 与其状态。
┌─────────────────────────────────────────────────────────────┐
│ 组件 Fiber 的 Hook 链表 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 组件函数执行: │
│ │
│ useState('a') ──→ useState('b') ──→ useEffect(...) │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────────┐ │
│ │ state: │ │ state: │ │ effect: │ │
│ │ 'a' │ │ 'b' │ │ callback │ │
│ │ next ───┼────→│ next ───┼─────→│ next ──→ null│ │
│ └─────────┘ └─────────┘ └─────────────┘ │
│ │
│ 下一次渲染: 按相同顺序遍历链表,取回各自的状态 │
│ 如果调用顺序变了(条件/循环中调用),节点对不上 → 💥 │
│ │
└─────────────────────────────────────────────────────────────┘正是这个链表设计决定了第一条铁律:Hook 必须在组件或自定义 Hook 的顶层调用,不能放进条件、循环或普通回调。 否则调用顺序在两次渲染间变化,链表节点错位,可能导致状态串位甚至崩溃。
1.2 两条铁律的深层原因
| 规则 | 表面理解 | 深层原因 |
|---|---|---|
| 只在顶层调用 | 不要放在 if / for 里 | 链表靠调用顺序索引,顺序变了 React 无法匹配 |
| 只在 React 函数中调用 | 不要在普通 JS 函数里调 | Hook 的状态存储在 Fiber 节点上,普通函数没有对应的 Fiber |
React 通过 ESLint 插件 eslint-plugin-react-hooks 在编译期检查这两条规则。但理解原理比依赖 lint 更重要——当你写自定义 Hook 时,你写的 Hook 调用了 useState/useEffect,这意味着你的自定义 Hook 也必须在顶层调用。
1.3 条件逻辑的正确放置位置
function SearchPage({ enabled }: { enabled: boolean }) {
const [query, setQuery] = useState('')
// ✅ 条件放进 Effect 内部,而不是包住 Hook 调用
useEffect(() => {
if (!enabled) return // 提前返回 = 跳过同步
document.title = query ? `搜索:${query}` : '搜索'
}, [enabled, query])
return (/* ... */)
}第2部分:useState 深度解析
2.1 惰性初始化:避免重复昂贵计算
useState(initialValue) 中的 initialValue 在每次渲染时都会执行(只是除首次外结果被丢弃)。如果初始值需要从 localStorage 读取、需要复杂计算,应该用惰性初始化——传入一个函数:
// ❌ 每次渲染都读取 localStorage,尽管只有首次用到
const [todos, setTodos] = useState(
JSON.parse(localStorage.getItem('todos') ?? '[]')
)
// ✅ 传入函数:React 只在首次渲染时调用它
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos')
return saved ? JSON.parse(saved) : []
})惰性初始化的函数必须是纯函数——不能有副作用,React 在严格模式下可能调用两次以检测纯度。
2.2 函数式更新:避免陈旧闭包
当新状态依赖旧状态时,直接传值可能读到过期的值:
// ❌ 风险:如果短时间内多次 setCount,每次拿到的 prev 可能都是旧的
function handleTriple() {
setCount(count + 1)
setCount(count + 1) // 这个 count 和上一行拿到的是同一个值
setCount(count + 1) // 三行都用的是快照值 → 最终只 +1
}
// ✅ 函数式更新:React 把最新的 pending state 传给你
function handleTriple() {
setCount(prev => prev + 1)
setCount(prev => prev + 1)
setCount(prev => prev + 1) // 每次拿到上一步的结果 → 最终 +3
}函数式更新在以下场景特别关键:
- 连续多次 setState(批量更新期间)
- useEffect 依赖数组为空但需要 state 的最新值
- 用 useCallback 包裹的回调中引用了 state
2.3 对象和数组的不可变替换
React 用 Object.is() 做浅比较。直接 mutate 对象/数组不会触发重渲染:
// ❌ 直接 push:引用没变 → React 认为没变化 → 不重渲染
todos.push(newTodo)
setTodos(todos)
// ✅ 创建新数组:引用变了 → React 检测到变化 → 重渲染
setTodos([...todos, newTodo])
// ✅ 对象同理:展开 + 覆盖
setUser(prev => ({ ...prev, name: 'Alice' }))
// ✅ 深层嵌套考虑 useImmer 或结构化克隆
setConfig(prev => ({
...prev,
theme: { ...prev.theme, dark: !prev.theme.dark }
}))第3部分:useReducer 完整实战
3.1 为什么需要 Reducer
当多个事件以不同方式修改同一块状态,或状态更新逻辑复杂到难以用若干个 useState 表达时,useReducer 将状态转移逻辑集中到一个纯函数中:
┌─────────────────────────────────────────────────────────────┐
│ useReducer 数据流 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户操作 │
│ ┌──────┐ dispatch({ type: 'added', title: '...' }) │
│ │ 点击 │ ─────────────────────────────────────┐ │
│ └──────┘ │ │
│ ▼ │
│ 网络回调 ┌──────────┐ │
│ ┌──────┐ dispatch({ type: 'loaded' }) │ │ │
│ │ 响应 │ ──────────────────────────────→ │ Reducer │ │
│ └──────┘ │ (纯函数) │ │
│ │ │
│ 定时器 │ │
│ ┌──────┐ dispatch({ type: 'tick' }) │ │
│ │ 计时 │ ──────────────────────────────────┘ │
│ └──────┘ │ │
│ ▼ │
│ 新 State │
│ │
│ 关键:所有修改都通过 dispatch → reducer,单向流动 │
│ │
└─────────────────────────────────────────────────────────────┘3.2 用判别联合设计 Action 类型
TypeScript 的 discriminated union 让每种 action 携带不同的 payload,类型安全:
type Todo = { id: string; title: string; done: boolean }
type Action =
| { type: 'added'; title: string }
| { type: 'removed'; id: string }
| { type: 'toggled'; id: string }
| { type: 'edited'; id: string; title: string }
| { type: 'cleared_completed' }
function todoReducer(state: Todo[], action: Action): Todo[] {
switch (action.type) {
case 'added':
return [...state, { id: crypto.randomUUID(), title: action.title, done: false }]
case 'removed':
return state.filter(t => t.id !== action.id)
case 'toggled':
return state.map(t => t.id === action.id ? { ...t, done: !t.done } : t)
case 'edited':
return state.map(t => t.id === action.id ? { ...t, title: action.title } : t)
case 'cleared_completed':
return state.filter(t => !t.done)
}
}Reducer 必须纯净。
crypto.randomUUID()这类副作用需放在 dispatch 之前生成好 ID,再写入 action。
3.3 与 Immer 集成
深层嵌套状态的手动展开很繁琐,Immer 的 produce 让你用 mutable 写法生成 immutable 结果:
import { produce } from 'immer'
type State = { user: { profile: { name: string; bio: string } }; posts: Post[] }
function reducer(state: State, action: Action): State {
return produce(state, draft => {
switch (action.type) {
case 'update_bio':
draft.user.profile.bio = action.bio // 像 mutable 一样写
break
case 'rename':
draft.user.profile.name = action.name
break
}
})
}Immer 也提供 useImmerReducer 快捷封装,省去手动调用 produce。
3.4 与 Context 组合做轻量状态管理
对于中型应用,useReducer + Context 是 Redux/Zustand 的轻量替代:
// 分别导出 state 和 dispatch,避免不必要渲染
const TodosContext = createContext<Todo[]>([])
const DispatchContext = createContext<React.Dispatch<Action>>(() => {})
export function TodosProvider({ children }: { children: React.ReactNode }) {
const [todos, dispatch] = useReducer(todoReducer, [])
return (
<DispatchContext.Provider value={dispatch}>
<TodosContext.Provider value={todos}>
{children}
</TodosContext.Provider>
</DispatchContext.Provider>
)
}
// 消费者只需读取自己需要的 Context
export function useTodos() { return useContext(TodosContext) }
export function useDispatch() { return useContext(DispatchContext) }为什么拆成两个 Context? 只 dispatch 的组件不需要在 todos 变化时重渲染。按职责拆分 Context 是以最小粒度订阅的核心技巧。
3.5 useReducer vs useState 选择标准
┌─────────────────────────────────────────────────────────────┐
│ useReducer vs useState 选择决策树 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 你的状态是: │
│ │
│ ┌──────────────────┐ │
│ │ 单个简单值 │ ───→ useState ✅ │
│ │ (字符串/数字/bool) │ │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ 多个独立值 │ ───→ 多个 useState ✅ │
│ │ (互不依赖) │ │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ 多个值互相关联 │ │
│ │ 更新逻辑复杂 │ ───→ useReducer ✅ │
│ │ 下一个状态依赖 │ │
│ │ 上一个状态 │ │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ 需要测试状态转移 │ ───→ useReducer ✅ │
│ │ reducer 可独立 │ (reducer 是纯函数,易于单测) │
│ │ 于组件测试 │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘第4部分:useRef 的三种模式
4.1 模式一:DOM 引用
最经典的用法 —— 拿到原生 DOM 节点的直接引用:
function AutoFocusInput() {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus() // 挂载后自动聚焦
}, [])
return <input ref={inputRef} placeholder="自动聚焦" />
}4.2 模式二:可变值容器(不触发渲染)
useRef 创建的 { current: ... } 对象在组件整个生命周期中保持同一个引用。修改 .current 不会触发重渲染,适合存储不需要驱动 UI 的数据:
function Timer() {
const intervalRef = useRef<number | null>(null)
const [count, setCount] = useState(0)
const start = () => {
if (intervalRef.current !== null) return // 防止重复启动
intervalRef.current = window.setInterval(() => {
setCount(c => c + 1) // 用 useState 驱动 UI
}, 1000)
}
const stop = () => {
if (intervalRef.current === null) return
clearInterval(intervalRef.current)
intervalRef.current = null
}
// 组件卸载时清理
useEffect(() => () => { stop() }, [])
return (/* ... */)
}这里 intervalRef 存的是 timer ID——它不需要显示在 UI 上,但需要在 start 和 stop 之间共享。如果用 useState 存 ID,每次修改都触发不必要的重渲染。
4.3 模式三:实例变量模式(存储 previous value)
追踪上一次渲染时的值,常用于比较变化或实现动画:
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>()
useEffect(() => {
ref.current = value // Effect 在渲染后执行,存的是"上一轮"的值
})
return ref.current // 返回的是还没更新的旧值
}
// 使用
function Chat({ roomId }: { roomId: string }) {
const prevRoomId = usePrevious(roomId)
useEffect(() => {
if (prevRoomId !== roomId) {
console.log(`切换房间: ${prevRoomId} → ${roomId}`)
}
}, [roomId, prevRoomId])
// ...
}这个模式的原理很精妙:ref.current = value 在 Effect 中执行,发生在 DOM 更新之后。组件函数体中 return ref.current 在渲染阶段执行,此时 ref 里存的是上一轮 Effect 写入的值。
4.4 forwardRef + useImperativeHandle
当需要向父组件暴露子组件的 DOM 或方法时:
interface ImperativeHandle {
focus: () => void
scrollToTop: () => void
}
const FancyInput = forwardRef<ImperativeHandle, { label: string }>(
function FancyInput({ label }, ref) {
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
scrollToTop: () => containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }),
}), []) // 依赖数组为空:方法引用稳定不变
return (
<div ref={containerRef}>
<label>{label}</label>
<input ref={inputRef} />
</div>
)
}
)
// 父组件
function Parent() {
const fancyRef = useRef<ImperativeHandle>(null)
return (
<>
<FancyInput ref={fancyRef} label="姓名" />
<button onClick={() => fancyRef.current?.focus()}>聚焦</button>
</>
)
}useImperativeHandle 让你控制暴露的接口,而不是把整个 DOM 节点暴露出去。这在封装第三方组件库或复杂 UI 逻辑时尤其有用。
第5部分:useEffect 的正确心智模型
5.1 核心思想:同步外部系统,不是"生命周期"
React 文档强调:Effect 是用来让组件与外部系统保持同步的。外部系统包括:
- 浏览器 API(
document.title、localStorage、history) - 第三方库(图表、地图 SDK、视频播放器)
- 网络连接(WebSocket、EventSource)
- 全局事件订阅
┌─────────────────────────────────────────────────────────────┐
│ Effect 同步模型 │
├─────────────────────────────────────────────────────────────┤
│ │
│ React 状态 ──────────────────────────→ 外部系统 │
│ (state, props) 同步方向 (DOM 属性, │
│ │ 网络连接, │
│ │ 第三方实例) │
│ │ │
│ ← 外部系统变化通过事件/回调 → setState → 触发同步 │
│ │
│ useEffect(() => { │
│ /* 建立同步 */ │
│ return () => { /* 清理上一轮同步 */ } │
│ }, [/* 依赖:当这些值变化时重新同步 */]) │
│ │
└─────────────────────────────────────────────────────────────┘5.2 清理函数:防止内存泄漏和状态错乱
每次 Effect 重新执行前(依赖变化或组件卸载),React 会先用旧依赖执行清理函数,再用新依赖执行 setup:
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<string[]>([])
useEffect(() => {
const ws = new WebSocket(`wss://chat.example.com/${roomId}`)
ws.addEventListener('message', (event) => {
setMessages(prev => [...prev, event.data])
})
return () => {
ws.close() // 切换到其他房间时关闭旧连接
}
}, [roomId])
// ...
}渲染 roomId='A' → 连接 A
渲染 roomId='B' → 先 close A → 再连接 B (由清理函数 + 新 setup 完成)
组件卸载 → close B5.3 依赖数组的工作原理
React 在每次渲染后比较依赖数组中的每个值(使用 Object.is())。只要有一个值变了,就触发 Effect 重新执行。
常见误区:
// ❌ 空依赖数组 + 内部引用 state → 陈旧闭包
useEffect(() => {
const timer = setInterval(() => {
console.log(count) // count 永远是首次渲染的值
}, 1000)
return () => clearInterval(timer)
}, [])
// ✅ 方案1:把 count 加入依赖
useEffect(() => {
const timer = setInterval(() => { console.log(count) }, 1000)
return () => clearInterval(timer)
}, [count]) // 但每秒重建 interval,不优雅
// ✅ 方案2:函数式更新,不依赖外部 count
useEffect(() => {
const timer = setInterval(() => {
setCount(c => console.log(c) || c + 1)
}, 1000)
return () => clearInterval(timer)
}, []) // 依赖数组为空,interval 一次创建5.4 严格模式下的双重调用
React 18+ 在开发环境的 StrictMode 下会挂载 → 卸载 → 重新挂载每个组件。这会导致 Effect 执行两次(setup → cleanup → setup)。这不是 bug,而是一个压力测试,帮你提前发现缺少清理逻辑的 Effect。
如果你的 Effect 在 StrictMode 下异常(如重复连接 WebSocket),说明清理函数不够完善。
5.5 何时不该用 Effect
React 官方列出了几种不需要 Effect 的场景,这是从"生命周期思维"转变的关键:
┌─────────────────────────────────────────────────────────────┐
│ 不需要 Effect 的场景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 场景1:从 props/state 计算派生值 │
│ ❌ const [fullName, setFullName] = useState('') │
│ useEffect(() => { │
│ setFullName(firstName + ' ' + lastName) │
│ }, [firstName, lastName]) │
│ ✅ const fullName = firstName + ' ' + lastName │
│ 直接在渲染中计算,不需要额外的状态 + Effect │
│ │
│ 场景2:响应用户事件 │
│ ❌ useEffect(() => { /* 发请求 */ }, [submitted]) │
│ ✅ function handleSubmit() { /* 发请求 */ } │
│ 用户点击是事件,不是状态同步,应在事件处理器中处理 │
│ │
│ 场景3:重置子组件树 │
│ ❌ useEffect(() => { resetForm() }, [userId]) │
│ ✅ <ProfileForm key={userId} /> │
│ key 变化时 React 会卸载旧组件、挂载新组件,自然达到重置 │
│ │
│ 场景4:缓存昂贵计算 │
│ ❌ useEffect(() => { setResult(heavyCalc(data)) }, [data]) │
│ ✅ const result = useMemo(() => heavyCalc(data), [data]) │
│ useMemo 在渲染期间计算,不会造成额外的提交 + 渲染 │
│ │
│ 场景5:数据获取 │
│ ❌ useEffect 里手写 fetch + loading + error │
│ ✅ 使用 TanStack Query / SWR / RTK Query │
│ 它们处理缓存、去重、重试、竞态、后台刷新等 │
│ │
└─────────────────────────────────────────────────────────────┘5.6 竞态处理
手写请求 Effect 时,必须处理旧请求晚于新请求返回的问题:
useEffect(() => {
let ignore = false // 闭包标记
async function fetchUsers() {
const res = await fetch(`/api/users?q=${query}`)
const data = await res.json()
if (!ignore) setUsers(data) // 只有最新请求的结果才被采用
}
fetchUsers()
return () => { ignore = true } // 下一次 Effect 执行前标记为忽略
}, [query])使用 AbortController 是更彻底的方案(可以从网络层取消请求),但布尔标记在大多数场景下已经足够。
第6部分:useLayoutEffect vs useEffect vs useInsertionEffect
6.1 三者的执行时序
┌─────────────────────────────────────────────────────────────┐
│ 渲染 → 提交 流程中的 Hook 执行点 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 渲染阶段 (Render Phase) │ │
│ │ React 调用组件函数,计算 Virtual DOM │ │
│ │ useInsertionEffect 的 setup 在此排队 │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DOM 变更 (Mutation) │ │
│ │ React 将 Virtual DOM 写进真实 DOM │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ useInsertionEffect 执行 │ │
│ │ 在 DOM 变更之后、layout effect 之前 │ │
│ │ 用途:CSS-in-JS 库插入样式规则 │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ useLayoutEffect 执行 │ │
│ │ 在浏览器绘制之前同步执行 │ │
│ │ 用途:读取 DOM 布局 + 同步修改,避免闪烁 │ │
│ │ ⚠️ 阻塞绘制,长任务会让用户看到白屏 │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 浏览器绘制 (Paint) │ │
│ │ 像素呈现在屏幕上 │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ useEffect 执行 │ │
│ │ 在浏览器绘制之后异步执行 │ │
│ │ 用途:大多数同步逻辑、网络请求、订阅 │ │
│ │ ✅ 不阻塞绘制,用户看到 UI 后才执行 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘6.2 各自适用场景
| Hook | 何时用 | 典型场景 |
|---|---|---|
useEffect | 绝大多数情况 | 数据获取、订阅、手动改 DOM 属性(不影响布局)、日志 |
useLayoutEffect | 需要在绘制前同步测量/修改 DOM | 测量元素尺寸后定位 tooltip、同步滚动位置、避免闪烁的 DOM 操作 |
useInsertionEffect | CSS-in-JS 库作者 | 在 DOM 变更后、布局计算前动态注入 <style> 规则 |
// useLayoutEffect 经典场景:tooltip 定位
function Tooltip({ targetRef }: { targetRef: RefObject<HTMLElement> }) {
const tooltipRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState({ x: 0, y: 0 })
useLayoutEffect(() => {
const target = targetRef.current
const tooltip = tooltipRef.current
if (!target || !tooltip) return
const targetRect = target.getBoundingClientRect()
// 测量 + 同步设置位置,在绘制前完成 → 用户不会看到 tooltip"跳一下"
setPos({
x: targetRect.left,
y: targetRect.bottom + 8,
})
}, [targetRef])
return <div ref={tooltipRef} style={{ position: 'fixed', left: pos.x, top: pos.y }}>...</div>
}经验法则:默认用
useEffect。只有当 UI 出现可感知的闪烁或布局跳动,再考虑改成useLayoutEffect。
第7部分:useMemo 和 useCallback 的正确姿势
7.1 核心原则:不是"默认就加"
useMemo 和 useCallback 本身有开销:它们创建闭包、维护依赖数组、在每次渲染时做依赖比较。在简单计算或轻量组件上,加 memo 的开销可能比不加更大。
┌─────────────────────────────────────────────────────────────┐
│ useMemo / useCallback 决策流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 这个值/函数是否: │
│ │
│ 1. 计算昂贵? (循环 > 1000 次 / 递归 / 大数组排序) │
│ └── 否 → 不加 memo ✅ │
│ └── 是 → 继续判断 ↓ │
│ │
│ 2. 被传给用 React.memo 包裹的子组件? │
│ └── 是 → 需要用 useMemo / useCallback 保持引用稳定 │
│ └── 否 → 可以不加 │
│ │
│ 3. 是 useEffect 的依赖? │
│ └── 是 → 如果每次渲染都变会导致 Effect 频繁执行 │
│ 用 useCallback 包裹可以避免 │
│ └── 否 → 可以不加 │
│ │
│ 4. 是自定义 Hook 的返回值? │
│ └── 是 → 应该用 useCallback 包裹,调用方无法控制 │
│ 你的 Hook 内部是否每次都创建新函数 │
│ │
└─────────────────────────────────────────────────────────────┘7.2 useMemo 实战:缓存昂贵派生值
function ProductList({ products, minPrice, maxPrice, sortBy }: Props) {
// ✅ useMemo:过滤 + 排序是 O(n log n),数据量大时值得缓存
const filtered = useMemo(() => {
return products
.filter(p => p.price >= minPrice && p.price <= maxPrice)
.sort((a, b) => {
switch (sortBy) {
case 'price-asc': return a.price - b.price
case 'price-desc': return b.price - a.price
case 'name': return a.name.localeCompare(b.name)
default: return 0
}
})
}, [products, minPrice, maxPrice, sortBy])
return <ul>{filtered.map(p => <ProductItem key={p.id} product={p} />)}</ul>
}7.3 useCallback + React.memo 组合拳
单用 useCallback 不会阻止子组件重渲染——还需要子组件用 React.memo 包装:
const ExpensiveChild = React.memo(function ExpensiveChild({
onSelect,
}: {
onSelect: (id: string) => void
}) {
// 只有 onSelect 引用变化时才重渲染
return (/* 昂贵的渲染 */)
})
function Parent() {
const [items, setItems] = useState<Item[]>([])
// ✅ useCallback 保证 onSelect 引用稳定
const handleSelect = useCallback((id: string) => {
setItems(prev => prev.map(item =>
item.id === id ? { ...item, selected: true } : item
))
}, []) // 使用函数式更新,依赖数组可以为空
return (
<>
{items.map(item => (
<ExpensiveChild key={item.id} onSelect={handleSelect} />
))}
</>
)
}7.4 自定义 Hook 中的 useCallback
自定义 Hook 返回的函数应该用 useCallback 包裹——调用方可能把这些函数作为 useEffect 依赖或传给 memo 组件:
function useSearch(query: string) {
const [results, setResults] = useState<Result[]>([])
const search = useCallback(async (q: string) => {
const res = await fetch(`/api/search?q=${q}`)
setResults(await res.json())
}, [])
const clear = useCallback(() => {
setResults([])
}, [])
return { results, search, clear }
// search 和 clear 的引用永远不变 → 调用方可以安全地放入依赖数组
}如果你不确定要不要加 useCallback:先不加。用 React DevTools Profiler 实测。发现性能瓶颈后再加,而不是提前优化。
第8部分:useTransition 和 useDeferredValue —— 并发渲染
8.1 React 18 并发模式的核心思想
并发渲染允许 React 中断正在进行的渲染来处理更高优先级的更新(如用户输入),然后再回来继续低优先级的渲染。这让 UI 始终保持响应。
useTransition 和 useDeferredValue 是这一机制的 API 入口。
8.2 useTransition:标记非紧急更新
function SearchPage() {
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// 紧急更新:立即更新输入框的值
setQuery(e.target.value)
// 非紧急更新:可以被中断,不会阻塞输入
startTransition(() => {
setFilteredResults(filterAndSort(allItems, e.target.value))
})
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<ResultList results={filteredResults} />
</>
)
}┌─────────────────────────────────────────────────────────────┐
│ useTransition 工作原理 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户快速输入 "react": │
│ │
│ 输入 'r': setQuery('r') + startTransition(过滤 'r') │
│ 输入 're': setQuery('re') + startTransition(过滤 're') │
│ ↑ 紧急更新中断了正在进行的过滤 'r' 的渲染 │
│ 输入 'rea': ... │
│ │
│ 最终: React 丢弃被中断的中间渲染,只完成最后一次过滤 │
│ 用户始终看到流畅的输入 → 无卡顿 │
│ │
└─────────────────────────────────────────────────────────────┘8.3 useDeferredValue:延迟一个值的更新
当 props 来自外部(无法用 startTransition 包裹),用 useDeferredValue 延迟该值的同步:
function SearchResults({ query }: { query: string }) {
// deferredQuery 会"滞后"于 query:query 变化时先渲染旧结果,
// 等 React 空闲了再更新 deferredQuery 并渲染新结果
const deferredQuery = useDeferredValue(query)
const results = useMemo(() => filterItems(allItems, deferredQuery), [deferredQuery])
const isStale = query !== deferredQuery // 显示的是旧结果时提示
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
{results.map(r => <ResultItem key={r.id} item={r} />)}
</div>
)
}| 特性 | useTransition | useDeferredValue |
|---|---|---|
| 控制方 | 你有 setState 的控制权 | props 来自外部,你无法控制更新时机 |
| 返回 | [isPending, startTransition] | 延迟后的值 |
| 用法 | startTransition(() => setX(v)) | const d = useDeferredValue(v) |
| 适用场景 | 你触发状态更新 | 状态由父组件或外部源驱动 |
8.4 与 Suspense 集成
并发 Hook 和 Suspense 天然搭配:
function App() {
const [tab, setTab] = useState('home')
const [isPending, startTransition] = useTransition()
function switchTab(next: string) {
startTransition(() => setTab(next))
}
return (
<>
<TabBar onSwitch={switchTab} isPending={isPending} />
<Suspense fallback={<Skeleton />}>
{tab === 'home' && <HomeFeed />}
{tab === 'profile' && <ProfilePage />}
</Suspense>
</>
)
}startTransition 包裹 setTab 后,React 不会在切换 tab 时隐藏已加载的内容去显示 Suspense fallback——而是保持旧 UI 直到新内容就绪,避免"闪一下骨架屏"的不良体验。
第9部分:React 19 新 Hook
9.1 use() —— 在渲染中读取 Promise 和 Context
React 19 的 use() 是一个特殊的 Hook,它可以在条件语句和循环中调用(打破了传统 Hook 规则,因为它内部不依赖链表索引):
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`)
return res.json()
}
function UserProfile({ userId }: { userId: string }) {
// use() 读取 Promise:React 会"暂停"渲染直到 Promise resolve
// 需要外层 <Suspense> 提供 fallback
const user = use(fetchUser(userId))
// use() 也能读取 Context(可以在条件中)
const theme = userId === 'admin'
? use(AdminThemeContext)
: use(DefaultThemeContext)
return <div style={{ color: theme.color }}>{user.name}</div>
}
// 外层必须包裹 Suspense
function App() {
return (
<Suspense fallback={<Loading />}>
<UserProfile userId="123" />
</Suspense>
)
}use() 和 useContext() 的关键区别:
use()可以在条件/循环中使用;useContext()不行use()读 Promise 时需要 Suspense;useContext()不需要
9.2 useActionState —— 表单 Action 状态管理
替代了传统的 useState({ error, loading }) + try/catch 模式,直接与 Server Actions 或 async 函数集成:
import { useActionState } from 'react'
async function updateProfile(prevState: State, formData: FormData): Promise<State> {
const name = formData.get('name') as string
if (!name) return { error: '名字不能为空' }
try {
await saveToServer({ name })
return { message: '保存成功' }
} catch {
return { error: '保存失败,请重试' }
}
}
function EditProfile() {
const [state, submitAction, isPending] = useActionState(updateProfile, { message: '' })
return (
<form action={submitAction}>
<input name="name" />
<button type="submit" disabled={isPending}>
{isPending ? '保存中...' : '保存'}
</button>
{state.error && <p className="error">{state.error}</p>}
{state.message && <p className="success">{state.message}</p>}
</form>
)
}9.3 useFormStatus —— 读取表单提交状态
在表单内部的子组件中读取父 <form> 的提交状态,无需 prop drilling:
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending, data, method, action } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? '提交中...' : '提交'}
</button>
)
}
function MyForm() {
return (
<form action={submitAction}>
<input name="email" />
<SubmitButton /> {/* 自动读取外层 form 的 pending 状态 */}
</form>
)
}9.4 useOptimistic —— 乐观更新
在服务器响应之前就更新 UI,如果最终失败则回滚:
import { useOptimistic } from 'react'
function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
initialTodos,
(state, newTodo: Todo) => [...state, newTodo]
)
async function handleAdd(formData: FormData) {
const title = formData.get('title') as string
const tempTodo: Todo = { id: `temp-${Date.now()}`, title, done: false }
// 立刻更新 UI(乐观)
addOptimistic(tempTodo)
// 发送真实请求
await createTodoOnServer(title)
// 如果请求失败,React 自动回滚 optimisticTodos 到 initialTodos
}
return (
<>
<form action={handleAdd}>
<input name="title" />
<button type="submit">添加</button>
</form>
<ul>
{optimisticTodos.map(t => (
<li key={t.id} style={{ opacity: t.id.startsWith('temp') ? 0.5 : 1 }}>
{t.title}
</li>
))}
</ul>
</>
)
}React 19 新 Hook 一览:
┌─────────────────────────────────────────────────────────────┐
│ React 19 新增 Hook 总结 │
├─────────────────────────────────────────────────────────────┤
│ │
│ use() │
│ ├── 读取 Promise → 配合 Suspense,声明式数据获取 │
│ ├── 读取 Context → 可在条件/循环中使用 │
│ └── 打破"只在顶层调用"规则 │
│ │
│ useActionState(formAction, initialState) │
│ ├── 返回 [state, submitAction, isPending] │
│ └── 替代手动 error/loading 状态管理 │
│ │
│ useFormStatus() │
│ ├── 返回 { pending, data, method, action } │
│ └── 子组件无需 prop drilling 即可读取 form 状态 │
│ │
│ useOptimistic(state, updateFn) │
│ ├── 返回 [optimisticState, addOptimistic] │
│ └── 乐观更新 + 自动回滚 │
│ │
└─────────────────────────────────────────────────────────────┘第10部分:自定义 Hook 设计模式
10.1 本质:复用状态逻辑,而非共享状态
自定义 Hook 复用的是状态逻辑 + 同步协议,而不是共享同一份状态。每个调用自定义 Hook 的组件拥有独立的 state 和 effect 实例——就和直接调用 useState/useEffect 一样。
10.2 命名约定与返回值设计
// ✅ 命名以 use 开头(React 要求,ESLint 也依赖这个规则)
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches)
useEffect(() => {
const mql = window.matchMedia(query)
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}, [query])
return matches
}返回值设计原则:
- 单个值(如
useOnlineStatus→boolean):直接 return - 多个值(如
useSearch→{ results, search, clear }):返回对象,调用方按需解构 - 类 useState 的二元组(如
useToggle→[value, toggle]):用于调用方熟悉的心智模型
10.3 稳定引用策略
自定义 Hook 返回的对象/函数必须考虑引用稳定性:
// ❌ 每次渲染都返回新对象 → 调用方 useEffect 会频繁触发
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 })
// ...
return { width: size.width, height: size.height }
}
// ✅ 方案1:useMemo 稳定返回值的引用
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 })
// ...
return useMemo(() => ({ width: size.width, height: size.height }), [size.width, size.height])
}
// ✅ 方案2:拆成两个独立的 Hook 或返回值
function useWindowSize() {
const [width, setWidth] = useState(0)
const [height, setHeight] = useState(0)
// ...
return { width, height } // 基本类型的引用本身就是稳定的
}10.4 典型模式汇总
┌─────────────────────────────────────────────────────────────┐
│ 自定义 Hook 常见模式 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 模式1: 封装浏览器 API │
│ useOnlineStatus, useMediaQuery, useLocalStorage, │
│ useGeolocation, useClipboard │
│ │
│ 模式2: 封装副作用 + 清理 │
│ useEventListener, useInterval, useTimeout, │
│ useWebSocket, useIntersectionObserver │
│ │
│ 模式3: 数据获取 │
│ useFetch, useQuery, useMutation │
│ 注意:生产环境优先 TanStack Query / SWR │
│ │
│ 模式4: 状态机 / 布尔开关 │
│ useToggle, useBoolean, useCounter, useUndo │
│ │
│ 模式5: 组合 Context │
│ useAuth, useTheme, useTodos → 封装 useContext 调用 │
│ 提供更好的错误提示(不在 Provider 内时 throw) │
│ │
└─────────────────────────────────────────────────────────────┘10.5 实战:组合多个基础 Hook 构建高级 Hook
function useDebouncedSearch(delay = 300) {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, delay)
const {
data: results,
isLoading,
error,
} = useQuery({
queryKey: ['search', debouncedQuery],
queryFn: () => fetchResults(debouncedQuery),
enabled: debouncedQuery.length > 0,
})
return { query, setQuery, results, isLoading, error }
}
// 这个 Hook 组合了:
// - useState (query 状态)
// - useDebounce (防抖,可能是另一个自定义 Hook)
// - useQuery (TanStack Query,数据获取)
// 对外暴露统一的接口,调用方只需一行代码
function SearchBox() {
const { query, setQuery, results, isLoading } = useDebouncedSearch(300)
return (/* ... */)
}核心总结
┌─────────────────────────────────────────────────────────────┐
│ React Hooks 心智模型总结 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 渲染阶段 (Render) → useState, useReducer, useMemo, │
│ useCallback, useRef (创建对象) │
│ │
│ DOM 变更后,绘制前 → useInsertionEffect, useLayoutEffect │
│ │
│ 浏览器绘制后 → useEffect │
│ │
│ 并发调度 → useTransition, useDeferredValue │
│ │
│ 表单/数据 → useActionState, useFormStatus, │
│ (React 19) useOptimistic, use() │
│ │
└─────────────────────────────────────────────────────────────┘核心原则:
- Hook 规则源于链表存储 —— 理解"为什么"比记住规则更重要
- state 是不可变的 —— 新对象 = 新渲染;函数式更新避免陈旧闭包
- Effect 同步外部系统 —— 不是 componentDidMount/Update/Unmount 的替代品
- 能不用 Effect 就不用 —— 计算值、事件处理、key 重置都比 Effect 更合适
- useMemo/useCallback 不是免费的 —— 先测量,再优化
- 并发模式用 useTransition —— 让 UI 在昂贵计算时保持响应
- 自定义 Hook 是逻辑复用 —— 不是状态共享;注意返回值引用稳定性
章节测试
测试1:Hook 规则
为什么 Hook 不能在条件语句中调用?请从内部实现角度解释。
测试2:useState
以下代码有什么问题?如何修复?
function Counter() {
const [count, setCount] = useState(
JSON.parse(localStorage.getItem('count') ?? '0')
)
// ...
}测试3:useReducer
将以下 useState 代码改写为 useReducer,并定义判别联合的 Action 类型:
const [isLoading, setIsLoading] = useState(false)
const [data, setData] = useState<Data | null>(null)
const [error, setError] = useState<string | null>(null)测试4:useRef
组件需要记住上一次 props.value 的值,并在变化时打印日志。请用 useRef 实现。
测试5:useEffect
以下 Effect 在 React StrictMode 下有什么问题?
useEffect(() => {
const ws = new WebSocket('wss://example.com')
ws.onmessage = (e) => setMessages(prev => [...prev, e.data])
}, [])测试6:useMemo
以下场景加 useMemo 是否合理?为什么?
const doubled = useMemo(() => count * 2, [count])测试7:并发 Hook
useTransition 和 useDeferredValue 的核心区别是什么?各自适用什么场景?
测试8:React 19
use() 与 useContext() 相比有哪些关键区别?
参考答案
测试1答案
React 内部用单向链表存储每个组件实例的 Hook 状态,按调用顺序索引。如果 Hook 在条件/循环中调用,两次渲染间的调用顺序可能不同,链表节点无法正确对应,导致状态串位。这是由 React Fiber 架构中 Hook 的链表实现决定的。
测试2答案
问题:localStorage.getItem() 在每次渲染时都会执行,尽管只有首次的值被使用。修复:使用惰性初始化函数 useState(() => JSON.parse(localStorage.getItem('count') ?? '0')),React 只在首次渲染时调用它。
测试3答案
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string }
type Action<T> =
| { type: 'start_loading' }
| { type: 'success'; data: T }
| { type: 'error'; error: string }
function reducer<T>(state: RequestState<T>, action: Action<T>): RequestState<T> {
switch (action.type) {
case 'start_loading': return { status: 'loading' }
case 'success': return { status: 'success', data: action.data }
case 'error': return { status: 'error', error: action.error }
}
}测试4答案
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>()
useEffect(() => { ref.current = value })
return ref.current
}测试5答案
StrictMode 下组件会挂载 → 卸载 → 重新挂载。空依赖数组的 Effect 会先建立连接、清理、再建立连接。如果清理函数没有关闭 WebSocket(当前代码缺少 return () => ws.close()),会导致连接泄漏。正确做法:添加 return () => ws.close()。
测试6答案
不合理。count * 2 是 O(1) 的简单算术运算,useMemo 自身的开销(闭包创建、依赖比较)可能比直接计算还大。useMemo 只有在计算确实昂贵(大数组操作、复杂对象构建)时才值得使用。
测试7答案
useTransition 返回 [isPending, startTransition],适合你有 setState 控制权的场景——用 startTransition 包裹自己的 setState 调用来标记非紧急更新。useDeferredValue 返回延迟后的值,适合props 来自外部、你无法控制更新时机的场景——传入一个外部值,React 在空闲时更新它。
测试8答案
use()可以在条件语句和循环中调用(不依赖链表索引),useContext()必须在顶层调用use()可以读取 Promise(需要外层 Suspense),useContext()只能读 Context
相关笔记
- [[01-react-components-and-rendering]] - React 组件模型和渲染机制
- [[08-testing-performance-production]] - 测试与性能优化(含 useMemo/useCallback 性能分析)
- [[05-nextjs-app-router-and-rendering]] - Next.js App Router(Server Components、Server Actions 与 React 19 Hook 的关系)
下一步学习
- [ ] 阅读 08 - 测试与性能优化
- [ ] 阅读 05 - Next.js App Router
- [ ] 阅读 React 官方文档 You Might Not Need an Effect
- [ ] 阅读 React 19 发布博客中关于新 Hook 的说明
学习状态:🟡 开始学习