钩子规则存在的原因:纤维、钩子列表与调度器
React 内部结构详解:Fiber 节点、双缓冲机制、执行通道、钩子链表,以及各类内置钩子家族如何存储状态并安排任务执行。
大多数 React 开发者都能背诵钩子规则,但很少有人能解释为何违反这些规则会导致状态损坏,而非仅仅抛出有用的错误。答案在于 React 的内部机制:每次调用钩子都会在 Fiber 中生成一个链表节点,React 是根据钩子的调用顺序来定位每个节点的。本指南将详细介绍 Fiber、钩子对象、React 在渲染过程中切换的调度器,以及各类钩子的内部工作原理,这样这些规则就不再显得随意,而是会呈现出设计上的必然结果。
编写表单或获取数据时并不需要这些知识,但在调试过时的值、在 useEffect 和 useLayoutEffect 之间做选择,或疑惑为何被缓存的子组件仍会重新渲染时,这些知识就会派上用场。
简要回顾:钩子取代了什么以及它们伴随的规则
Hooks在React 16.8中引入,目前内置的钩子数量已增至约十七个。它们解决了类组件长期存在的三个问题:带状态的逻辑难以在多个组件之间复用,相关逻辑分散在各种生命周期方法中导致组件变得臃肿,以及JavaScript类本身(如this绑定、生命周期理解)让许多开发者感到困惑。
随着钩子的出现,也有一套规则随之产生:
- 在函数组件体的最顶层调用钩子。
- 在自定义钩子体的最顶层调用钩子。
- 绝不在条件语句或循环内部调用钩子。
- 绝不在条件判断后的提前
return语句之后调用钩子。 - 绝不在事件处理函数内部调用钩子。
- 绝不在类组件中调用钩子。
useEffect、useMemo 或 useReducer 的回调函数中调用钩子。try、catch 或 finally 块中调用钩子。违反这些规则会导致警告、错误,甚至更严重的隐蔽漏洞。简而言之,组件的钩子会形成一条单链表,悬挂在 React 为每个组件维护的带状态 JavaScript 对象——Fiber 节点上。要理解这一点的重要性,可以先从 Fiber 本身入手。如果您已经很熟悉它,可以直接跳到关于钩子对象的部分。如需更通俗地了解同步与状态机制,可参阅 为 React 的同步、状态和钩子构建思维模型。
React Fiber:钩子所在的引擎
Fiber是React的协调引擎,于React 16版本推出,它彻底重写了React计算和应用UI更新的方式。
旧版堆栈协调器的问题
在Fiber出现之前,React使用的是所谓的堆栈协调器。每次更新时,它都会递归遍历组件树,而JavaScript调用堆栈上的递归遍历无法中途暂停:一旦开始就会一直运行直到整个树都被处理完毕。对于占据单个主线程的大型组件树,这会导致动画卡住、按键响应延迟以及界面出现卡顿。更糟糕的是,没有办法让诸如按键这样的紧急更新优先于正在进行的那些规模较大但重要性较低的渲染操作。
Fiber带来的变革
Fiber是一种简单的JavaScript对象,代表一个工作单元,与某个组件实例或DOM节点相关联。由于React直接跟踪这些工作单元而非依赖调用栈,因此它具备了以下三种能力:
- 暂停与继续。React可以在处理过程中暂停,让浏览器去处理更紧急的任务(如用户输入),之后再从同一位置继续。
- 优先级控制。紧急的更新可以优先于不太重要的更新被处理。
- 重用或丢弃。如果在渲染进行时用户切换了页面,未完成的工作可以直接被舍弃。
Fiber节点的结构
每一个React元素,无论是组件、宿主DOM元素还是文本节点,都对应一个Fiber对象。这个大型对象包含了组件的属性、状态以及指向其DOM表示的链接。
纤维节点并非通过数组来存储子节点,而是通过三个指针构成树结构:
child指向该纤维节点的第一个子节点。sibling指向同一层级中的下一个纤维节点。return指向父节点。
处理更新操作时需沿着这些指针前进:尽可能通过 child 向下遍历,通过 sibling 横向移动,当某个分支处理完毕后再通过 return 返回上层。由于这只是对指针的普通循环而非递归,React 可以在任意两个纤维节点之间停止。
利用两棵树实现双缓冲
Fiber 从图形编程中借鉴了双缓冲技术。React 在内存中始终保存着两棵纤维节点树:
- 当前树完全反映屏幕上的内容。React在计算更新时不会修改它。
- 处理中树会在内容发生变化时在后台构建。React会复制需要更新的当前fiber,并将新版本与旧版本并列放置。
当处理中树构建完成之后,React会切换根指针。此时处理中树变为当前树,屏幕也会随之显示新状态。每个fiber都会保存一个指向另一棵树中对应节点的alternate指针,这就是hook状态在多次渲染之间得以传递的方式。
渲染阶段与提交阶段
这种架构将每次更新分为两个阶段。
渲染阶段是可以被中断的。React会遍历组件树,调用组件的函数并执行钩子,在内存中构建临时工作树的同时将结果与当前树进行对比。由于React掌控着遍历流程,其调度器可以每隔几毫秒就将控制权交还给浏览器。如果在低优先级的渲染正在进行时用户输入了内容,React可以暂停当前操作、处理输入后再继续渲染。当有更新更紧急的内容出现使得临时工作树不再适用时,React甚至可以直接丢弃它。因为渲染过程可能会重复执行多次,也有可能根本不会完成,所以组件函数必须是纯函数:在渲染过程中不得产生任何副作用。
提交阶段是同步的。一旦WIP树处理完成,React会一次性将计算出的变更应用到真实的DOM中。这一步无法暂停,因为如果在DOM修改过程中中途停止,就会向用户展示半更新、不一致的界面。布局效果在此处执行,被动效果从这里安排调度,引用也会被附加到这里。
通道:React如何决定中断优先级
为了解决哪些操作可能被其他操作中断的问题,React会给每次更新标记一个通道。这些通道以位掩码中的比特形式表示,从而使得优先级的组合与比较变得高效。大致可分为:
- 同步通道,用于处理离散且紧急的交互操作,如点击和按键(而像悬停或滚动这类连续事件则有自己的高优先级通道)。
- 过渡通道,用于处理可被中断的任务:后台更新、基于数据驱动的重新渲染、标签页切换等。
你从未直接操作过 Fiber,但它却是 React 18 和 19 的核心功能基础。并发渲染、Suspense、useTransition 以及 useDeferredValue 全部依赖于可中断的渲染机制。
钩子对象及调用顺序为何至关重要
函数组件没有用于存储状态的实例,因此 React 会将其保存在 Fiber 中一个名为 memoizedState 的字段中。对于函数组件而言,该字段指向单链表中的第一个钩子。
渲染过程中的每次钩子调用都会对应一个大致结构如下的对象:
{
memoizedState: any, // The internal state of the hook
baseState: any, // The state before any unprocessed updates
baseQueue: Update | null, // Updates that were skipped due to priority
queue: UpdateQueue | null, // Circular linked list of pending state updates
next: Hook | null // Pointer to the next hook in the component
}
钩子的memoizedState用于存储其对应的值(即useState的状态、useEffect的效应记录、useMemo的缓存结果);queue用于保存待处理的更新;baseState和baseQueue则用于记录因当前处理流程未涉及而跳过的更新;而next则用于指向下一个钩子。
注意缺少了什么:没有键或名称。在重新渲染时,React会从头开始遍历列表,将第一个钩子调用与第一个节点配对,第二个钩子调用与第二个节点配对,以此类推。如果在if语句中调用钩子且条件发生变化,后续的所有调用都会与错误的节点配对,从而导致一个钩子的状态泄露到另一个钩子中。这正是“钩子规则”存在的核心原因:它们确保在每次渲染时都以相同的顺序执行相同的钩子。关于循环、提前返回、try块以及回调的规则,其实都是对同一要求的不同体现。
有一个现代例外情况印证了这一原则:React 19中的use API可以条件性地调用,正是因为它并不以同样的方式依赖列表中的某个位置。
调度器:相同的钩子名称,不同的实现方式
你导入的 useState 实际只是一个简单的封装层。在运行时,它会将请求转发给 React 当前阶段所配置的调度器。旧版本的 React 通过 ReactCurrentDispatcher 提供这一功能;而在较新版本中,调度器存在于 React 的内部共享对象中,但原理依然相同。
- 在首次渲染时,HooksDispatcherOnMount 会处于激活状态。
useState会对应到mountState,后者会创建一个新的钩子对象,设置其初始状态,并将其添加到列表末尾。 - 在重新渲染时,HooksDispatcherOnUpdate 会处于激活状态。
useState会对应到updateState,后者会遍历现有的列表(实际上相当于执行workInProgressHook = workInProgressHook.next),处理待处理的队列,然后返回新的状态。
这种设计也解释了为何在事件处理程序中调用钩子会失败:因为当处理程序执行时,渲染已经完成,此时处于激活状态的正是那个会抛出异常的调度器。
各钩子家族的内部工作原理
所有钩子都基于链表结构,但它们在存储的内容以及执行时机上存在很大差异。
状态钩子:useState和useReducer
从内部结构来看,useState实际上就是带有内置还原器的useReducer,该还原器要么直接返回新值,要么将旧值传递给用户定义的更新函数。两者采用相同的执行模式:
- 存储。该钩子会维护一个基础状态(最后已提交的值)以及一个更新队列,后者是由待处理变更构成的循环链表。
- 调度。调用某个设置函数,例如
setCount(c => c + 1),会创建一个包含该操作信息的更新对象,将其添加到队列中,并通过分配执行通道来标记该任务需要处理(旧版本则使用过期时间来实现相同功能)。 - 执行。在下次渲染时,React会按顺序遍历队列并执行每个操作,从而生成新的
memoizedState。那些所属执行通道未被当前渲染包含的更新会被保存在baseQueue中,稍后重新执行,这样就能保证不同优先级任务的执行顺序。
这也正是为什么当下一状态依赖于前一状态时,更新函数是更安全的选择:它们会按顺序作用于队列目前计算出的任何状态。
效应钩子:useInsertionEffect、useLayoutEffect 和 useEffect
每个效应钩子都会在其钩子状态中存储一个效应记录,其中包含设置函数、清理函数以及依赖项数组。这些记录还会被链接到纤维节点的 updateQueue 中的一个独立列表上,那些依赖项发生变化的效应会被标记出来,以便提交阶段知道需要运行哪些效应。这三个钩子在执行时机上存在差异:
- useInsertionEffect会在布局效果之前执行,位于任何可能读取布局信息的代码之前。它存在于那些需要尽早注入
<style>规则的CSS-in-JS库中,这样在测量布局时样式就已经就位,从而避免重复计算样式。 - useLayoutEffect会在React修改DOM之后、浏览器有机会绘制之前同步执行。主线程会被阻塞,直到该效果及其清理操作完成,因此它适合在用户看到任何内容之前对DOM节点进行测量和调整,但不适用于更复杂的操作。
MessageChannel,若不可用则退而使用 setTimeout),因此不会延迟视觉更新。需要注意的是,当更新来自用户的明确操作时,React 可能会在绘制之前执行这些被动效应。性能钩子:useMemo 和 useCallback
它们是缓存机制,可避免昂贵的重新计算,或在多次渲染之间保持引用稳定。
- 存储。该钩子会存储一对数据:缓存的值以及用于计算该值的依赖数组。
- 执行过程。在重新渲染时,React会使用
Object.is将每个新的依赖项与缓存中的进行比较。如果全部匹配,则不会调用工厂函数,而是直接返回缓存值;如果有任何差异,React就会调用工厂函数,存储新的值和依赖项,然后返回结果。 - useCallback与
useMemo(() => fn, deps)功能相同:它保留的是你传入的函数对象,而非调用该函数后产生的值。React的源代码中是分别实现这两者的,但行为一致。
由于比较仅是浅层比较,如果每次渲染时依赖项都是新创建的对象或数组,那么缓存功能就完全失去了作用。
可变值钩子:useRef和useImperativeHandle
Refs用于存储不参与渲染的信息,比如DOM节点或定时器ID。
- useRef 是代码库中最简单的钩子。在组件挂载时,它会创建
{ current: initialValue }并将其作为钩子的状态存储;后续的每次渲染都会返回同一个对象。对current的写入不会影响更新队列或执行路径,因此不会触发渲染。 - useImperativeHandle 通过为 ref 添加自定义方法,来控制父组件能够访问的内容。其内部机制与
useLayoutEffect类似:会在状态提交时同步执行,这样当父组件的效果函数运行时,该 handle 已经准备就绪。在 React 19 中,函数组件可以直接将ref作为普通属性接收,因此不再需要forwardRef来使用它。
上下文钩子:useContext
useContext 的独特之处在于它从未在钩子列表中占据任何位置。
- 读取。它会从组件上方的最近匹配的 Provider 中读取值,并将该上下文记录在纤维的依赖列表中。
- 传播。当某个 Provider 的值发生变化时,React 会在其下方的、依赖该上下文的纤维中查找,并安排它们重新渲染。即便中间组件通过
React.memo或shouldComponentUpdate而跳过渲染,这一过程依然会发生,这就是为什么对父组件进行记忆化处理也无法保护使用该上下文的组件。
并发钩子:useTransition 和 useDeferredValue
这两者让你能够控制渲染通道系统,从而中断耗时的渲染过程。
- useTransition 返回
[isPending, startTransition]。在startTransition(() => setQuery(text))内部进行的更新会被分配到过渡队列而非紧急队列。如果在过渡渲染过程中有点击或按键事件发生,React 会放弃正在处理的半完成树,先处理紧急更新,然后再从头开始重新启动过渡过程。 - useDeferredValue 是用来包裹值而非设置器。React 实际上会保留两个版本:首先使用旧值进行渲染以确保界面响应正常,随后再安排在后台以较低优先级使用新值进行渲染。
专用钩子:useId 和 useSyncExternalStore
有些钩子在应用程序代码中很少出现,但对库的开发者来说却非常重要。
- useId 能避免服务器端渲染时的数据同步问题。它会根据组件在组件树中的位置生成一个 ID。由于在初始数据加载阶段,服务器端与客户端上的组件树结构相同,因此无需全局计数器即可使这些 ID 匹配。
- useSyncExternalStore 可以替代手动编写的用于连接 Redux 或 Zustand 等外部存储的
useEffect代码。使用时需要提供两个函数:一个用于在存储上注册变更监听器,另一个是getSnapshot,用于获取存储的当前值。React 在渲染时会读取该快照,如果存储在渲染过程中发生变化,它会同步重新渲染,从而确保界面的各个部分显示的存储状态一致,避免并发渲染可能导致的界面不一致问题。
核心要点
- 钩子状态是纤维中的一个链表,仅通过调用顺序来匹配;每个钩子规则的存在都是为了在多次渲染中保持该顺序不变。
- 纤维将渲染过程转化为可中断的循环,而双缓冲机制则让 React 能够在不影响屏幕上内容的情况下准备新的节点结构。
- 渲染阶段可能会多次执行,且必须保持纯函数特性;而提交阶段则仅同步执行一次,正是在此阶段触发各种效果。
- 调度器既解释了组件挂载/更新的分离机制,也说明了在渲染之外调用钩子时会出现的错误。
- 了解每个钩子存储数据的位置以及其执行时间,有助于选择合适的效果触发时机、保持记忆化机制的有效性,并更好地理解上下文与并发更新的问题。
相关阅读
- useState 的值为何能保留:React Elements 与 Fibers —— 为什么 React 函数组件会在多次调用之间忘记所有状态,为什么元素无法存储状态,以及 fiber 的 memoizedState 字段是如何让 useState 的值保持有效的。
- React Hooks 的类型定义:useState、useEffect、useReducer 与自定义 Hook —— 学习如何在 TypeScript 中正确地为 useState、useEffect、useReducer 以及自定义 Hook 定义类型,同时了解何时选择 TypeScript 而非普通 JavaScript 更为合适。