首页 / 文章 / useState 如何保存其值:React 元素与 Fiber 的对比

useState 如何保存其值:React 元素与 Fiber 的对比

为什么 React 函数组件会在多次调用之间忘记所有内容,为什么元素无法保存状态,以及 Fiber 的 memoizedState 字段是如何让 useState 的值保持有效的。

1724 词

函数组件本质上只是一个函数,而函数在返回后会立即忘记其局部变量。然而 useState 却会在每次渲染时返回更新后的值,仿佛该函数还记得这些变量。本文将精准回答一个具体问题:在两次渲染之间,那个值实际上存储在哪里?读完之后,你将能够区分 React 为每个组件生成的两种对象——元素和纤维,并解释哪种对象承载状态以及原因。

谜题:没有记忆的函数

让我们从最熟悉的组件——计数器开始:

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

点击一次显示1,再点击一次显示2。这没什么奇怪的,直到你从纯JavaScript的角度来看待它。Counter是一个函数。每次调用该函数时,它的局部变量都会从无到有地创建,而当函数返回时这些变量又会消失。再次调用则一切重新开始。

按照这种逻辑,React第二次调用Counter()时,useState(0)应该再次生成0。但实际上它生成的是1。一定有某种存在于函数之外的机制在保存着上一次调用时的数值。不可能是函数体本身,因为函数的运作方式根本不是如此。那么究竟是什么呢?

排除元素的可能性

有一个显而易见的候选答案,但事实证明它是错误的,排除它之后真正的答案就更加清晰了。

<button onClick={...}>{count}</button> 这行代码是 JSX。浏览器不会直接执行 JSX,而是需要通过 Babel 或 TypeScript 编译器等工具在代码打包前将其转换为普通的函数调用。从概念上讲,其结果为:

React.createElement("button", { onClick: fn }, count)

随着 React 17 引入的自动 JSX 运行时,实际使用的编译后调用方式是来自 react/jsx-runtime 的 jsx(),而非 React.createElement,但两者的功能相同。无论使用哪种函数调用方式,最终都会返回一个普通对象:

{
  type: "button",
  props: { onClick: fn, children: 1 },
}

React将此称为元素。它只是一种描述,而非实际存在的对象:只是一条简短记录,说明此处应显示一个按钮,该按钮具有特定的点击处理函数,并会展示这个数字。真正的元素还包含更多字段,如key、ref以及内部的$typeof标记,但这些字段都不用于存储历史信息。

现在来看关键点:每次调用Counter都会创建一个全新的元素。点击按钮后,Counter()会被执行,返回一个全新的对象,而之前的那个对象则会被视为垃圾数据。如果状态存储在元素上,就无法实现从渲染两个对象到仅渲染一个对象的切换,因为在开始渲染两个对象时,用于渲染单个对象的那个对象已经不存在了。

这种可丢弃性是刻意设计的。元素之所以价格低廉,是因为每次渲染时都会从头重新创建,无需与任何内容保持同步。这也意味着它们不能存储在 count 的位置。

持久存在的对象:fiber

除了这些元素之外,React 还为每个已挂载的组件维护另一个生命周期更长的对象。该对象不会在每次渲染时重新创建,而是在组件首次挂载时生成,之后只要组件仍在屏幕上就会一直被保留并更新。这就是 fiber。

纤维通常被描述为某种神秘的存在,但实际上在最底层,纤维只是一个普通的 JavaScript 对象。它背后并没有特殊的运行时结构。如果你在 React 的协调器中的调试器中暂停并查看某个纤维,会发现它只是一个带有若干属性的普通对象,这些属性完全可以使用一对大括号自行编写。你也可以在浏览器中查看它们:React 会为 DOM 节点附加内部键,这些键指向对应的纤维,这就是 React DevTools 能找到它们的方式。

与其一次性列出所有字段,不如逐个为 Counter 构建纤维。在组件挂载之后,其最简版本如下所示:

{
  type: Counter,
}

type:该对象的归属

type 是最简单的字段,用于标识该 fiber 所跟踪的组件。type: Counter 表示该对象用于管理一个 Counter 实例,并保存对该函数本身的引用。

宿主元素也会拥有 fiber。Counter 所渲染的按钮也有自己的 fiber,其 type 为字符串 "button" 而非函数。因此,<Counter /> fiber 的值为 { type: Counter },而 <button> fiber 的值为 { type: "button" }:虽然字段相同、用途也相同,但分别指向用户自定义的组件或内置标签。

type 在状态同步中也起着重要作用。当 React 将新元素与同一位置上的现有 fiber 进行比较时,如果 type 相同,则可以重用该 fiber 及其状态;而 type 不同时,则会丢弃旧 fiber 并创建新的。这就是为什么在相同位置切换两个不同的组件时它们的状态会被重置的原因。

不过,仅凭 type 本身并无法说明状态是如何被保留的。为此需要另一个字段。

memoizedState:计数值实际存储的位置

useState 需要在函数外部有一个地方来保存当前值,因为函数的局部变量会在每次调用时都被重置。这个位置就是 fiber 上名为 memoizedState 的字段。“Memoized” simply意味着被记住:从之前保留下来而非重新计算。

将其加入草图后得到:

{
  type: Counter,
  memoizedState: { count: 0 },
}

这只是一个简化后的示意图。在实际实现中,函数组件的 fiber 中的 memoizedState 指向钩子对象链表中的第一个元素,每个钩子调用都会生成一个这样的元素,而数字 0 会存储在该钩子自身的 memoizedState 字段中,而非位于 { count: 0 } 对象里。React 并不知道你的变量名为 count,它只知道“第一个钩子的值”。要了解状态存储的位置,这个简化版本已经足够了。

接下来点击一下。React会再次调用Counter()。当执行到useState(0)时,该钩子并不会在代码中返回0,而是会查找对应fiber存储的状态,找到当前值(在点击处理完成后为1),并返回该值。useState的参数仅作为初始值,仅在组件首次渲染时使用,之后就会被忽略。从那以后,状态的真实来源是fiber,而非函数体中的字面值。

这就是开头谜题的完整答案。计数信息既不在函数中——因为函数会在每次调用之间忘记所有内容,也不在元素中——因为元素在每次渲染后都会被丢弃。它存在于fiber中,这是一个由React维护并在每次Counter渲染时都会更新的独立对象。

两个实际后果

该模型解释了若干原本看似随意的行为:

  • 在组件挂载后修改useState的参数不会影响存储的值,因为React仅会在首次时读取该值。如果需要从props重置状态,请更改组件的key,这样React就会创建一个新的fiber。
  • 由于fiber是按照树结构中的位置来查找的,状态属于组件被渲染的位置,而非函数定义本身。并排的两个<Counter />元素会拥有两个fiber以及两个独立的计数值。

fiber除了这两个字段还包含什么

type和memoizedState就已经足以解决状态相关的问题,但真正的fiber还包含更多内容。它会保存所生成的DOM节点的引用,以及该节点的父节点、第一个子节点和下一个兄弟节点的指针,这样React就能遍历整个节点树;同时还会保存之前的props以便与新传入的props进行比较。这些字段决定了React如何判断哪些部分发生了变化,以及如何遍历整个组件树,而这与单个值的存储位置是两个不同的问题。

现在可以明确的是这两个对象之间的界限,因为将它们混为一谈正是导致渲染相关问题混乱的根源:

Element                           Fiber
--------                          -----
Created by React.createElement    Created internally by React
New object every render           Same object, updated in place
Discarded right after             Persists for the component's
  React reads it                    entire mounted lifetime
Holds no history                  Holds memoizedState, the real
                                     remembered value across renders
Describes what should exist       Is the thing that actually exists,
                                     with real memory attached to it

一个便于记忆的说法是:元素是你向 React 发出的请求,而纤维则是 React 对当前状态的记录。每次渲染都会生成一批新的、无需占用内存的元素,并将它们交给纤维树,而纤维树则保留着上一次渲染时所有需要持续存在的元素。前者负责描述状态,后者负责存储状态。

一个未解之谜:纤维总是成对出现

认为纤维是“在原位更新”的说法虽是个有用的初步理解,但并不完全准确。实际上 React 会为每个纤维保存两个版本,一个与当前屏幕上显示的内容一致,另一个则为下一次更新做准备,两者之间会相互切换。正是这种设计使得 React 能在更新时不影响已显示的界面,这一点值得单独说明。

核心要点

  • 函数组件的局部变量会在每次调用时重新创建,因此该函数本身无法存储状态。
  • JSX会被编译为用于生成元素的调用指令:这些简单的、可丢弃的描述会在每次渲染时重新构建。
  • Fiber是普通对象,在组件挂载期间会一直存在;type字段指明了该Fiber所跟踪的组件类型。
  • useState通过Fiber的memoizedState来读取和写入值,而该属性实际上指向按调用顺序排列的钩子对象列表。
  • 传递给useState的初始值仅在组件挂载时起作用;之后,Fiber才是状态的真实来源。

相关阅读