React 基础体系 · 第 3/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React JSX 与渲染模型:元素、Fiber、Reconciliation 和 Key
React 的 JSX 看起来像 HTML,实际却是一种描述用户界面的 JavaScript 语法。组件函数执行时并不会直接创建 DOM;它返回 React Element,React 再根据新旧元素树计算需要保留、更新、移动或删除的部分,最后将结果提交到具体渲染环境。
要理解 React 的更新行为,至少需要区分四个层次:
- JSX:开发者书写 UI 描述的语法。
- React Element:JSX 编译后产生的不可变描述对象。
- Fiber:React 内部用于保存工作状态、连接树结构和调度更新的节点。
- 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 抽象为:
其中:
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>
false、true、null 和 undefined 通常不会产生可见文本节点,因此:
{items.length === 0 && <p>暂无数据</p>}
在 items.length === 0 时显示 p,否则不显示任何内容。
但数字 0 会被渲染出来,这是常见错误:
{items.length && <p>暂无数据</p>}
当 items.length 为 0 时,表达式结果是数字 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 描述。这就是“纯渲染”的核心要求。
下面的代码在渲染期间修改模块级变量:
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 之间通常通过 child、sibling、return 等关系组织,而不是直接使用 JavaScript 数组作为唯一结构。实现细节可能随 React 版本变化,因此应用不应读取或修改 Fiber。
3. 当前树与工作树
在一次更新中,React 通常需要保留已提交的当前树,同时构造或复用一棵工作树。完成后,新的工作树成为下一棵已提交树。
Fiber 实现中常见 alternate 关系,用于连接同一逻辑位置的当前节点和工作节点。这个术语属于实现层,不是公开 API;准确理解它的价值在于:
- React 不必把所有工作立即写入用户可见 DOM;
- 未完成的工作可以被中断或丢弃;
- 只有完成并提交后,用户才能看到这次更新的结果。
因此,组件中的渲染逻辑必须允许被重新执行;不能把“组件函数恰好执行一次”当作契约。
五、Reconciliation:新旧元素如何匹配
Reconciliation 常译为“协调”或“调和”,指 React 根据新旧 Element 描述计算更新的过程。它不是把任意两棵树做完全最优的通用树编辑距离计算。React 使用了适合 UI 场景的启发式规则,以避免昂贵的通用算法。
可以先建立一个简化匹配关系:
这里:
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 />,A 和 B 是不同的组件类型,组件状态通常不会在这个位置延续。
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 状态并不是简单挂在函数变量上的。可以用下面的抽象表示状态槽位:
更准确地说,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":
- 旧 Element 的 key 是
"alice"; - 新 Element 的 key 是
"bob"; - 类型相同但 key 不同,身份不匹配;
- 旧
Chat被卸载; - 新
Chat被创建; - 新的
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 的匹配过程是:
- 新位置 0 请求
key="b"; - React 找到旧的
key="b"对应 Fiber; - 复用该 Fiber、组件状态和相关 DOM 节点;
- 新位置 1 匹配旧的
key="a"; - 处理宿主节点移动;
"办公"仍然属于“鼠标”行。
如果使用索引作为 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 只重新渲染发生变化的组件。”更准确的说法是:
- 状态更新使某个更新源进入调度;
- React 执行相关组件以获得新的 Element;
- React 对新旧描述进行协调;
- 未变化的宿主节点通常可以复用;
- 最终只有必要的提交操作作用于宿主环境。
组件函数执行与 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. useMemo 和 useCallback 也不改变身份规则
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} />
))
可能的根因包括:
id为undefined;- 数据源包含重复 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();- 浏览器宽度、
window、localStorage; - 时区和本地化格式;
- 服务端与客户端使用了不同数据;
- 服务端输出顺序与客户端列表顺序不同;
- 由不稳定 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>
);
}
点击某一行时,完整过程可以分解为:
- 浏览器事件触发
setSelected; - React 将更新加入调度;
ItemRow根据新 state 产生新的 Element;- React 根据组件类型
ItemRow和 keyitem.id匹配旧 Fiber; - 该行的 state 槽位继续属于同一个实体;
- 新旧
<button>类型相同; - React 比较文本内容和相关属性;
- Commit 阶段更新必要的 DOM 内容;
- 其他行的身份不变,其 DOM 通常不需要因这一行的状态变化而重建。
如果把 key 改成索引,代码在静态列表中可能暂时表现正常;一旦排序或删除,位置身份与业务身份分离,状态就可能显示在错误行。如果把 key 改成随机数,每次更新都会让所有行看起来像新实体,局部状态和 DOM 复用都会失效。
可以用下面的规则概括整个模型:
其中:
- JSX 决定如何书写描述;
- Element 记录
type、key和 props; - Fiber 保存 React 内部的工作和状态;
- Reconciliation 依据类型、key 和树结构推断复用关系;
- Commit 才真正修改 DOM、原生视图或其他宿主环境;
- key 定义的是兄弟节点之间的身份,不是性能开关,也不是 DOM 属性。
掌握这条因果链后,列表状态错位、表单是否重置、组件为何重新执行、DOM 为何没有重建,以及服务端水合为何失败,都可以回到同一个问题:新旧树中的描述是否被 React 判断为同一个位置、同一种类型、同一个身份。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 项目工具链:Vite、TypeScript、Lint、环境变量和构建
- 下一篇:React 组件与 Props:纯渲染、组合、Children 和 API 契约
- 延伸:React 性能优化:Profiler、Memo、更新边界、长列表和 Bundle
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论