React: Hooks 规则与常见陷阱
最后更新:2026-10-26
1. 你将学到
- Hooks 的两条铁律(为什么不能条件式调用)
- 闭包陷阱(Stale Closure)的识别和修复
- useEffect 无限循环的 5 种原因
- useState 异步更新的陷阱
- 10 个最常见 Bug 的修复方案
2. 一个调试噩梦的故事
(1) 痛点:一个 useEffect 调试了 3 小时
Alice 写了一个倒计时组件:
▶ 示例:Hooks规则演示
输出:
TEXT
状态:timeLeft。通过 useEffect 管理副作用。定时更新
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>
}
输出:
TEXT
useEffect 管理副作用
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 调试
▶ 试一试输出:
TEXT
状态:timeLeft(setter:setTimeLeft)。useEffect 管理副作用。基于定时器的更新
▶ 示例 2:自定义 Hook 解决闭包陷阱 —— useStableCallback
▶ 展开运行示例(70 行)
输出:
TEXT
❌ 在条件语句内调用 Hook 会导致 bug。✅ 始终在顶层调用 Hook。规则:每次渲染时 Hook 的调用顺序必须保持一致。
输出:
TEXT
... ('Current Count:', count)
▶ 示例 3:useState 批量更新陷阱与修复
▶ 试一试输出:
TEXT
渲染:Shopping Cart + Total: ${total}
输出:
TEXT
显示:"Shopping Cart"。状态:items(setter:setItems)、total(setter:setTotal)。按钮:addItemFixed({ id: 3, name: 'Notebook', price: 12 })}> Add Notebook、Add Book + Pen。列表:{item.name} - ${item.price}
▶ 示例 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>
)
}
输出:
TEXT
try { await fetchData(); } catch (err) { setError(err.message); } → 显示 "Failed to load data"。错误已处理,应用继续运行。
输出:
TEXT
显示:"Loading..."。状态:user(setter:setUser)、loading(setter:setLoading)。useEffect 管理副作用。异步数据获取/加载状态
▶ 示例 5:useReducer 替代多个 useState 避免状态不一致
▶ 试一试输出:
TEXT
渲染:Count: {state.count} + History: {state.history.join(', ')}
❓ 常见问题
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 定位性能瓶颈