Appearance
第七章:Hooks 的革命
7.1 Hooks 概览:设计动机与实现原理
自 2018 年在 React Conf 上发布以来,Hooks 无疑是 React 历史上最具革命性的特性。它彻底改变了 React 组件的编写方式,使得函数组件(Function Components)也能拥有状态、生命周期等原本只有类组件(Class Components)才具备的能力。本章将深入探讨 Hooks 的设计动机、核心实现原理以及常用 Hooks 的源码。
Hooks 的设计动机:为什么需要 Hooks?
在 Hooks 出现之前,React 社区主要依赖两种模式来复用组件间的有状态逻辑(stateful logic):高阶组件(Higher-Order Components, HOCs)和渲染属性(Render Props)。
虽然这两种模式功能强大,但它们都存在一些固有的问题:
包装器地狱(Wrapper Hell):使用 HOCs 或 Render Props 模式,尤其是在组合多个逻辑时,往往会导致组件树层级过深。在 React DevTools 中,你会看到一层又一层的 Provider、Consumer、Connect 等,这使得追踪数据流和调试变得异常困难。
jsx// 典型的 Wrapper Hell withRouter(withAuth(withTheme(MyComponent)));逻辑分散与混乱:在类组件中,相关的逻辑常常被迫分散在不同的生命周期方法中。例如,一个在
componentDidMount中设置的订阅,需要在componentWillUnmount中清除。而一个componentDidUpdate方法内,可能包含了多个互不相关的逻辑(例如,同时处理 props 变化和 state 变化)。这使得组件越来越难以理解和维护。类的复杂性:JavaScript 的
this关键字是学习的一大障碍。在类组件中,开发者需要时刻关注this的指向,并经常需要使用.bind(this)或箭头函数来确保事件处理函数中的this指向正确的组件实例。此外,类组件的语法也相对冗长。
Hooks 的诞生正是为了解决以上所有问题。 它提供了一种更直接、更简洁的方式来“钩入”(hook into)React 的状态和生命周期特性,而无需编写类。
Hooks 的核心实现原理:链表与调用顺序
一个常见的问题是:React 是如何在多次渲染之间,为一个普通的 JavaScript 函数(函数组件)保存状态的?为什么我们必须在顶层调用 Hooks,而不能在条件语句或循环中调用?
答案就隐藏在 react-reconciler 包的 ReactFiberHooks.js 文件中。其核心原理可以概括为:
对于每一个正在渲染的函数组件,React 都会在内部为其维护一个 Hooks 链表。每次调用 useState、useEffect 等 Hook 函数时,React 都会按顺序从这个链表中取出或创建对应的 Hook 节点。
让我们从 Hook 对象的类型定义开始看起:
javascript
// In packages/react-reconciler/src/ReactFiberHooks.js
export type Hook = {
memoizedState: any, // 当前 Hook 的状态值 (例如 useState 的 state)
baseState: any,
baseQueue: Update<any, any> | null,
queue: any, // 存储更新操作的队列
next: Hook | null, // 指向下一个 Hook 节点的指针
};这个结构清晰地展示了一个链表节点。memoizedState 存储了 Hook 的状态,而 next 属性则指向下一个 Hook,形成一个单向链表。
这个链表存储在哪里?
注释给出了答案:
Hooks are stored as a linked list on the fiber's memoizedState field.
一个函数组件的所有 Hooks 状态,都以链表的形式存储在该组件对应的 Fiber 节点的 memoizedState 属性上。
当一个函数组件**首次渲染(mount)**时,每调用一个 Hook(如 useState),React 就会创建一个新的 Hook 对象,并将其追加到链表的末尾。workInProgressHook 指针会随之移动。
当该组件**更新渲染(update)**时,React 会取出上一次渲染时构建的 currentHook 链表。然后,每当再次调用一个 Hook 时,React 就会从链表中取出对应的 Hook 节点(currentHook = currentHook.next),并读取其中的 memoizedState 作为当前的状态。
javascript
// In packages/react-reconciler/src/ReactFiberHooks.js
// 在组件渲染前设置的全局变量
let renderLanes: Lanes = NoLanes;
let currentlyRenderingFiber: Fiber = (null: any);
// 当前 Hook 链表 (来自上一次渲染)
let currentHook: Hook | null = null;
// 正在构建的 Hook 链表 (本次渲染)
let workInProgressHook: Hook | null = null;为什么必须保证 Hooks 的调用顺序?
正是因为 React 完全依赖于 Hooks 的调用顺序来识别每一个 Hook。它没有神奇的魔法去区分 useState() 和 useState(),它只知道“这是第一个 useState 调用,对应链表的第一个节点”,“这是第二个 useState 调用,对应链表的第二个节点”。
如果你在条件语句中调用 Hook,就可能导致在某次渲染中,Hooks 的调用顺序或数量发生变化。例如:
jsx
if (someCondition) {
useState(1); // 第一次渲染时 someCondition 为 true,此 Hook 被调用
}
useState(2); // 第一次渲染时,这是第二个 Hook如果第二次渲染时 someCondition 变为 false,第一个 useState 就不会被调用。当 React 执行到第二个 useState(2) 时,它会认为这是“第一个” Hook 调用,于是它会去读取链表的第一个节点的状态,导致状态错乱,这违背了 React 的设计假设,因此会立即报错。
这就是“Rules of Hooks”(Hooks 的规则)中“只在顶层使用 Hooks”这一条规则的技术原因。
在接下来的章节中,我们将深入分析 useState、useEffect 等常用 Hook 的源码,看看它们是如何与这个底层的链表结构进行交互的。