React Hook 原理

聊聊 Hook 到底是怎么工作的

先抛结论:Hook 就是通过闭包和链表这两个"法宝",在函数组件的多次渲染之间帮我们"记住"状态和副作用,而且非常依赖调用顺序。

听起来有点绕?没关系,往下看就懂了。

1. Hook 为啥不能乱顺序调用?

React 官方文档里反复强调:别在循环、条件判断、嵌套函数里调用 Hook。这规矩看起来烦人,但它其实是理解 Hook 原理的关键钥匙。

底层原理:链表来管理

React 内部用一条单向链表来存一个组件里的所有 Hook。每个 Hook 节点都带着自己的状态(state、effect 函数、依赖项等)。

怎么工作的呢?举个例子:

function MyComponent() {
  const [a, setA] = useState('A')   // 链表的第 1 个节点
  const [b, setB] = useState('B')   // 第 2 个节点
  useEffect(() => { ... }, [])      // 第 3 个节点

  return <div>{a}{b}</div>
}
  • 第一次渲染:React 按顺序把三个 Hook 串成链表:Hook0 -> Hook1 -> Hook2
  • 后续渲染:React 接着这个链表,按完全相同的顺序一个一个取。useState('A') 永远对应第一个节点,useState('B') 永远对应第二个——就像排队一样,顺序不能乱。

顺序乱了会怎样?

假如你在条件语句里调用 Hook:

function MyComponent({ show }) {
  if (show) {
    const [a, setA] = useState("A"); // 有时调用,有时不调用
  }
  const [b, setB] = useState("B");

  return <div>{b}</div>;
}

第一次 show = true 时,链表里有两个 Hook。但第二次 show = false 时,只有一个 Hook —— 链表对不上号了!React 就会把 b 的状态错塞到 a 的位置上,张冠李戴,各种奇奇怪怪的 bug 就来了。

所以 Hook 的核心就是:顺序稳,泰山稳。

2. useState 是怎么"记住"状态的?

这里有个关键点要搞清楚:函数组件本身没有"实例",它的状态是存放在 React 内部的 Fiber 节点上的。

状态存在哪?

每个组件在 React 内部都对应一个 Fiber 节点(你可以理解为 React 用来描述组件的数据结构)。Fiber 节点上有个 memorizedState 属性,里面就是 Hook 对象的链表。

具体流程

  1. 首次渲染:调用 useState('A')

    • React 创建 Hook 对象 { memorizedState: 'A', queue: [] }
    • 把它挂到 Fiber 节点的链表末尾
    • 返回 [currentState, dispatchAction],也就是 [值, set函数]
  2. 触发更新:调用 setState(newValue)

    • React 把这个"更新动作"放进 Hook 的更新队列
    • 标记组件需要重新渲染
  3. 再次渲染:组件重新执行 useState

    • React 顺着链表找到同一个 Hook 对象
    • 把队列里的更新全部处理掉,算出最新的 state
    • 返回新的 [currentState, setState]

说白了,每次渲染时 useState 并不是真的"记住"了什么,而是 React 通过闭包机制,让函数能准确找到上次那个 Hook 节点,然后把最新状态吐出来。

3. useEffect 是怎么玩转副作用的?

useEffect 的核心就是依赖项数组——它决定了这个 effect 什么时候该重新跑。

执行时机

useEffect 的回调函数不是在渲染时同步执行的,而是等浏览器把 DOM 画完之后才异步执行。这样就不会卡住页面的绘制,体验更好。

依赖项是怎么工作的?

useEffect(() => {
  console.log("执行了");
}, [count]);
  1. 渲染时,React 拿到 deps = [count]
  2. 跟上一次存储的 deps浅比较
  3. 如果发现依赖项变了,就安排执行新的 effect;否则该干嘛干嘛

几种情况:

  • [] 空数组:只在组件挂载时执行一次,之后卸载时执行清理函数。因为每次比较都是 [] === [],永远相等,就不重新跑了。
  • [count] 有依赖:每次 count 变化,浅比较发现不等,就重新执行。
  • 不写第二个参数:每次渲染都执行,因为 undefined 跟任何东西比较都不等。

清理函数是咋回事?

useEffect(() => {
  const timer = setInterval(() => {
    console.log("tick");
  }, 1000);

  return () => clearInterval(timer); // 清理函数
}, []);

清理函数会在两种情况执行:

  1. 组件卸载
  2. effect 重新执行前(先清理旧的,再执行新的)

4. Hooks 和 Fiber 是怎么配合的?

Fiber 是 React 16+ 引入的新协调引擎,Hook 们就是寄生在 Fiber 节点上的。

  • 每个组件对应一个 Fiber 节点,节点的 memorizedState 指向 Hook 链表的头
  • 渲染阶段:React 设置一个"当前Fiber"的指针和一个"当前Hook"的指针。每次调用 Hook,就顺着链表往下走一个
  • 提交阶段:渲染完了,DOM 准备好之后,React 才去执行那些被调度好的 effect

所以 Hook 和 Fiber 是紧密绑定的——Fiber 提供存储结构,Hook 提供操作接口。

面试怎么说?

记住这几点就够了:

  1. 一句话定调:Hook 本质是通过稳定调用顺序,在 Fiber 节点的链表里存取状态和副作用

  2. 强调顺序和链表:顺序是 Hook 的根基,链表是实现基础

  3. 区分存储位置:state 存在 Fiber 节点上,不是函数变量里

  4. useState 重点:更新队列、基于旧状态计算新状态

  5. useEffect 重点:依赖项浅比较、清理函数执行时机

  6. 解释常见现象

    • 闭包陷阱:每次渲染都是独立的函数调用,effect 回调捕获的是那次渲染的 state。依赖项数组就是来解决这个的
    • Hook 规则:就是为了保链表顺序不乱