【React 进阶】揭秘 Hooks 原理:你的 useState 状态存在哪了?

157 阅读8分钟

image.png

前言:告别 Class,拥抱 Hooks

如今,React Hooks 已经成为函数式组件(Function Components)开发的主流方式。相比于曾经的 Class 组件,Hooks 以其简洁的语法、强大的逻辑复用能力以及更符合函数式编程思想的特点,赢得了广大开发者的青睐。我们熟练地使用 useState 来管理状态,用 useEffect 处理副作用,用 useContext 共享数据…

但是,你是否曾好奇过:

  • 函数组件每次渲染不都是重新执行一遍吗?那 useState 的状态是如何在多次渲染之间保存下来的?它不像 Class 组件那样有 this 实例啊。
  • useEffect 是如何精确地知道何时执行副作用,又如何进行依赖比较的?
  • 为什么 React 官方反复强调 Hooks 的使用规则?比如“只能在顶层调用 Hooks”、“只能在 React 函数或自定义 Hook 中调用”?这些规则背后隐藏着什么秘密?

如果你也有同样的疑问,那么这篇文章将带你深入 React Hooks 的内部,揭开它神秘的面纱,理解其核心工作原理。知其然,更要知其所以然,这样才能更自信、更高效地使用 Hooks。

一、状态的“记忆”:Fiber 节点与 Hooks 链表

要理解 Hooks 如何保存状态,我们首先需要了解 React 的一个核心概念:Fiber 架构

简单来说,React 在进行协调(Reconciliation)时,会为我们编写的每一个组件(无论是 Class 还是 Function)创建一个内部的工作单元,叫做 Fiber 节点。这个 Fiber 节点就像是组件的“身份证”和“档案袋”,它记录了组件的类型、props、state、DOM 节点信息,以及与其他 Fiber 节点的关系(父、子、兄弟)。

关键点来了:对于函数组件,React 将 Hooks 的信息(状态、副作用等)存储在与其对应的 Fiber 节点上!

那么,具体是怎么存储的呢?React 在 Fiber 节点内部维护了一个数据结构(可以理解为一个有序链表数组),用来按顺序存放该组件中所有 Hooks 的信息。

我们以 useStateuseEffect 为例,看看具体流程:

1. 首次渲染 (Initial Render)

当你的函数组件第一次被渲染执行时:

  • 遇到第一个 useState(initialValue):React 会创建一个 Hook 对象/节点,存入初始值 initialValue,并将这个 Hook 对象添加到 Fiber 节点关联的 Hooks 链表的末尾。然后返回 [initialValue, dispatchFunction]
  • 遇到第一个 useEffect(effectFn, deps):React 同样创建一个 Hook 对象,记录下副作用函数 effectFn 和依赖项 deps,添加到链表末尾
  • 遇到第二个 useState(...):创建新的 Hook 对象,添加到链表末尾
  • 以此类推…

重点: Hooks 按照它们在组件代码中被调用的顺序,依次添加到这个内部链表中。

2. 后续渲染 (Re-render)

当组件因为状态更新(比如调用了 useState 返回的 setState 函数)或者父组件重新渲染而再次执行时:

  • React 会找到对应的 Fiber 节点,并拿出上次渲染时构建好的那个 Hooks 链表。同时,它内部会维护一个指针(或索引) ,初始指向链表头部。
  • 遇到第一个 useState(...):React 不会创建新的 Hook 对象,而是从链表中按顺序取出第一个 Hook 对象,读取其中存储的当前状态值,并返回 [currentState, dispatchFunction]。然后,指针移到下一个节点。
  • 遇到第一个 useEffect(...):React 取出链表中对应的 Hook 对象,比较新的依赖项 deps 和上一次存储的依赖项。如果依赖项发生变化(或者首次渲染),则标记该副作用函数需要执行。然后,指针移到下一个节点。
  • 遇到第二个 useState(...):取出链表中第二个 Hook 对象,返回其当前状态…
  • 以此类推…

核心机制总结: React 通过 Fiber 节点 将 Hooks 的状态和组件实例关联起来,并通过一个内部有序链表(或数组) 来存储这些 Hooks 的信息。每次渲染时,React 严格按照 Hooks 的调用顺序,依次从这个链表中读取或更新对应 Hook 的数据。

image.png

二、规则的“枷锁”:为何必须遵守 Hooks 规则?

理解了上述原理,React Hooks 的两条核心使用规则就变得顺理成章了:

规则 1:只能在函数组件的顶层调用 Hooks,不能在循环、条件判断或嵌套函数中调用。

  • 原因: 正是因为 React 依赖稳定且可预测的调用顺序来查找和关联 Hook 状态。如果在条件语句(if)、循环(for/while)或嵌套函数中调用 Hooks,那么在不同的渲染中,Hooks 的调用顺序可能发生改变

  • 后果:

    javascript复制代码
    function MyComponent({ showAge }) {
      const [name, setName] = useState('Alice'); // Hook 1
    
      if (showAge) {
        // !!! 错误示范:在条件语句中调用 Hook !!!
        const [age, setAge] = useState(30); // Hook 2 (有时存在,有时不存在)
      }
    
      const [count, setCount] = useState(0); // Hook 3 (有时是第 2 个被调用,有时是第 3 个)
    
      // ...
    }
    

    在上面的例子中,如果 showAgetrue 变为 false,第二次渲染时:

    1. useState('Alice') 仍然是第一个 Hook,React 正确取出 name 的状态。
    2. if 条件不满足,第二个 useState 不会被调用
    3. 执行到 useState(0) 时,React 期望这是第二个 Hook 调用,但它实际上对应的是上一次渲染中的第三个 Hook (count) 的状态!React 就会错误地将 count 的状态当做 age 的状态(如果之前 age 存在的话),或者直接报错,因为链表顺序和长度对不上了。
  • 结论: 保持 Hooks 调用顺序的绝对稳定,是保证 React 能正确匹配状态的关键。

规则 2:只能在 React 函数组件或自定义 Hook 中调用 Hooks。

  • 原因:  Hooks 的状态是与特定的组件 Fiber 节点相关联的。

    • 在普通的 JavaScript 函数中调用 useState,React 根本不知道这个状态应该“挂载”到哪个组件实例上,没有对应的 Fiber 节点上下文。
    • 自定义 Hook (以 use 开头的函数) 本身并不是组件,但它们必须在函数组件内部被调用。当你在组件 MyComponent 中调用自定义 Hook useMyHook 时,useMyHook 内部调用的 useState 或 useEffect,其状态实际上是注册在 MyComponent 的 Fiber 节点的 Hooks 链表中的,遵循着整体的调用顺序。

三、闭包的“魔法”:useEffect 与 Stale Closure

Hooks,尤其是 useEffect,与 JavaScript 的闭包概念紧密相关。

  • 闭包:  函数能够“记住”并访问其词法作用域(即定义时的作用域),即使该函数在其词法作用域之外执行。

  • useEffect 与闭包:  你传递给 useEffect 的那个副作用函数,它会捕获(“记住”)其定义时所在作用域中的 props 和 state

    javascript复制代码
    function Counter() {
      const [count, setCount] = useState(0);
    
      useEffect(() => {
        // 这个函数就是一个闭包
        const intervalId = setInterval(() => {
          console.log(`Current count is: ${count}`); // 读取定义时捕获的 count
        }, 1000);
        return () => clearInterval(intervalId);
      }, []); // 空依赖数组,意味着 effect 只在首次渲染后运行一次
    
      // ... render
    }
    
  • Stale Closure (过时闭包) 问题:  在上面的例子中,如果 count 发生变化,但由于依赖数组是 [],effect 不会重新执行,setInterval 中的回调函数依然打印的是首次渲染时捕获的 count 值 (0) ,而不是最新的 count。这就是典型的“过时闭包”问题。

  • 依赖数组的作用:  useEffect 的依赖数组 deps 正是用来解决这个问题的。React 会比较每次渲染时 deps 中的值。如果发生变化,React 会:

    1. 执行上一次 effect 返回的清理函数(如果存在)。
    2. 重新执行 effect 函数。这次执行会形成新的闭包,捕获到当前渲染周期的 props 和 state

理解闭包有助于我们正确设置 useEffect 的依赖项,避免因捕获到过时的变量而导致的 bug。

总结:拨开云雾见月明

现在,我们再回头看开篇的问题:

  • useState 状态如何保存?  -> 存储在与函数组件关联的 Fiber 节点上的有序 Hooks 链表/数组中。
  • useEffect 如何工作?  -> 同样存储在链表中,React 根据调用顺序找到它,并根据依赖项比较结果决定是否执行副作用函数(及其清理函数)。副作用函数通过闭包访问组件状态。
  • 为何有 Hooks 规则?  -> 为了保证每次渲染时 Hooks 的调用顺序绝对稳定,从而让 React 能正确地从内部链表中找到对应的 Hook 数据。

React Hooks 的设计确实精妙,它在不引入 this 的情况下,利用 Fiber 架构和对调用顺序的巧妙依赖,实现了在函数组件中管理状态和副作用的能力。理解其原理,不仅能帮助我们写出更可靠的代码,也能在遇到问题时更快地定位和解决。

希望这篇文章能帮助你对 React Hooks 有更深入的认识!

你对 Hooks 的原理还有哪些疑问?或者有什么有趣的发现?欢迎在评论区交流讨论! 👇👇👇