React: Hooks 规则与常见陷阱

最后更新:2026-10-26

1. 你将学到



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) 为什么这两个规则如此重要?

100%
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(闭包陷阱)。

📖 小节


📝 作业

  1. 基础题(难度⭐):找出下面代码中的 Bug 并修复:

    ▶ 试一试
  2. 进阶题(难度⭐⭐):找出下面代码中的 Bug 并修复(至少 3 个):

    ▶ 试一试
  3. 挑战题(难度⭐⭐⭐):实现一个 useStableCallback Hook,接收一个回调函数,始终返回一个引用稳定的函数,但调用时执行的是最新的回调(解决闭包陷阱的终极方案)。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏