原文链接:React Reconciliation: The Hidden Engine Behind Your Components,2025.04.08,by Christian Ekrem。
协调引擎
在我之前的文章(1,2)中,我探讨了 React.memo 的工作原理,以及如何通过组合更巧妙地优化性能。但要真正掌握 React 的性能优化,我们需要了解其背后的驱动引擎:React 的协调算法。
协调是 React 更新 DOM 来匹配组件树的过程。正是协调机制让 React 的声明式编程模型成为可能——你只需描述想要的结果,React 就能高效地实现它。
组件标识与状态持久化
在深入探讨技术细节之前,我们先来看一个令人惊讶的现象,它揭示了 React 对组件标识的处理方式。
考虑下面这个简单的文本输入切换示例:
const UserInfoForm = () => {
const [isEditing, setIsEditing] = useState(false);
return (
<div className="form-container">
<button onClick={() => setIsEditing(!isEditing)}>
{isEditing ? "Cancel" : "Edit"}
</button>
{isEditing ? (
<input
type="text"
placeholder="Enter your name"
className="edit-input"
/>
) : (
<input
type="text"
placeholder="Enter your name"
disabled
className="view-input"
/>
)}
</div>
);
};
当你与这个表单进行交互时,有趣的现象就出现了。如果你在编辑时在输入框中输入内容,然后点击“Cancel”按钮,再次点击“Edit”按钮时,你之前输入的文本依然存在!即便这两个input元素有着不同的属性(一个是禁用状态,另一个是类名)。
React 会保留 DOM 元素及其状态,因为这两个元素在元素树中的类型相同(都是input),且位置相同。React 只会更新现有元素的属性,而不是重新创建它。
如果我们将代码改为:
{
isEditing ? (
<input type="text" placeholder="Enter your name" className="edit-input" />
) : (
<div className="view-only-display">Name will appear here</div>
);
}
那么切换编辑模式时,就会挂载和卸载完全不同的元素,用户输入的任何内容都会丢失。
这种现象突出了 React 协调机制的一个基本要点:元素类型是确定标识的主要因素。 理解这一概念是掌握 React 性能优化的关键。
元素树,而非虚拟 DOM
你可能听说过 React 使用“虚拟 DOM”来优化更新。虽然这是一个很有用的概念模型,但更准确地说,React 的内部表示是一个元素树,是对屏幕上所显示内容的轻量级描述。
当你编写如下 JSX 代码时:
const Component = () => {
return (
<div>
<h1>Hello</h1>
<p>World</p>
</div>
);
};
React 会将其转换为一个普通 JavaScript 对象树:
{
"type": "div",
"props": {
"children": [
{
"type": "h1",
"props": {
"children": "Hello"
}
},
{
"type": "p",
"props": {
"children": "World"
}
}
]
}
}
对于像div或input这样的 DOM 元素,“type”是一个字符串。而对于自定义 React 组件,“type”是一个函数的引用:
{
"type": Input, // 对 Input 函数本身的引用
"props": {
"id": "company-tax-id",
"placeholder": "Enter company Tax ID"
}
}
协调机制的工作原理
当 React 需要更新 UI(在状态变化或重新渲染之后)时,它会执行以下步骤:
- 通过调用你的组件创建一个新的元素树。
- 将新元素树与之前的元素树进行比较。
- 确定需要哪些 DOM 操作,才能使真实的 DOM 与新元素树匹配。
- 高效地执行这些操作。
比较算法遵循以下的关键原则:
- 元素类型决定标识:React 首先检查元素的“type”。如果类型发生变化,React 会重新构建整个子树。
// 首次渲染
<div>
<Counter />
</div>
// 第二次渲染
<span>
<Counter />
</span>
由于div变为了span,React 会销毁整个旧树(包括Counter组件),并从头开始构建一个新树。
- 树中的位置很重要:React 会比较树中相同位置的元素。
// 之前
<>
{showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>
// 之后(当 showDetails 变化时)
<>
{showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>
在这个条件渲染的例子中,当 showDetails 为 true 时,位置 1 处是 UserProfile 元素;当 showDetails 为 false 时,位置 1 处是 LoginPrompt 元素。React 发现相同位置的组件类型不同,所以会卸载一个并挂载另一个。
但如果有两个相同类型的组件:
// 之前
<>
{isPrimary ? (
<UserProfile userId={123} role="primary" />
) : (
<UserProfile userId={456} role="secondary" />
)}
</>
React 发现前后位置 1 处的组件类型都是UserProfile,所以它只会更新组件的属性,而不是销毁并重新创建组件。
- 键覆盖基于位置的比较:
key属性可以覆盖基于位置的标识。
<>
{isPrimary ? (
<UserProfile key="active-profile" userId={123} role="primary" />
) : (
<UserProfile key="active-profile" userId={456} role="secondary" />
)}
</>
即使这些组件出现在条件渲染的不同分支中,React 也会将它们视为同一个组件,因为它们具有相同的键,这样在切换时就能保留状态。
键(Key)的神奇作用
键主要用于在列表渲染中,但其对 React 的协调过程有着更深远的影响。
列表中为什么需要键
在渲染列表时,React 使用键来跟踪哪些项被添加、删除或重新排序。
<ul>
{items.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
如果没有键,React 将仅依赖元素在数组中的位置。如果你在开头插入一个新项,React 会认为每个元素的位置都发生了变化,从而重新渲染整个列表。
有了键,React 就可以在不同渲染之间匹配元素,而不受其位置的影响。
数组之外也需要键吗
React 并不强制要求为静态元素添加键。
// 不需要键
<>
<Input />
<Input />
</>
这是因为 React 知道这些元素是静态的,它们在树中的位置是可预测的。
但即使在列表之外,键也能发挥强大作用。考虑以下示例:
const Component = () => {
const [isReverse, setIsReverse] = useState(false);
return (
<>
<Input key={isReverse? "some-key" : null} />
<Input key={!isReverse? "some-key" : null} />
</>
);
};
当 isReverse 切换时,键 'some-key' 会在两个输入框之间切换,导致 React 在这两个位置之间“移动”组件的状态!
混合动态和静态元素
一个常见的担忧是,向动态列表中添加项是否会改变列表后面静态元素的标识。
<>
{items.map((item) => (
<ListItem key={item.id} />
))}
<StaticElement /> {/* 当 items 变化时,这个元素会重新挂载吗? */}
</>
React 对此有巧妙的处理方式。它将整个动态列表视为第一个位置的单个单元,所以无论列表如何变化,StaticElement 都会始终保持其位置和标识。
React 内部实际的表示如下:
[
// 整个动态数组成为单个子元素
[
{ "type": ListItem, "key": "1" },
{ "type": ListItem, "key": "2" }
],
{ "type": StaticElement } // 始终保持在第二个位置
];
即使你在列表中添加或删除项,StaticElement 在父数组中的位置仍为2。这意味着当列表变化时,它不会重新挂载。这是一种巧妙的优化,确保静态元素不会因相邻动态列表的变化而不必要地重新挂载。
组件标识与性能
理解这些协调细节可以解释几种 React 性能模式:
- 为什么内联组件定义不好:在其他组件内部定义组件会在每次渲染时创建新的函数引用。
const Parent = () => {
// 不良实践:InnerComponent 每次渲染时都会重新创建
const InnerComponent = () => <div>Inner content</div>;
return <InnerComponent />;
};
由于组件的“type”(函数引用)每次渲染时都在变化,React 将其视为完全不同的组件,每次都会卸载并重新挂载。
- 为什么组合模式有效:组合模式利用了 React 的协调算法。
const CounterButton = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
};
const Parent = () => {
return (
<div>
<CounterButton />
<ExpensiveComponent />
</div>
);
};
当 count 变化时,只有 CounterButton 的组件树需要协调。React 甚至不会触及 ExpensiveComponent 的组件树,因为它在一个单独的分支中。
- 使用键实现高级状态保留:基于我们对键的理解,我们可以实现一些高级模式。
const TabContent = ({ activeTab }) => {
// 所有选项卡内容都有相同的键,所以 React 在切换选项卡时保留状态
return (
<div>
{activeTab === "profile" && <ProfileTab key="tab-content" />}
{activeTab === "settings" && <SettingsTab key="tab-content" />}
{activeTab === "activity" && <ActivityTab key="tab-content" />}
</div>
);
};
为什么这样可行呢?当activeTab变化时,React 会看到:
-
之前:类型为
ProfileTab且键为"tab-content"的元素。 -
之后:类型为
SettingsTab且键为"tab-content"的元素。
React 首先通过键来识别组件,然后才是类型。 由于键保持不变,React 将其视为“同一个组件更改了类型”,而不是“一个组件被卸载,另一个组件被挂载”。
这实际上实现了组件内部状态的转移!如果 ProfileTab 中有用户输入的表单数据,在切换到 SettingsTab 时,这些值仍会保留,即使它们是完全不同的组件。
这种模式在保留选项卡或向导步骤之间的表单输入状态,以及需要在更改视觉表示时保持某些状态的过渡效果中非常有用。
状态托管:强大的性能模式
状态托管(State Colocation)是一种将状态尽可能靠近其使用位置的模式。 这种方法确保只有直接受状态变化影响的组件才会更新,从而最大限度地减少不必要的重新渲染。
考虑以下示例:
// 性能较差 - 当过滤器变化时,整个应用都会重新渲染
const App = () => {
const [filterText, setFilterText] = useState("");
const filteredUsers = users.filter((user) => user.name.includes(filterText));
return (
<>
<SearchBox filterText={filterText} onChange={setFilterText} />
<UserList users={filteredUsers} />
<ExpensiveComponent />
</>
);
};
当 filterText 变化时,整个 App 组件都会重新渲染,包括不受过滤器影响的 ExpensiveComponent。
通过将过滤器状态与使用它的组件放在同一位置:
const UserSection = () => {
const [filterText, setFilterText] = useState("");
const filteredUsers = users.filter((user) => user.name.includes(filterText));
return (
<>
<SearchBox filterText={filterText} onChange={setFilterText} />
<UserList users={filteredUsers} />
</>
);
};
const App = () => {
return (
<>
<UserSection />
<ExpensiveComponent />
</>
);
};
现在,当过滤器变化时,只有UserSection会重新渲染。这种模式不仅提高了性能,还通过确保每个组件只管理真正属于它的状态,实现了更好的组件设计。
组件设计:针对变化进行优化
性能优化通常是一个组件设计问题。如果一个组件承担的职责过多,就更有可能发生不必要的重新渲染。
在使用React.memo之前,先思考以下问题:
- 这个组件是否有混合的职责?处理多个关注点的组件更有可能频繁重新渲染。
- 状态提升得是否过高?当状态在组件树中所处的位置比需要的更高时,会导致更多组件重新渲染。
看看下面的例子:
// 有问题的设计 - 混合了多个关注点
const ProductPage = ({ productId }) => {
const [selectedSize, setSelectedSize] = useState("medium");
const [quantity, setQuantity] = useState(1);
const [shipping, setShipping] = useState("express");
const [reviews, setReviews] = useState([]);
// 同时获取产品详情和评论
useEffect(() => {
fetchProductDetails(productId);
fetchReviews(productId).then(setReviews);
}, [productId]);
return (
<div>
<ProductInfo
selectedSize={selectedSize}
onSizeChange={setSelectedSize}
quantity={quantity}
onQuantityChange={setQuantity}
/>
<ShippingOptions shipping={shipping} onShippingChange={setShipping} />
<Reviews reviews={reviews} />
</div>
);
};
每次尺寸、数量或配送方式发生变化时,整个页面都会重新渲染,包括不相关的评论部分。
一个更好的设计是将这些关注点分开:
const ProductPage = ({ productId }) => {
return (
<div>
<ProductConfig productId={productId} />
<ReviewsSection productId={productId} />
</div>
);
};
const ProductConfig = ({ productId }) => {
const [selectedSize, setSelectedSize] = useState("medium");
const [quantity, setQuantity] = useState(1);
const [shipping, setShipping] = useState("express");
// 产品相关的逻辑
return (
<>
<ProductInfo
selectedSize={selectedSize}
onSizeChange={setSelectedSize}
quantity={quantity}
onQuantityChange={setQuantity}
/>
<ShippingOptions shipping={shipping} onShippingChange={setShipping} />
</>
);
};
const ReviewsSection = ({ productId }) => {
const [reviews, setReviews] = useState([]);
useEffect(() => {
fetchReviews(productId).then(setReviews);
}, [productId]);
return <Reviews reviews={reviews} />;
};
这种结构确保更改产品尺寸不会导致评论重新渲染。无需使用记忆化技术,只需合理划分组件边界即可。
协调机制与整洁架构
对协调机制的理解与整洁架构原则完美契合:
- 单一职责原则:每个组件都应该只有一个变化的原因。当组件专注于单一职责时,就不太可能触发不必要的重新渲染。
- 依赖倒置原则:组件应该依赖抽象,而不是具体实现。这使得通过组合优化性能变得更加容易。
- 接口隔离原则:组件应该有最少的、集中的接口。这减少了属性变化触发不必要重新渲染的可能性。
实用指南
基于我们对协调机制的深入探讨,以下是一些实用指南:
- 将组件定义放在父组件外部,以防止重新挂载。
- 将状态下移,以隔离重新渲染的边界。
- 在相同位置保持组件类型一致,避免卸载。
- 策略性地使用键——不仅在列表中,在任何你想控制组件标识的地方都可以使用。
- 调试重新渲染问题时,从元素树和组件标识的角度去思考。
- 记住,
React.memo只是在协调机制约束下发挥作用的工具,它不会改变基本算法。
结论
理解 React 的协调算法能够揭示许多 React 性能模式背后的“原因”。它解释了为什么组合模式效果显著,为什么列表中需要键,以及为什么在其他组件内部定义组件存在问题。
这些知识有助于我们做出更优的架构决策,自然地构建出高性能的 React 应用。与其过度使用记忆化技术与 React 的协调算法对抗,不如根据 React 识别和更新组件的方式来设计组件结构,从而更好地利用这一算法。
下次优化 React 应用时,可以思考一下你的组件结构如何影响协调过程。有时候,最佳的优化方式是构建一更简单、更集中的组件树,使其符合 React 识别和更新组件的规则。