Skip to content

第八章:Hooks,没有实例的状态从哪里来

2018 年 React Conf 上,团队展示 Hooks;2019 年 2 月,React 16.8 正式发布。表面看,useState 只是把 class state 写得更短:

js
const [count, setCount] = useState(0);

但 Hooks 真正改变的是状态逻辑的组织单位。class 把 state、lifecycle 和方法绑定到组件实例;Hooks 让一个函数组件在每次 render 中按顺序声明多个状态记录和外部同步关系。逻辑可以被提取成 custom Hook,而不需要新增 wrapper component 或修改继承结构。

设计压力来自三类长期问题

Hooks RFC #68 把动机概括为三类:

  1. 逻辑复用依赖组件层级。 Mixin 有命名和隐式依赖问题,HOC/Render Props 会增加 wrapper tree。
  2. 复杂组件被 lifecycle 切碎。 同一订阅的建立与清理分散在 mount/unmount,同一 lifecycle 又混入多个无关业务。
  3. Class 给人和工具都增加成本。 this、binding、method 语义和热更新让学习与编译优化更困难。

Hooks 不要求删除 class,也不改变 JSX。它是 opt-in,并能与现有组件组合。这个渐进性让团队可以在不重写生态的前提下替换主流编程风格。

Hook 状态不在函数局部变量里

函数返回后局部变量会消失,useState 如何记住上次值?答案在 Fiber。

React 16.8 的 ReactFiberHooks.js 定义 Hook 记录,包含 memoizedState、更新队列和 next。一组 Hooks 以链表形式挂在当前 function component Fiber 的 memoizedState 上。

render 开始时,React 设置 currentlyRenderingFiberReactCurrentDispatcher.current:首次挂载使用 mount dispatcher,更新使用 update dispatcher。每调用一次 primitive Hook,运行时就取得或创建链表中的下一个记录。

text
FunctionComponent Fiber
  memoizedState


   Hook(useState) ──► Hook(useEffect) ──► Hook(useMemo) ──► null

用户代码没有传 ID。Hook 的身份来自“这是该组件本次 render 的第几个 Hook”。因此调用顺序不是 lint 风格偏好,而是运行时寻址方案。

Rules of Hooks 是数据结构的外部表现

若 Hook 可以放在条件分支里:

js
if (enabled) {
  useState(0);
}
useEffect(...);

第一次 enabled=true 时,链表第一个记录属于 state,第二个属于 effect;下一次为 false,useEffect 会读到原本 state 的位置。React 无法仅靠函数名恢复身份。

所以 Hooks 必须:

  • 只在 React function component 或 custom Hook 顶层调用。
  • 每次 render 按相同顺序调用。
  • 不能放进普通条件、循环或提前 return 之后。

开发环境会记录 Hook 类型并比较前后顺序,eslint-plugin-react-hooks 则在静态层面检查。RFC 明确把这种约束列为缺点,而不是假装它不存在。

本文判断:Hooks 用“结构化调用顺序”交换“显式实例字段和命名空间”。它让组合更直接,却把控制流限制提升为框架语义。

useState 是队列,不是一个可变格子

调用 setter 不会直接改当前闭包里的变量,而是向 Hook update queue 加入 action,并为 Fiber 安排工作。下一次 render,React 按优先级处理队列,得到新的 memoizedState

这解释了几个常见现象:

  • 同一次 render 中的 state 是快照。
  • 连续 setter 可能被批处理。
  • 函数式 updater 能基于队列中的前一个结果计算。
  • 低优先级更新可能被跳过,稍后再重放。
  • render-phase update 需要单独队列和重渲染上限。

React 16.8 源码甚至有 RE_RENDER_LIMIT = 25,防止组件在 render 中持续安排更新形成无限循环。Hooks 的简洁 API 背后仍是 Fiber scheduler 与 update queue。

Effect 把生命周期按“同步关系”重组

Class lifecycle 按组件阶段组织:所有 mount 逻辑放在一起,所有 update 逻辑放在一起。useEffect 按业务关系组织:建立一个外部同步,同时返回它的清理函数。

js
useEffect(() => {
  const connection = connect(roomId);
  return () => connection.disconnect();
}, [roomId]);

这段代码把创建和销毁放在同一闭包,React 在依赖变化后先清理旧 effect,再执行新 effect。多个无关关系可以拆成多个 effect,不必挤在一个 lifecycle 方法里。

但 effect 不是“任何 render 后代码”的垃圾桶。它用于让 React 状态与外部系统同步。把纯派生数据、事件响应或状态链式变换都放进 effect,会增加额外 render,并形成难以追踪的时间依赖。React 后来的文档不断强调“You Might Not Need an Effect”,19.2 又用 useEffectEvent 区分 effect 中的事件语义。

Dependency Array 暴露了闭包时间模型

函数组件每次 render 都创建新的闭包。一个 effect 看到的是它所属 render 的 props/state。若依赖数组遗漏值,effect 可能长期保留旧闭包;若每次都创建不稳定对象,effect 又会频繁重跑。

RFC 把 stale closure 明确列为缺点。exhaustive-deps lint rule 尝试让依赖与闭包读取保持一致,但它不能自动判断业务意图:某段逻辑到底是持续同步,还是只在一个事件发生时读取最新值。

Hooks 因而把以前隐藏在 class 实例可变字段里的时间问题显式化。Class 方法读取 this.state 常得到“现在”的值;函数闭包则保存“这次 render”的值。前者容易出现异步回调观察到意外新状态,后者容易出现旧闭包。两者不是谁没有时间问题,而是快照语义不同。

RFC 没有掩盖代价

Hooks RFC 的 Drawbacks 包括:

  • 学习概念增加。
  • 调用顺序规则可能让某些重构困难。
  • event handler 每次 render 重新创建,可能降低 PureComponent 效果。
  • 闭包可能捕获旧 props/state。
  • React 内部需要模块级当前 render 状态。
  • useMemo 不能在条件后自由使用。
  • tuple 返回需要由调用者命名。

这份清单很有价值,因为 Hooks 后来的流行容易让设计显得必然。实际上团队比较过继续使用 class、语言级语法、render props、HOC、mixin 等方案。Hooks 是在 JavaScript 不增加新语法、保持渐进兼容、并为未来编译器留空间的条件下做的选择。

Custom Hook 复用的是逻辑,不是状态实例

两个组件调用同一个 custom Hook,不会共享状态;每次调用都会在各自 Fiber 上建立独立 Hook 记录。Custom Hook 共享的是“如何组合 primitive Hook”的代码。

这与 HOC 的差别很大:custom Hook 不创建额外 React 节点,不截获 props,也不改变 ref。它可以返回值和函数,由调用组件决定 UI。DevTools 后来需要额外解析 Hook 调用栈,才能把这类逻辑边界展示出来。

共享同一份状态仍需 context、外部 store 或提升 state。Hooks 没有让所有状态管理消失,只提供了统一接入方式。

Hooks 与 Compiler 的长期关系

React Compiler 1.0 的官方回顾称,早期 Prepack 研究为 Hooks 设计提供了经验,Hooks 也考虑了未来编译优化。稳定的调用规则、纯 render 和显式依赖关系,使编译器更容易分析组件中的数据流与可变性。

不过 Hooks 不是为 Compiler 独占设计的中间语言。它首先解决真实的逻辑复用和 class 体验问题。Compiler 后来能够自动 memoize,是因为 Hooks 与 Fiber 已经建立了一套相对可分析的 reactive function 约束。

本文判断:Hooks 把 React 的“组件是计算”理念推得更彻底。class 组件像一个长期存在的可变对象;function component 更像一次次产生快照的计算,而状态由 React 在计算外部保存。

Hooks 发布时,Fiber 的并发能力仍未成为默认产品。接下来几年,团队会经历 Async Rendering、Concurrent Mode、Suspense for Data Fetching 等多次命名和 API 调整。下一章讨论为何最终发布的不是一个开关式“并发模式”,而是一组各自可采用的并发 feature。

Engineering history, reconstructed from primary sources.