React 基础体系 · 第 3/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React JSX 与渲染模型:元素、Fiber、Reconciliation 和 Key

React 的 JSX 看起来像 HTML,实际却是一种描述用户界面的 JavaScript 语法。组件函数执行时并不会直接创建 DOM;它返回 React Element,React 再根据新旧元素树计算需要保留、更新、移动或删除的部分,最后将结果提交到具体渲染环境。

要理解 React 的更新行为,至少需要区分四个层次:

  1. JSX:开发者书写 UI 描述的语法。
  2. React Element:JSX 编译后产生的不可变描述对象。
  3. Fiber:React 内部用于保存工作状态、连接树结构和调度更新的节点。
  4. Reconciliation:React 比较新旧元素描述并决定如何复用或替换已有 Fiber 的过程。

key 不属于 DOM 属性,也不是用来提升所有列表性能的装饰字段。它参与的是“同一父节点下,某个子元素在多次渲染之间是否代表同一个身份”的判断。


一、从 JSX 到 React Element

1. JSX 是语法,不是模板字符串

下面的 JSX:

const element = (
  <button type="button" disabled={true}>
    保存
  </button>
);

表达的是一个 JavaScript 值。现代 React 项目通常使用自动 JSX 转换,编译结果在概念上接近:

const element = jsx("button", {
  type: "button",
  disabled: true,
  children: "保存",
});

这里的 jsx 通常来自 react/jsx-runtime,具体调用形式由编译器生成。手写等价代码时可以写成:

import { jsx } from "react/jsx-runtime";

const element = jsx("button", {
  type: "button",
  disabled: true,
  children: "保存",
});

传统 JSX 转换则更接近:

import React from "react";

const element = React.createElement(
  "button",
  {
    type: "button",
    disabled: true,
  },
  "保存"
);

这两种写法的语义目标相同:创建 React Element 描述。自动 JSX 转换减少了手动导入 React 的需要,但并没有改变 JSX 不是 HTML、Element 不是 DOM 节点这一事实。

2. React Element 是不可变的描述对象

可以把一个 Element 抽象为:

E=(type, key, ref, props)E = (type,\ key,\ ref,\ props)

其中:

  • type:元素类型,例如 "div""button"UserCard 函数组件;
  • key:同一父节点下用于识别身份的可选标识;
  • ref:引用宿主节点或组件暴露对象的机制;
  • props:传给宿主元素或组件的属性,包括 children

例如:

function Greeting({ name }: { name: string }) {
  return <h1>Hello, {name}</h1>;
}

const element = <Greeting name="Ada" />;

这里的 element 不是 Greeting 的执行结果,也不是一个已经插入页面的 DOM 节点。它只是类似下面的描述:

{
  type: Greeting,
  props: {
    name: "Ada"
  }
}

实际对象还可能包含 React 内部字段;这些字段不应作为应用逻辑依赖。

React Element 创建后不应被直接修改:

const element = <UserCard name="Ada" />;

// 不要这样做:
// element.props.name = "Grace";

如果输入变化,应创建新的 Element:

const nextElement = <UserCard name="Grace" />;

React 的更新模型是:组件重新执行,生成新的描述;React 比较新旧描述并处理实际变更,而不是由开发者命令式地修改 Element。

3. JSX 表达式的几个边界

JSX 中的大括号是 JavaScript 表达式上下文:

const count = 3;

const view = (
  <section>
    <h2>任务</h2>
    <p>数量:{count}</p>
  </section>
);

属性值和子节点有不同的语义:

const view = (
  <input
    className="search"
    disabled={false}
    aria-label="搜索"
    value={"React"}
  />
);
  • className="search" 是字符串字面量;
  • disabled={false} 是布尔值;
  • aria-label="搜索" 是字符串属性;
  • value={"React"} 是表达式。

下面的值可以作为 React 子节点渲染:

<div>
  {"文本"}
  {42}
  {condition && <span>条件成立</span>}
</div>

falsetruenullundefined 通常不会产生可见文本节点,因此:

{items.length === 0 && <p>暂无数据</p>}

items.length === 0 时显示 p,否则不显示任何内容。

但数字 0 会被渲染出来,这是常见错误:

{items.length && <p>暂无数据</p>}

items.length0 时,表达式结果是数字 0,页面可能显示 0。应写成:

{items.length === 0 && <p>暂无数据</p>}

或:

{items.length > 0 ? <List items={items} /> : <p>暂无数据</p>}

数组可以作为子节点:

<ul>
  {items.map((item) => (
    <li key={item.id}>{item.title}</li>
  ))}
</ul>

数组中的字符串和数字会形成文本内容,Element 则形成嵌套结构。对象本身通常不能直接作为子节点:

// 错误:普通对象不是合法的 React child
<div>{user}</div>

应选择具体字段:

<div>{user.name}</div>

二、Element、组件和 DOM 节点不是同一个东西

下面三个概念经常被混淆:

function Counter() {
  return <button>0</button>;
}

const element = <Counter />;
  • Counter 是一个 JavaScript 函数;
  • element 是描述“使用 Counter 组件”的 React Element;
  • <button> 最终可能对应浏览器中的 DOM 节点。

调用组件函数得到的是另一个 Element:

const child = Counter();

但应用代码不应手动调用组件函数来绕过 React:

// 不推荐
const view = Counter();

正确方式是:

const view = <Counter />;

React 需要通过组件 Element 建立组件边界、处理 props、记录状态位置、调度更新和处理错误边界。直接调用函数会绕过这些语义。

宿主元素和组件也可以用 type 区分:

<div />
<UserCard />

概念上分别是:

{ type: "div", props: ... }
{ type: UserCard, props: ... }

字符串类型通常代表宿主环境元素;函数或类类型代表 React 组件。一个自定义组件返回 <div>,并不意味着这个组件本身就是一个 DOM 节点。


三、渲染阶段与提交阶段

React 更新通常可以分为两个主要阶段:

flowchart LR
    A[状态更新或父组件重新渲染] --> B[Render 渲染阶段]
    B --> C[生成新的 Element 描述]
    C --> D[Reconciliation 比较新旧树]
    D --> E[生成待提交工作]
    E --> F[Commit 提交阶段]
    F --> G[更新 DOM 或其他宿主环境]
    F --> H[运行 layout effects]
    H --> I[浏览器绘制]
    I --> J[运行 passive effects]

1. Render 阶段:计算,不应产生外部副作用

组件函数的主要职责是根据输入计算 UI:

type UserCardProps = {
  name: string;
};

function UserCard({ name }: UserCardProps) {
  return <p>{name}</p>;
}

可以抽象为:

UI=f(props, state)UI = f(props,\ state)

在相同输入下,组件应返回等价的 UI 描述。这就是“纯渲染”的核心要求。

下面的代码在渲染期间修改模块级变量:

let renderCount = 0;

function Panel() {
  renderCount += 1;

  return <p>渲染次数:{renderCount}</p>;
}

这不是可靠的渲染计数器,因为:

  • React 可能重新执行组件而不提交对应 DOM 变化;
  • 开发环境下 Strict Mode 可能故意额外调用渲染逻辑以发现不纯代码;
  • 并发渲染可能开始一棵树后放弃它;
  • 多个实例会共享同一个模块变量。

副作用应放在事件处理器或 Effect 中:

import { useEffect, useState } from "react";

function Clock() {
  const [now, setNow] = useState(() => new Date());

  useEffect(() => {
    const id = window.setInterval(() => {
      setNow(new Date());
    }, 1000);

    return () => {
      window.clearInterval(id);
    };
  }, []);

  return <time>{now.toLocaleTimeString()}</time>;
}

useEffect 的清理函数会在 Effect 重新运行前以及组件卸载时执行。它适合订阅、定时器和外部系统同步;不应把所有计算都塞进 Effect。

2. Commit 阶段:把结果应用到宿主环境

渲染阶段只是计算。React 只有在提交阶段才会将最终结果应用到 DOM,例如:

  • 创建节点;
  • 删除节点;
  • 更新属性;
  • 插入或移动节点;
  • 更新文本;
  • 执行相关引用和 Effect 生命周期。

因此,“组件函数执行了”不等于“页面一定发生了 DOM 更新”。

例如父组件重新渲染时,子组件函数可能再次执行,但如果新旧结果等价,宿主节点可能无需改变:

function Label({ text }: { text: string }) {
  return <span>{text}</span>;
}

function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <button onClick={() => setCount((value) => value + 1)}>
        {count}
      </button>
      <Label text="固定文本" />
    </>
  );
}

这里 App 的状态变化会使 App 重新渲染。Label 是否执行、执行多少次,取决于 React 的更新传播和优化;而 <span> 是否真实更新,还取决于最终比较结果。


四、Fiber:React 内部的工作节点

1. Fiber 解决了什么问题

Fiber 是 React 当前实现中用于表示和处理工作单元的数据结构。一个 Fiber 通常保存与某个组件或宿主元素相关的信息,例如:

  • 元素类型和实际类型;
  • key
  • pendingProps 与已提交的 props;
  • 组件状态和 Hook 链;
  • 父节点、子节点、兄弟节点;
  • 宿主实例引用;
  • 副作用标记;
  • 调度优先级相关信息;
  • 与当前树对应的另一份工作节点引用。

Fiber 不是 React Element 的公开替代品,也不是浏览器 DOM。可以这样区分:

概念 作用 是否面向应用代码
JSX 编写 UI 描述的语法
React Element 不可变的 UI 描述值 是,但通常不直接修改
Fiber React 内部的可调度工作节点 否,属于实现细节
DOM 节点 浏览器中的宿主对象 是,可通过事件、ref 等间接使用

Fiber 的重要意义不是“让比较算法变快”这么简单,而是把渲染工作拆成可保存、可恢复和可放弃的单元,使 React 可以在较新的架构中进行优先级调度和并发渲染。

2. Fiber 树与 Element 树的关系

组件返回的 Element 形成逻辑上的 UI 树:

function App() {
  return (
    <main>
      <Header />
      <Content />
    </main>
  );
}

React 会围绕这些 Element 建立内部 Fiber 结构。一个函数组件 Fiber 可以对应:

  • 该函数组件本身;
  • 它返回的子元素;
  • 子元素对应的后代 Fiber;
  • 与宿主环境交互的节点。

Fiber 之间通常通过 childsiblingreturn 等关系组织,而不是直接使用 JavaScript 数组作为唯一结构。实现细节可能随 React 版本变化,因此应用不应读取或修改 Fiber。

3. 当前树与工作树

在一次更新中,React 通常需要保留已提交的当前树,同时构造或复用一棵工作树。完成后,新的工作树成为下一棵已提交树。

Fiber 实现中常见 alternate 关系,用于连接同一逻辑位置的当前节点和工作节点。这个术语属于实现层,不是公开 API;准确理解它的价值在于:

  • React 不必把所有工作立即写入用户可见 DOM;
  • 未完成的工作可以被中断或丢弃;
  • 只有完成并提交后,用户才能看到这次更新的结果。

因此,组件中的渲染逻辑必须允许被重新执行;不能把“组件函数恰好执行一次”当作契约。


五、Reconciliation:新旧元素如何匹配

Reconciliation 常译为“协调”或“调和”,指 React 根据新旧 Element 描述计算更新的过程。它不是把任意两棵树做完全最优的通用树编辑距离计算。React 使用了适合 UI 场景的启发式规则,以避免昂贵的通用算法。

可以先建立一个简化匹配关系:

match(old, new, position)={true,old.type=new.typeold.key=new.keyfalse,otherwisematch(old,\ new,\ position) = \begin{cases} true, & old.type = new.type \land old.key = new.key \\ false, & otherwise \end{cases}

这里:

  • old 是旧的 Element 或旧 Fiber 对应的描述;
  • new 是新渲染出来的 Element;
  • type 是元素或组件类型;
  • key 是同一父节点下的身份标识;
  • position 表示它们属于同一个父节点的子集合。

这个公式是理解模型的简化表达,不是 React 源码中的完整条件。实际过程还需要处理 Fragment、文本、Portal、特殊宿主节点和列表匹配等情况。

1. 类型不同:通常替换整棵子树

旧树:

<div>
  <Counter />
</div>

新树:

<section>
  <Counter />
</section>

在同一位置,"div""section" 的类型不同。React 通常会卸载旧的 div 子树,再创建新的 section 子树。旧子树中的状态和 DOM 节点不会因为它们内部内容相似而自动保留。

组件类型变化也一样:

function A() {
  return <input />;
}

function B() {
  return <input />;
}

从:

<A />

变成:

<B />

即使二者都返回相同的 <input />AB 是不同的组件类型,组件状态通常不会在这个位置延续。

2. 类型相同:复用已有节点并比较属性

旧树:

<button className="primary">保存</button>

新树:

<button className="danger">删除</button>

两者类型都是 "button",React 通常保留原有宿主节点,只更新变化的属性和文本。它不会因为 className 改变就必然销毁并重新创建整个按钮。

组件类型相同也会沿着组件边界继续处理:

<UserCard name="Ada" />

变成:

<UserCard name="Grace" />

UserCard 这个组件位置可以被保留,只是接收了新的 props,并重新计算返回的子树。

3. 子节点顺序与列表匹配

考虑旧列表:

[
  <li key="a">A</li>,
  <li key="b">B</li>
]

新列表:

[
  <li key="b">B</li>,
  <li key="a">A</li>
]

如果 key 稳定,React 可以识别:

  • key="b" 仍然是原来的 B;
  • key="a" 仍然是原来的 A;
  • 它们的位置发生了变化。

于是 React 可以复用对应的组件状态和宿主节点,并处理必要的移动。

没有 key 时,React 主要只能依据位置和类型推断:

[
  <li>A</li>,
  <li>B</li>
]

变成:

[
  <li>B</li>,
  <li>A</li>
]

因为两个子节点类型都为 "li",位置 0 和位置 1 都可以继续复用,但 React 未必能知道业务上的 A 和 B 已经交换。若每一项包含输入框、动画状态或局部组件状态,就可能出现“数据变了,但输入值跟错项”的表现。


六、Key:子节点的身份,而不是 DOM 属性

1. Key 的作用域

key 的定义是:在同一个父节点的直接子节点集合中,用来区分多个同类元素的身份标识。

<ul>
  {users.map((user) => (
    <li key={user.id}>{user.name}</li>
  ))}
</ul>

这里 user.id 只需要在这一个 ul 的直接子节点中稳定且唯一。它不需要:

  • 在整个应用中全局唯一;
  • 与数据库主键具有某种特殊格式;
  • 被组件通过 props.key 读取;
  • 出现在最终 DOM 的 key 属性中。

key 是 React 用于匹配的保留字段,不会作为普通 prop 自动传入组件:

function Row(props: { id?: string }) {
  console.log(props.id); // 与 key 无关
  return null;
}

<Row key="user-1" id="user-1" />;

如果组件需要业务 ID,应显式传递:

<Row key={user.id} id={user.id} />

2. Key 必须稳定

稳定意味着:对于同一个业务实体,在多次渲染中应产生相同的 key。

正确示例:

type Todo = {
  id: string;
  title: string;
};

function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

错误示例:

function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={Math.random()}>{todo.title}</li>
      ))}
    </ul>
  );
}

每次渲染都生成新 key,等价于告诉 React:“这些是全新的节点。”结果可能包括:

  • 子组件状态全部重置;
  • DOM 节点被重新创建;
  • 输入焦点丢失;
  • Effect 频繁清理和重新建立;
  • 动画重新开始;
  • 产生额外工作。

下面的写法也会造成类似问题:

<li key={Date.now()}>{todo.title}</li>

key 应来自数据身份,而不是渲染时间。

3. Key 必须在兄弟节点中唯一

下面的代码有重复 key:

<ul>
  <li key="same">A</li>
  <li key="same">B</li>
</ul>

React 会在开发环境发出重复 key 警告。重复 key 使新旧节点的匹配不再具有唯一意义,可能导致元素复用错误、状态归属不确定或部分子树缺失。

注意,key 只要求在同一父节点的兄弟集合中唯一:

<>
  <ul>
    <li key="a">A</li>
  </ul>
  <ul>
    <li key="a">A</li>
  </ul>
</>

两个不同 ul 下的 key 可以相同,因为它们处于不同的匹配作用域。

4. 数组中使用 Fragment 时也要提供 Key

如果列表项需要返回多个兄弟节点,应使用带 key 的 Fragment:

import { Fragment } from "react";

function People({ people }: { people: { id: string; name: string }[] }) {
  return (
    <dl>
      {people.map((person) => (
        <Fragment key={person.id}>
          <dt>{person.name}</dt>
          <dd>用户</dd>
        </Fragment>
      ))}
    </dl>
  );
}

简写 Fragment:

<>
  <dt>名字</dt>
  <dd>说明</dd>
</>

不能附加 key:

// 语法不支持 key
< key={person.id}>...</>

如果需要 key,应使用完整写法 Fragment


七、Key 与状态身份:为什么改变 Key 会重置状态

React 状态并不是简单挂在函数变量上的。可以用下面的抽象表示状态槽位:

StateIdentity=(parent, type, key, siblingPosition)StateIdentity = (parent,\ type,\ key,\ siblingPosition)

更准确地说,React 根据树中的位置、组件类型和 key 等信息判断一个组件是否是之前的同一个组件。只要身份保持,状态通常可以保留;身份改变,旧状态通常会被卸载。

1. 同一位置、同一类型:状态保留

import { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button type="button" onClick={() => setCount((value) => value + 1)}>
      {count}
    </button>
  );
}

function App({ visible }: { visible: boolean }) {
  return <>{visible ? <Counter /> : <Counter />}</>;
}

两个分支虽然写在条件表达式的不同位置,但它们最终都在同一个父节点下产生同一种组件。切换 visible 时,React 可能把它视为同一位置的 Counter,因此状态可以保留。

2. 通过不同 Key 强制重置

function Chat({ recipientId }: { recipientId: string }) {
  const [draft, setDraft] = useState("");

  return (
    <textarea
      value={draft}
      onChange={(event) => setDraft(event.target.value)}
      placeholder={`发送给 ${recipientId}`}
    />
  );
}

function Messenger({ recipientId }: { recipientId: string }) {
  return <Chat key={recipientId} recipientId={recipientId} />;
}

recipientId"alice" 变为 "bob"

  1. 旧 Element 的 key 是 "alice"
  2. 新 Element 的 key 是 "bob"
  3. 类型相同但 key 不同,身份不匹配;
  4. Chat 被卸载;
  5. Chat 被创建;
  6. 新的 useState("") 初始化为空字符串。

因此 key 可以作为明确的“身份边界”,用于让表单、编辑器或步骤组件在实体切换时重置内部状态。

3. 不要用 Key 代替状态建模

如果列表项只是排序变化,稳定 key 可以保持每项身份:

function TaskList({ tasks }: { tasks: Todo[] }) {
  return tasks.map((task) => <TaskRow key={task.id} task={task} />);
}

如果业务要求“切换筛选条件时保留输入”,就不应随意把筛选条件拼进 key:

// 会在 filter 改变时重置每行状态
<TaskRow key={`${filter}-${task.id}`} task={task} />

key 是身份定义的一部分。把不必要的变量加入 key,相当于声明这些变量变化时就是新实体。


八、列表更新的完整算例

假设每一行都有独立输入状态:

import { useState } from "react";

type Product = {
  id: string;
  name: string;
};

function ProductRow({ product }: { product: Product }) {
  const [note, setNote] = useState("");

  return (
    <li>
      <strong>{product.name}</strong>
      <input
        value={note}
        onChange={(event) => setNote(event.target.value)}
        aria-label={`${product.name}备注`}
      />
    </li>
  );
}

function ProductList({ products }: { products: Product[] }) {
  return (
    <ul>
      {products.map((product) => (
        <ProductRow key={product.id} product={product} />
      ))}
    </ul>
  );
}

初始数据:

[
  { id: "a", name: "键盘" },
  { id: "b", name: "鼠标" }
]

用户在“鼠标”行输入 办公。此时可以抽象为:

key product note
a 键盘 ""
b 鼠标 "办公"

随后服务器返回排序后的数据:

[
  { id: "b", name: "鼠标" },
  { id: "a", name: "键盘" }
]

使用 product.id 作为 key 时,React 的匹配过程是:

  1. 新位置 0 请求 key="b"
  2. React 找到旧的 key="b" 对应 Fiber;
  3. 复用该 Fiber、组件状态和相关 DOM 节点;
  4. 新位置 1 匹配旧的 key="a"
  5. 处理宿主节点移动;
  6. "办公" 仍然属于“鼠标”行。

如果使用索引作为 key:

products.map((product, index) => (
  <ProductRow key={index} product={product} />
));

更新前后身份表是:

位置 旧 key 旧商品 旧 note
0 0 键盘 ""
1 1 鼠标 "办公"

排序后:

位置 新 key 新商品
0 0 鼠标
1 1 键盘

React 会认为位置 0 的旧行仍是位置 0 的同一个行,只是 props 从“键盘”变成“鼠标”;位置 1 同理。于是 "办公" 可能留在位置 1,却现在显示在“键盘”行。这不是 React 把 state 随机弄错,而是索引 key 明确把“位置”声明成了身份,而业务真正需要的是“商品 ID”。


九、什么时候索引 Key 可以接受

索引 key 不是绝对禁止。它只有在列表具有以下性质时才较安全:

  • 列表不会重新排序;
  • 不会在头部或中间插入、删除;
  • 每一项没有需要长期保留的局部状态;
  • 列表项不会被单独移动;
  • 列表项的身份本来就等同于固定位置。

例如一个静态的法律条款列表:

const sections = ["定义", "范围", "责任"];

function Terms() {
  return (
    <ol>
      {sections.map((section, index) => (
        <li key={index}>{section}</li>
      ))}
    </ol>
  );
}

如果这些条件未来可能改变,最好仍然使用稳定业务 ID:

const sections = [
  { id: "definition", title: "定义" },
  { id: "scope", title: "范围" },
  { id: "responsibility", title: "责任" },
];

需要区分两个问题:

  • 正确性问题:错误 key 可能导致状态归属、输入值和 Effect 生命周期不符合业务预期;
  • 性能问题:稳定 key 可以帮助 React 复用节点和工作,但 key 本身不会让组件自动跳过渲染。

十、Reconciliation 不等于“只渲染变化部分”

常见误解是:“React 只重新渲染发生变化的组件。”更准确的说法是:

  1. 状态更新使某个更新源进入调度;
  2. React 执行相关组件以获得新的 Element;
  3. React 对新旧描述进行协调;
  4. 未变化的宿主节点通常可以复用;
  5. 最终只有必要的提交操作作用于宿主环境。

组件函数执行与 DOM 变化是两个不同层次:

function ExpensiveView({ value }: { value: number }) {
  console.log("组件函数执行");

  return <div>{value}</div>;
}

console.log 出现,说明函数执行过;它不表示 div 一定被销毁重建,也不表示浏览器一定进行了可见布局变化。

1. Memo 与 Reconciliation 的关系

memo 可以在特定条件下跳过函数组件的重新计算:

import { memo } from "react";

const UserName = memo(function UserName({ name }: { name: string }) {
  console.log("UserName render");
  return <span>{name}</span>;
});

当父组件更新且 name 的 props 比较结果相等时,React 可能跳过 UserName 的重新渲染。但要注意:

  • memo 不是数据一致性机制;
  • 组件自己的 state 更新仍会触发自身更新;
  • Context 变化可能使组件更新;
  • 默认比较主要是浅比较;
  • 新建对象和函数可能使 props 每次都不同;
  • 即便跳过组件渲染,父级协调仍然存在。

例如:

<UserName user={{ name: "Ada" }} />

父组件每次渲染都会创建新对象,浅比较通常认为 props 已变化。若要优化,应先确认更新边界和数据结构,而不是机械地添加 memo

2. useMemouseCallback 也不改变身份规则

const filtered = useMemo(
  () => products.filter((product) => product.category === category),
  [products, category]
);

这可以稳定某个计算结果的引用,但不会改变列表项的 key 语义。列表仍然需要:

filtered.map((product) => (
  <ProductRow key={product.id} product={product} />
));

性能优化解决的是“是否需要重新计算、是否需要传播更新”;key 解决的是“子节点在更新之间是不是同一个身份”。两者相关但不能互相替代。


十一、并发渲染与可中断工作

现代 React 的渲染工作可以按优先级调度。某个更新开始后,React 可能:

  • 暂停当前工作;
  • 先处理更高优先级更新;
  • 丢弃尚未提交的工作;
  • 重新执行组件;
  • 最终只提交最后完成的结果。

这意味着渲染函数不能依赖“执行一次就一定提交”:

let requestCount = 0;

function SearchResults({ query }: { query: string }) {
  requestCount += 1; // 错误:渲染阶段不应通过它发起外部行为
  return <p>{query}</p>;
}

如果需要请求数据,应根据运行环境和框架的数据加载模型处理。在客户端组件中,简化示例可以使用 Effect:

import { useEffect, useState } from "react";

function Results({ query }: { query: string }) {
  const [data, setData] = useState<string[]>([]);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    setError(null);

    fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
      .then((response) => {
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        return response.json() as Promise<string[]>;
      })
      .then(setData)
      .catch((reason: unknown) => {
        if (reason instanceof DOMException && reason.name === "AbortError") {
          return;
        }
        setError(reason instanceof Error ? reason : new Error("请求失败"));
      });

    return () => controller.abort();
  }, [query]);

  if (error) {
    return <p role="alert">{error.message}</p>;
  }

  return (
    <ul>
      {data.map((item) => (
        <li key={item}>{item}</li>
      ))}
    </ul>
  );
}

这里的 AbortController 处理了一个真实故障路径:查询快速变化时,旧请求可能晚于新请求返回。清理旧请求可以减少过期结果覆盖新结果的风险。实际项目还应根据缓存、路由和数据请求库的模型决定数据边界。

并发能力并不意味着组件可以不纯。恰恰相反,可中断和可重放要求渲染阶段更接近纯函数。


十二、服务端渲染、客户端渲染与水合

1. 客户端渲染

传统客户端入口通常类似:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";

const container = document.getElementById("root");

if (!container) {
  throw new Error("Missing #root container");
}

createRoot(container).render(
  <StrictMode>
    <App />
  </StrictMode>
);

createRoot 会在浏览器中创建客户端 React 根。组件在客户端执行,React 负责生成并提交 DOM。

2. 服务端渲染与 Hydration

服务端渲染可以先输出 HTML,再在浏览器中使用:

import { hydrateRoot } from "react-dom/client";
import App from "./App";

const container = document.getElementById("root");

if (!container) {
  throw new Error("Missing #root container");
}

hydrateRoot(container, <App />);

水合不是“重新把所有 DOM 创建一遍”,而是让客户端 React 根据相同的组件描述接管服务端已经输出的 HTML。服务端和客户端初始输出必须一致,否则可能出现 hydration mismatch。

错误示例:

function Clock() {
  return <p>{new Date().toLocaleTimeString()}</p>;
}

服务端生成时间和客户端首次生成时间可能不同,导致初始树不一致。更稳妥的方式是让初始渲染使用确定值,挂载后再更新:

import { useEffect, useState } from "react";

function Clock() {
  const [text, setText] = useState("--:--:--");

  useEffect(() => {
    const update = () => setText(new Date().toLocaleTimeString());

    update();
    const id = window.setInterval(update, 1000);
    return () => window.clearInterval(id);
  }, []);

  return <time>{text}</time>;
}

随机数、当前时间、仅浏览器可用的 API、不同地区的格式化结果,都可能造成服务端和客户端的初始描述不一致。key 不能修复这种数据不一致;它只参与同一树中的身份匹配。

3. 服务端组件与客户端组件边界

React 生态中的服务端组件能力由具体框架和构建系统提供。以常见框架约定为例:

  • 服务端组件可以在服务端执行,并将可序列化的结果或组件协议发送给客户端;
  • 需要浏览器事件、State、Effect 或浏览器 API 的部分必须位于客户端边界;
  • "use client" 是某些框架的文件边界指令,不是所有 React 渲染器都通用的 React 核心 API;
  • 服务端组件不能简单地把任意函数、类实例或不可序列化对象作为客户端 props 传输。

一个客户端组件示意:

"use client";

import { useState } from "react";

export function LikeButton({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);

  return (
    <button type="button" onClick={() => setCount((value) => value + 1)}>
      点赞 {count}
    </button>
  );
}

服务端和客户端边界改变的是组件在哪里执行、哪些能力可用,不改变 JSX 是 Element 描述、React 需要进行协调、key 参与兄弟节点身份判断这些基本模型。


十三、Key 不能修复这些问题

1. Key 不能让组件只渲染一次

<Item key={item.id} item={item} />

稳定 key 只能帮助 React 保持身份。父组件重新渲染时,Item 是否重新执行仍取决于更新路径、memo、Context 和自身状态等因素。

2. Key 不能替代唯一业务身份

下面的 key 看似唯一,但不稳定:

key={`${item.name}-${index}`}

如果名称变化或列表插入项目,key 也会变化。应使用数据层真正稳定的 ID:

key={item.id}

如果后端没有 ID,应在数据首次进入系统时生成一次,而不是每次 render 生成:

type DraftItem = {
  id: string;
  title: string;
};

生成时机应位于事件处理器、数据转换层或初始化函数中,而不是 JSX 映射期间。

3. Key 不能修复可变数据导致的错误

如果直接修改数组或对象:

products.push(newProduct);
setProducts(products);

可能因为引用没有变化而使依赖引用比较的逻辑失效,也会让数据流难以推理。应创建新数组:

setProducts((previous) => [...previous, newProduct]);

Key 处理的是子节点身份;不可变更新处理的是数据引用和状态更新契约。两者解决不同问题。

4. Key 不能替代错误边界和加载状态

列表请求失败时,即使 key 完全正确,组件仍可能显示错误。应明确建模状态:

type State =
  | { status: "loading" }
  | { status: "success"; items: Todo[] }
  | { status: "error"; message: string };

渲染时分别处理:

function TodoView({ state }: { state: State }) {
  switch (state.status) {
    case "loading":
      return <p>加载中……</p>;
    case "error":
      return <p role="alert">{state.message}</p>;
    case "success":
      return (
        <ul>
          {state.items.map((item) => (
            <li key={item.id}>{item.title}</li>
          ))}
        </ul>
      );
  }
}

身份匹配、异步状态和异常处理属于不同层次,不能通过同一个字段混合解决。


十四、常见失败表现与诊断路径

1. 输入值显示在错误的列表项中

优先检查:

key={index}

以及列表是否发生了:

  • 头部插入;
  • 中间删除;
  • 排序;
  • 过滤;
  • 跨列表移动。

然后确认每个实体是否有稳定 ID。可以在行组件中打印:

function Row({ item }: { item: Todo }) {
  console.log("render row", item.id);
  // ...
}

但日志只能说明组件执行过,不能单独证明 DOM 是否重建。需要结合 React DevTools、Profiler 和浏览器 DOM 检查节点是否被复用。

2. 切换数据后表单仍保留旧内容

如果业务要求切换实体时清空表单,应检查组件身份是否仍然相同:

<Editor record={record} />

这会倾向于保留同一位置的 Editor 状态。若切换实体必须重置:

<Editor key={record.id} record={record} />

如果部分字段应保留、部分字段应重置,则不应粗暴重置整个组件,而应把状态拆成更准确的边界,或在实体变化时显式同步特定状态。

3. 出现重复 Key 警告

检查实际生成的 key,而不是只检查变量名:

items.map((item) => (
  <Row key={item.id} item={item} />
))

可能的根因包括:

  • idundefined
  • 数据源包含重复 ID;
  • 把同一个数组拼接了两次;
  • 多个分支最终返回到同一兄弟集合;
  • 在嵌套映射中把外层 ID 错当成内层 ID。

可以临时验证:

const ids = items.map((item) => item.id);
const uniqueIds = new Set(ids);

if (ids.length !== uniqueIds.size) {
  throw new Error("Duplicate item IDs");
}

生产代码应在数据边界校验,而不是依赖 React 警告发现数据质量问题。

4. Hydration mismatch

出现水合不匹配时,应比较服务端 HTML 和客户端首次 render 的输入,重点检查:

  • Date.now()Math.random()
  • 浏览器宽度、windowlocalStorage
  • 时区和本地化格式;
  • 服务端与客户端使用了不同数据;
  • 服务端输出顺序与客户端列表顺序不同;
  • 由不稳定 key 或不稳定数据生成的结构。

解决方向是使首次输出确定且一致,再在客户端挂载后同步浏览器特有信息,而不是随意增加 key 或强制重新挂载。


十五、把完整模型串起来

看下面的组件:

import { useState } from "react";

type Item = {
  id: string;
  title: string;
};

function ItemRow({ item }: { item: Item }) {
  const [selected, setSelected] = useState(false);

  return (
    <li>
      <button type="button" onClick={() => setSelected((value) => !value)}>
        {selected ? "已选" : "选择"}:{item.title}
      </button>
    </li>
  );
}

export function ItemList({ items }: { items: Item[] }) {
  return (
    <ul>
      {items.map((item) => (
        <ItemRow key={item.id} item={item} />
      ))}
    </ul>
  );
}

点击某一行时,完整过程可以分解为:

  1. 浏览器事件触发 setSelected
  2. React 将更新加入调度;
  3. ItemRow 根据新 state 产生新的 Element;
  4. React 根据组件类型 ItemRow 和 key item.id 匹配旧 Fiber;
  5. 该行的 state 槽位继续属于同一个实体;
  6. 新旧 <button> 类型相同;
  7. React 比较文本内容和相关属性;
  8. Commit 阶段更新必要的 DOM 内容;
  9. 其他行的身份不变,其 DOM 通常不需要因这一行的状态变化而重建。

如果把 key 改成索引,代码在静态列表中可能暂时表现正常;一旦排序或删除,位置身份与业务身份分离,状态就可能显示在错误行。如果把 key 改成随机数,每次更新都会让所有行看起来像新实体,局部状态和 DOM 复用都会失效。

可以用下面的规则概括整个模型:

JSXElement 描述Fiber 工作树ReconciliationCommit宿主环境\text{JSX} \rightarrow \text{Element 描述} \rightarrow \text{Fiber 工作树} \rightarrow \text{Reconciliation} \rightarrow \text{Commit} \rightarrow \text{宿主环境}

其中:

  • JSX 决定如何书写描述;
  • Element 记录 typekey 和 props;
  • Fiber 保存 React 内部的工作和状态;
  • Reconciliation 依据类型、key 和树结构推断复用关系;
  • Commit 才真正修改 DOM、原生视图或其他宿主环境;
  • key 定义的是兄弟节点之间的身份,不是性能开关,也不是 DOM 属性。

掌握这条因果链后,列表状态错位、表单是否重置、组件为何重新执行、DOM 为何没有重建,以及服务端水合为何失败,都可以回到同一个问题:新旧树中的描述是否被 React 判断为同一个位置、同一种类型、同一个身份。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。