FumadocsZDecode
React

React Hooks 原理与规则

Hooks 的本质是 React 在 Fiber 节点上维护的一套链表,靠调用顺序而非变量名来定位每个 Hook 的状态。理解这一点,就能解释「为什么必须顶层调用」和「为什么不能脱离组件使用」这两条规则。


一、Hooks 解决了什么

Hooks 是 React 16.8 引入的函数组件状态机制,主要解决 Class 组件的三个痛点:

痛点Class 的问题Hooks 的方案
逻辑复用mixin / HOC / render props 导致嵌套地狱、props 冲突自定义 Hook 抽离逻辑
生命周期割裂一个逻辑被拆到 mount / update / unmount 三处useEffect 聚合同一逻辑
this 指向需要手动 bind(this)完全函数式,无 this
function Counter() {
  const [count, setCount] = React.useState(0)

  React.useEffect(() => {
    document.title = String(count)
  }, [count])

  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

二、本质:链表挂在 Fiber 上

React 把所有 Hooks 存到当前组件对应 Fiber 节点的 memoizedState 上,组成一条单向链表:

// fiber.memoizedState = hook1 -> hook2 -> hook3
interface Hook {
  memoizedState: any // 该 Hook 保存的状态
  queue: UpdateQueue | null // 更新队列
  next: Hook | null // 指向下一个 Hook
}

三、useState 原理

首次渲染(mount):创建 Hook 对象,挂到链表,返回状态和 dispatch。

function mountState(initial: any) {
  const hook: Hook = {
    memoizedState: initial,
    queue: { pending: null },
    next: null,
  }
  // 挂到 fiber.memoizedState 链表
  return [hook.memoizedState, dispatchSetState]
}

调用 setState:本质是往 hook.queue 塞一个更新任务,然后触发调度。

function dispatchSetState(queue: any, action: any) {
  queue.pending = action // 更新塞进队列
  scheduleUpdateOnFiber() // 触发 Fiber 重新渲染
}

四、为什么靠顺序定位 state

重新渲染时,React 不通过变量名找状态,而是按 Hook 的调用顺序依次从链表取节点:

function App() {
  const [a] = React.useState(0) // 取 hook1
  React.useEffect(() => {}) // 取 hook2
  const b = React.useMemo(() => 1, []) // 取 hook3
}

每次渲染,第 1 次 Hook 调用对应 hook1,第 2 次对应 hook2……一一对应。所以调用顺序必须每次渲染都一致


五、为什么不能写在 if / 循环里

如果把 Hook 放进条件分支,顺序会在不同渲染间错乱:

function App({ flag }: { flag: boolean }) {
  // ❌ 条件渲染导致 Hook 顺序变化
  if (flag) {
    React.useState(0)
  }
  React.useEffect(() => {})
}

flag 为 true 时链表是 useState -> useEffect,为 false 时变成只有 useEffect。React 按顺序取节点就会对不上,导致状态错乱。

function App() {
  // ✅ 顶层调用,顺序稳定
  const [count, setCount] = React.useState(0)
  React.useEffect(() => {
    console.log(count)
  }, [count])

  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

六、为什么不能在组件外调用

Hooks 内部靠一个全局指针 currentlyRenderingFiber 找到当前正在渲染的 Fiber,这个指针只在渲染函数组件的那一刻才被赋值:

function renderWithHooks(current, workInProgress, Component, props) {
  currentlyRenderingFiber = workInProgress // 渲染前赋值
  const children = Component(props) // 执行组件,此时 Hook 能拿到 fiber
  currentlyRenderingFiber = null // 渲染后清空
  return children
}

脱离渲染过程,这个上下文不存在,于是有三处依赖全部失效:

依赖说明缺失后果
Fiber 存状态Hook 链表挂在 fiber.memoizedState状态无处存放
Dispatcher 分发ReactCurrentDispatcher.current 区分 mount/update取到 null,直接抛错
触发重渲染setState 要通过 Fiber 发起调度状态变了也无法驱动 UI
// ❌ 组件外:currentlyRenderingFiber 为 null,报错
const [x] = useState(0)
// Invalid hook call. Hooks can only be called inside the body of a function component.

为什么自定义 Hook 能用? 因为它最终是被某个组件在渲染期间调用的,仍处在渲染上下文中,顺序也稳定:

function useUser() {
  const [user, setUser] = React.useState(null)
  return { user, setUser }
}

function App() {
  const { user } = useUser() // 在 App 的渲染过程中执行,上下文存在
  return <div>{user?.name}</div>
}

七、useEffect 与闭包陷阱

useEffect 的副作用在 commit 阶段执行(不是 render 阶段),deps 用 Object.is 逐项浅比较决定是否重新执行。

由于函数组件每次渲染都是全新作用域,effect 捕获的是当前渲染的变量快照,空依赖时会形成闭包陷阱:

function Timer() {
  const [count, setCount] = React.useState(0)

  React.useEffect(() => {
    // count 永远是首次渲染时的 0(闭包捕获了旧快照)
    const id = setInterval(() => console.log(count), 1000)
    return () => clearInterval(id)
  }, [count]) // 空依赖

  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

解决方式:函数式更新 setCount(c => c + 1)、用 useRef 存最新值,或补全依赖项。


八、useRef 原理

useRef 返回一个 { current } 对象,修改 current 不会触发重渲染,因为 React 不把它当作 state 变化。

function Stopwatch() {
  const timerRef = React.useRef<number | null>(null)

  const start = () => {
    // 改 current 不引发渲染,适合存定时器 id、DOM 引用
    timerRef.current = window.setInterval(() => {}, 1000)
  }

  return <button onClick={start}>开始</button>
}

九、最佳实践

  • 顶层调用:不在条件、循环、嵌套函数中调用 Hook。
  • 抽离逻辑:用自定义 Hook(useRequestuseUser)复用状态逻辑。
  • 不滥用 useEffect:能用派生值就别用 effect。
function Name({ first, last }: { first: string; last: string }) {
  // ❌ 不要用 effect 同步派生值
  // useEffect(() => setFullName(first + last), [first, last])

  // ✅ 直接计算
  const fullName = first + last
  return <span>{fullName}</span>
}
  • 慎用 useMemo / useCallback:它们有比较和内存成本,不是越多越好。
  • 依赖项完整:配合 eslint-plugin-react-hooks 自动检查。

核心三句话:Hooks 是挂在 Fiber 上的链表,React 靠调用顺序匹配每个 Hook 的状态。必须顶层调用,否则顺序错乱导致状态对不上;不能脱离组件,因为 Hooks 依赖渲染时才设置的 currentlyRenderingFiber 全局上下文。

On this page