React: Hooks 规则与常见陷阱
最后更新:2026-08-26
1. 你将学到
- Hooks 的两条铁律(为什么不能条件式调用)
- 闭包陷阱(Stale Closure)的识别和修复
- useEffect 无限循环的 5 种原因
- useState 异步更新的陷阱
- 10 个最常见 Bug 的修复方案
2. 一个调试噩梦的故事
(1) 痛点:一个 useEffect 调试了 3 小时
Alice 写了一个倒计时组件:
▶ 示例:Hooks规则演示
JSX
function Countdown({ targetDate }) {
const [timeLeft, setTimeLeft] = useState(computeTimeLeft())
function computeTimeLeft() {
return Math.max(0, Math.floor((new Date(targetDate) - Date.now()) / 1000))
}
useEffect(() => {
const timer = setInterval(() => {
// ❌ 闭包陷阱:timeLeft 永远是初始值
setTimeLeft(timeLeft - 1)
}, 1000)
return () => clearInterval(timer)
}, []) // 想只在挂载时启动
return <p>剩余:{timeLeft} 秒</p>
}
Bug:倒计时永远停在初始值 -1。因为 setInterval 回调里捕获的 timeLeft 是首次渲染时的值(闭包陷阱)。
(2) 修复方案
JSX
// ✅ 修复 1:函数式更新
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(prev => Math.max(0, prev - 1)) // prev 永远是最新值
}, 1000)
return () => clearInterval(timer)
}, [])
// ✅ 修复 2:或者把 targetDate 加入依赖数组
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(prev => Math.max(0, prev - 1))
}, 1000)
return () => clearInterval(timer)
}, [targetDate]) // targetDate 变化时重启定时器
教训:永远不要在 useEffect 中直接读取 state 来更新自身。用函数式更新 setState(prev => prev + 1)。
3. Hooks 的两条铁律
(1) 规则 1:只在最顶层调用 Hooks
不要在循环、条件语句或嵌套函数中调用 Hooks:
JSX
function BadComponent({ showExtra }) {
const [name, setName] = useState('') // ✅ 顶层
// ❌ 规则违反:条件中调用 Hook
if (showExtra) {
const [extra, setExtra] = useState('') // 报错!
useEffect(() => { /* ... */ }, [])
}
// ❌ 规则违反:循环中调用 Hook
for (let i = 0; i < items.length; i++) {
useEffect(() => { /* ... */ }, []) // 报错!
}
// ✅ 正确:条件逻辑在 Hook 内部处理
const [extra, setExtra] = useState('')
useEffect(() => {
if (showExtra) { /* 条件逻辑放在 useEffect 内部 */ }
}, [showExtra])
}
| 调用位置 | 是否合法 | 原因 |
|---|---|---|
| 函数组件顶层 | ✅ | Hooks 链表顺序每次渲染一致 |
| 自定义 Hook 顶层 | ✅ | 自定义 Hook 本质是函数组件的子调用链 |
| if / for / while 内部 | ❌ | 条件变化导致 Hooks 调用次数不一致,链表错位 |
| 普通工具函数 | ❌ | 不在 React 组件上下文中,无 Hooks 链表 |
| 类组件方法 | ❌ | 类组件没有 Hooks 链表机制 |
| useEffect 回调内部 | ❌ | 回调不是组件顶层,调用顺序不可预测 |
(2) 规则 2:只在函数组件或自定义 Hook 中调用 Hooks
JSX
// ❌ 普通函数中调用 Hook
function getData() {
const [data, setData] = useState(null) // 报错!
}
// ❌ 类组件中调用 Hook
class MyClass extends React.Component {
render() {
const [count, setCount] = useState(0) // 报错!
}
}
// ✅ 自定义 Hook 中调用
function useCustomHook() {
const [data, setData] = useState(null) // ✅ 正确
}
(3) 为什么这两个规则如此重要?
graph TB
A[组件渲染] --> B[Hook 链表]
B --> C[Hook 1: useState<br/>链表节点 1]
B --> D[Hook 2: useEffect<br/>链表节点 2]
B --> E[Hook 3: useRef<br/>链表节点 3]
D -->|条件渲染导致跳过| F[链表错位!]
F --> G[Hook 2 读到的是 Hook 3 的数据]
G --> H[Bug! 数据混乱]
style F fill:#ff4d4f,color:#fff
style H fill:#ff4d4f,color:#fff
React 依赖 Hooks 的调用顺序来匹配状态。如果每次渲染调用顺序不同,状态就乱了。
4. 陷阱 1:闭包陷阱(Stale Closure)
最常见的 Hooks Bug。
JSX
function Counter() {
const [count, setCount] = useState(0)
// ❌ 闭包陷阱
useEffect(() => {
const timer = setInterval(() => {
console.log(count) // 永远是 0!
setCount(count + 1) // 永远设置成 1
}, 1000)
return () => clearInterval(timer)
}, []) // count 被"锁"在首次渲染
// ✅ 修复 1:函数式更新
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1) // prev 永远是最新值
}, 1000)
return () => clearInterval(timer)
}, [])
// ✅ 修复 2:加入依赖
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(timer)
}, [count]) // count 变化时重启定时器
}
什么时候发生:在 useEffect、useCallback、useMemo 中读取了一个没有在依赖数组中声明的值。
| 闭包陷阱场景 | 症状 | 修复方式 |
|---|---|---|
| setInterval 中读 state | 定时器中的值永远不变 | 函数式更新 setState(prev => ...) |
| useCallback 中读 state | 回调中使用旧值 | 将 state 加入依赖数组或用 ref |
| 事件处理函数延迟执行 | 异步回调中 state 是旧值 | 用 useRef 存储最新值 |
| useEffect 依赖遗漏 | effect 中使用的变量不更新 | 补全依赖数组 |
| 异步请求后 set 竞态 | 快速切换时旧请求覆盖新结果 | 用 ignore flag 或 AbortController |
修复口诀:所有在回调中读取的变量,都要在依赖数组中声明。或者用函数式更新。
5. 陷阱 2:useEffect 无限循环
(1) 原因 1:没有依赖数组
JSX
// ❌ 无限循环!
useEffect(() => {
fetch('/api/data').then(setData)
}) // 没有依赖数组 → 每次渲染都执行 → setData 触发渲染 → 无限循环
// ✅ 空依赖数组
useEffect(() => {
fetch('/api/data').then(setData)
}, [])
(2) 原因 2:依赖数组中的引用类型
JSX
function App() {
const [count, setCount] = useState(0)
// ❌ 无限循环!
useEffect(() => {
console.log('useEffect 执行')
}, [{ count }]) // 每次渲染都创建新对象 {} → 引用不同 → 无限执行
// ✅ 正确:用基本类型
useEffect(() => {
console.log('useEffect 执行')
}, [count]) // 基本类型,值不变就不执行
}
(3) 原因 3:在 useEffect 里修改依赖
JSX
const [items, setItems] = useState([1, 2, 3])
// ❌ 无限循环!
useEffect(() => {
setItems([...items, 4]) // 修改 items → 重新渲染 → useEffect 又执行...
}, [items])
// ✅ 正确:为什么需要修改 items?一般不需要
// 如果是初始化,用 useRef 标记是否已初始化
const initialized = useRef(false)
useEffect(() => {
if (!initialized.current) {
setItems([...items, 4])
initialized.current = true
}
}, [items])
(4) 原因 4:函数作为依赖
| 无限循环原因 | 错误写法 | 正确写法 |
|---|---|---|
| 没有依赖数组 | useEffect(fn) |
useEffect(fn, []) |
| 依赖引用类型 | useEffect(fn, [{ count }]) |
useEffect(fn, [count]) |
| effect 中修改依赖 | useEffect(() => setItems(...), [items]) |
用 useRef 标记初始化 |
| 函数作为依赖 | useEffect(fn, [handleClick]) |
useCallback 稳定引用 |
| 对象作为依赖 | useEffect(fn, [config]) |
提取基本类型值或用 useMemo |
JSX
// ❌ 每次渲染都是新的函数引用
function App() {
const handleClick = () => { /* ... */ }
useEffect(() => {
fetch('/api').then(setData)
}, [handleClick]) // handleClick 每次都是新引用 → 无限执行
// ✅ 用 useCallback 稳定引用
const handleClick = useCallback(() => { /* ... */ }, [])
useEffect(() => {
fetch('/api').then(setData)
}, [handleClick]) // handleClick 引用不变,不会重复执行
}
6. 陷阱 3:useState 异步更新
JSX
function Counter() {
const [count, setCount] = useState(0)
function handleClick() {
// 你以为会 +3,结果只 +1
setCount(count + 1)
setCount(count + 1)
setCount(count + 1) // 三次都是 count + 1,而 count = 0
console.log(count) // 输出 0,不是 3
}
// ✅ 函数式更新
function handleCorrectClick() {
setCount(prev => prev + 1) // prev = 0 → 1
setCount(prev => prev + 1) // prev = 1 → 2
setCount(prev => prev + 1) // prev = 2 → 3
// count 还是 0(本次渲染的值),但下次渲染时是 3
}
}
核心理解:useState 的更新是异步批量处理的。在一次事件处理中,多个 setCount 会合并成一次渲染。用函数式更新来保证每次更新都基于最新值。
7. 陷阱 4:在 useEffect 中使用 async
JSX
// ❌ useEffect 不能直接传 async 函数
useEffect(async () => {
const data = await fetch('/api/data')
setData(await data.json())
}, [])
// 报错!async 函数返回 Promise,useEffect 期望返回清理函数(或 undefined)
// ✅ 正确:在内部定义 async 函数
useEffect(() => {
async function fetchData() {
const res = await fetch('/api/data')
const data = await res.json()
setData(data)
}
fetchData() // 调用内部 async 函数
}, [])
8. 完整示例:10 个常见 Bug 诊断表
JSX
// ============================================
// 示例:10 个常见 Hooks Bug + 修复方案
// ============================================
// Bug 1:条件 Hook
function Bug1({ show }) {
// if (show) const [x] = useState(0) // ❌
const [x] = useState(0) // ✅ 移到顶层
}
// Bug 2:闭包陷阱
function Bug2() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
// setCount(count + 1) // ❌ 闭包
setCount(prev => prev + 1) // ✅
}, 1000)
return () => clearInterval(id)
}, [])
}
// Bug 3:无限循环(依赖引用类型)
function Bug3() {
const [data, setData] = useState(null)
useEffect(() => {
fetch('/api').then(res => res.json()).then(setData)
}, []) // ✅ 空数组
// }, [data]) // ❌ data 变化又触发 useEffect
}
// Bug 4:忘记清理
function Bug4() {
useEffect(() => {
const id = setInterval(() => console.log('tick'), 1000)
return () => clearInterval(id) // ✅ 必须清理
}, [])
}
// Bug 5:直接修改 state
function Bug5() {
const [user, setUser] = useState({ name: 'Alice', age: 28 })
function updateAge() {
// user.age = 29 // ❌ 直接修改
setUser({ ...user, age: 29 }) // ✅ 创建新对象
}
}
// Bug 6:不需要的 useMemo
function Bug6({ count }) {
// const doubled = useMemo(() => count * 2, [count]) // ❌ 多此一举
const doubled = count * 2 // ✅ 简单计算不需要 useMemo
}
// Bug 7:effect 中读取 props 但不声明依赖
function Bug7({ userId }) {
const [user, setUser] = useState(null)
useEffect(() => {
fetch('/api/users/' + userId).then(res => res.json()).then(setUser)
}, [userId]) // ✅ userId 必须加入依赖
// }, []) // ❌ userId 变化不会重新请求
}
// Bug 8:useMemo 返回函数
function Bug8() {
// const fn = useMemo(() => { return () => console.log('hi') }, []) // ❌ 应该用 useCallback
const fn = useCallback(() => console.log('hi'), []) // ✅
}
// Bug 9:在 render 中发起副作用
function Bug9() {
const [data, setData] = useState(null)
// fetch('/api/data').then(setData) // ❌ 每次渲染都请求
useEffect(() => { fetch('/api/data').then(res => res.json()).then(setData) }, []) // ✅
}
// Bug 10:状态依赖未完整声明
function Bug10({ items }) {
const [filter, setFilter] = useState('')
// const filtered = items.filter(i => i.includes(filter)) // ✅ 在 render 中直接计算
const filtered = useMemo(() => items.filter(i => i.includes(filter)), [items, filter])
// useMemo 必须有正确的依赖
}
9. 调试技巧
(1) React DevTools Profiler
JSX
// 在 React DevTools 的 Profiler 标签页中:
// 1. 点击录制按钮
// 2. 在应用中触发操作
// 3. 停止录制
// 4. 查看火焰图:每一列是一个组件,高度代表渲染时间
// 看到不该重新渲染的组件渲染了 → 检查 useCallback / React.memo
(2) why-did-you-render
BASH
npm install @welldone-software/why-did-you-render
JSX
// src/wdyr.js
import React from 'react'
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render')
whyDidYouRender(React, {
trackAllPureComponents: true,
})
}
// 控制台会打印:为什么这个组件重新渲染了?因为哪个 Props 变了?
(3) 使用 useWhyDidYouUpdate 调试
JSX
function useWhyDidYouUpdate(name, props) {
const previousProps = useRef(props)
useEffect(() => {
const allKeys = Object.keys({ ...previousProps.current, ...props })
const changes = {}
allKeys.forEach(key => {
if (previousProps.current[key] !== props[key]) {
changes[key] = {
from: previousProps.current[key],
to: props[key]
}
}
})
if (Object.keys(changes).length > 0) {
console.log(`[why-did-you-update] ${name}:`, changes)
}
previousProps.current = props
})
}
// 使用
function MyComponent(props) {
useWhyDidYouUpdate('MyComponent', props)
return <div>{props.name}</div>
}
▶ 示例 2:自定义 Hook 解决闭包陷阱 —— useStableCallback
JSX
📖 仅展示
// ============================================
// 示例:useStableCallback——解决闭包陷阱的终极方案
// 功能:始终返回一个引用稳定的回调函数,但调用时执行最新逻辑
// ============================================
import { useRef, useCallback, useState, useEffect } from 'react'
// ---- 自定义 Hook:稳定的回调 ----
function useStableCallback(callback) {
// 用 ref 存储最新的回调
const callbackRef = useRef(callback)
// 每次渲染后更新 ref 中的回调
useEffect(() => {
callbackRef.current = callback
})
// 返回一个永远不变的函数
return useCallback((...args) => {
// 调用时执行最新版本的回调
return callbackRef.current(...args)
}, [])
}
// ---- 使用 useStableCallback 解决闭包陷阱 ----
function TimerWithStableCallback() {
const [count, setCount] = useState(0)
const [isRunning, setIsRunning] = useState(false)
// 这个回调函数依赖 count(每次渲染都是新函数)
function onTick() {
console.log('当前计数:', count)
// 这里如果直接使用 count,会形成闭包陷阱
// 但 useStableCallback 会保证调用的是最新版本
}
// ✅ 用 useStableCallback 包裹,引用永远稳定
const stableOnTick = useStableCallback(onTick)
useEffect(() => {
if (!isRunning) return
const timer = setInterval(() => {
// stableOnTick 引用不会变,但每次调用时执行的是最新的 onTick
stableOnTick()
setCount(prev => prev + 1)
}, 1000)
return () => clearInterval(timer)
}, [isRunning, stableOnTick]) // stableOnTick 永远不变,不会导致死循环
return (
<div style={{ maxWidth: '400px', margin: '0 auto', textAlign: 'center' }}>
<h2>⏱️ 闭包陷阱解决方案</h2>
<p style={{ fontSize: '48px', fontWeight: 'bold', margin: '20px 0' }}>{count}</p>
<button
onClick={() => setIsRunning(!isRunning)}
style={{
padding: '10px 24px',
backgroundColor: isRunning ? '#ff4d4f' : '#52c41a',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '16px'
}}
>
{isRunning ? '⏸ 暂停' : '▶ 开始'}
</button>
<p style={{ marginTop: '12px', color: '#666', fontSize: '13px' }}>
打开控制台查看日志——useStableCallback 确保定时器不会因为闭包而卡住
</p>
</div>
)
}
// ---- 对比:不使用 useStableCallback 的版本 ----
function TimerBroken() {
const [count, setCount] = useState(0)
const [isRunning, setIsRunning] = useState(false)
useEffect(() => {
if (!isRunning) return
// ❌ 直接使用 setInterval,count 被锁在闭包中
const timer = setInterval(() => {
setCount(count + 1) // 永远是 0 + 1 = 1
}, 1000)
return () => clearInterval(timer)
}, [isRunning]) // 缺少 count 依赖
// 修复方案:用函数式更新
// setCount(prev => prev + 1) // ✅ 不需要 count 依赖
return (
<div style={{ maxWidth: '400px', margin: '0 auto', textAlign: 'center', marginTop: '40px' }}>
<h3>❌ 有问题的版本(对比用)</h3>
<p style={{ fontSize: '48px', fontWeight: 'bold', margin: '20px 0', color: '#ff4d4f' }}>{count}</p>
<button onClick={() => setIsRunning(!isRunning)}
style={{ padding: '10px 24px', backgroundColor: '#d9d9d9', color: '#333', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
开始(会卡在 1)
</button>
</div>
)
}
▶ 示例 3:useState 批量更新陷阱与修复
JSX
function ShoppingCart() {
const [items, setItems] = useState([])
const [total, setTotal] = useState(0)
function addItem(item) {
setItems([...items, item])
setTotal(items.reduce((sum, i) => sum + i.price, 0) + item.price)
}
function addItemFixed(item) {
setItems(prev => [...prev, item])
setTotal(prev => prev + item.price)
}
function addMultiple() {
setItems(prev => [...prev, { id: 1, name: 'Book', price: 29 }])
setItems(prev => [...prev, { id: 2, name: 'Pen', price: 5 }])
setTotal(prev => prev + 34)
}
return (
<div>
<h3>Shopping Cart</h3>
<ul>
{items.map((item, idx) => (
<li key={idx}>{item.name} - ${item.price}</li>
))}
</ul>
<p>Total: ${total}</p>
<button onClick={() => addItemFixed({ id: 3, name: 'Notebook', price: 12 })}>
Add Notebook
</button>
<button onClick={addMultiple}>Add Book + Pen</button>
</div>
)
}
▶ 示例 4:useEffect 异步请求竞态问题
JSX
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
let ignore = false
setLoading(true)
async function fetchUser() {
try {
const res = await fetch(`/api/users/${userId}`)
const data = await res.json()
if (!ignore) {
setUser(data)
setLoading(false)
}
} catch (err) {
if (!ignore) setLoading(false)
}
}
fetchUser()
return () => { ignore = true }
}, [userId])
if (loading) return <p>Loading...</p>
if (!user) return <p>No user found</p>
return (
<div>
<h3>{user.name}</h3>
<p>Email: {user.email}</p>
<p>Role: {user.role}</p>
</div>
)
}
▶ 示例 5:useReducer 替代多个 useState 避免状态不一致
JSX
📖 仅展示
const initialState = { count: 0, step: 1, history: [] }
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {
...state,
count: state.count + state.step,
history: [...state.history, `+${state.step}`],
}
case 'decrement':
return {
...state,
count: state.count - state.step,
history: [...state.history, `-${state.step}`],
}
case 'setStep':
return { ...state, step: action.payload }
case 'reset':
return initialState
default:
return state
}
}
function CounterWithHistory() {
const [state, dispatch] = useReducer(reducer, initialState)
return (
<div>
<h3>Count: {state.count}</h3>
<label>
Step:
<input
type="number"
value={state.step}
onChange={e => dispatch({ type: 'setStep', payload: Number(e.target.value) })}
/>
</label>
<button onClick={() => dispatch({ type: 'increment' })}>+{state.step}</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-{state.step}</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
<p>History: {state.history.join(', ')}</p>
</div>
)
}
❓ 常见问题
Q 为什么有时候 setState 后页面没有更新?
A 可能的原因:① 直接修改了 state 对象(没有创建新引用);② 状态值没变(React 用 Object.is 比较,相同值不触发渲染);③ 在 useEffect 的依赖数组里忘了声明更新的变量;④ 在异步回调中读取了旧的闭包值。
Q useEffect 的依赖数组写空和不写有什么区别?
A 不写依赖数组
useEffect(fn) → 每次渲染后都执行(挂载 + 每次更新);空依赖数组 useEffect(fn, []) → 只在挂载时执行一次;有依赖 useEffect(fn, [a, b]) → 挂载时执行 + a 或 b 变化时执行。最常见的 Bug 来源:写了空数组但 effect 内部用了 state/props(闭包陷阱)。📖 小节
- 两条铁律:只在顶层调用 Hooks,只在函数组件/自定义 Hook 中调用 Hooks
- 闭包陷阱:在 useEffect/useCallback 中读取的变量必须声明在依赖数组中,或使用函数式更新
- 无限循环:检查依赖数组是否正确、引用类型是否稳定、是否在 effect 中修改了依赖
- 函数式更新:
setCount(prev => prev + 1)解决闭包陷阱和批量更新问题 - 安装
eslint-plugin-react-hooks,不要手动禁用 - 用 React DevTools Profiler 定位性能瓶颈
📝 作业
-
基础题(难度⭐):找出下面代码中的 Bug 并修复:
▶ 试一试JSXfunction Timer() { const [time, setTime] = useState(0) useEffect(() => { const id = setInterval(() => { setTime(time + 1) }, 1000) return () => clearInterval(id) }, []) return <p>{time}s</p> } -
进阶题(难度⭐⭐):找出下面代码中的 Bug 并修复(至少 3 个):
▶ 试一试JSXfunction SearchPage() { const [query, setQuery] = useState('') const [results, setResults] = useState([]) useEffect(async () => { const data = await fetch('/api/search?q=' + query) setResults(data) }, []) return <div> <input value={query} onChange={e => setQuery(e.target.value)} /> {results.map(r => <p key={r.id}>{r.name}</p>)} </div> } -
挑战题(难度⭐⭐⭐):实现一个
useStableCallbackHook,接收一个回调函数,始终返回一个引用稳定的函数,但调用时执行的是最新的回调(解决闭包陷阱的终极方案)。