React 协调机制:组件背后的隐藏引擎

223 阅读11分钟

原文链接:React Reconciliation: The Hidden Engine Behind Your Components,2025.04.08,by Christian Ekrem。

协调引擎

在我之前的文章(12)中,我探讨了 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"
                }
            }
        ]
    }
}

对于像divinput这样的 DOM 元素,“type”是一个字符串。而对于自定义 React 组件,“type”是一个函数的引用:

{
    "type": Input, // 对 Input 函数本身的引用
    "props": {
        "id": "company-tax-id",
        "placeholder": "Enter company Tax ID"
    }
}

协调机制的工作原理

当 React 需要更新 UI(在状态变化或重新渲染之后)时,它会执行以下步骤:

  1. 通过调用你的组件创建一个新的元素树。
  2. 将新元素树与之前的元素树进行比较。
  3. 确定需要哪些 DOM 操作,才能使真实的 DOM 与新元素树匹配。
  4. 高效地执行这些操作。

比较算法遵循以下的关键原则:

  1. 元素类型决定标识:React 首先检查元素的“type”。如果类型发生变化,React 会重新构建整个子树。
// 首次渲染
<div>
    <Counter />
</div>
// 第二次渲染
<span>
    <Counter />
</span>

由于div变为了span,React 会销毁整个旧树(包括Counter组件),并从头开始构建一个新树。

  1. 树中的位置很重要:React 会比较树中相同位置的元素。
// 之前
<>
    {showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>

// 之后(当 showDetails 变化时)
<>
    {showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>

在这个条件渲染的例子中,当 showDetailstrue 时,位置 1 处是 UserProfile 元素;当 showDetailsfalse 时,位置 1 处是 LoginPrompt 元素。React 发现相同位置的组件类型不同,所以会卸载一个并挂载另一个。

但如果有两个相同类型的组件:

// 之前
<>
    {isPrimary ? (
        <UserProfile userId={123} role="primary" />
    ) : (
        <UserProfile userId={456} role="secondary" />
    )}
</>

React 发现前后位置 1 处的组件类型都是UserProfile,所以它只会更新组件的属性,而不是销毁并重新创建组件。

  1. 键覆盖基于位置的比较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 性能模式:

  1. 为什么内联组件定义不好:在其他组件内部定义组件会在每次渲染时创建新的函数引用。
const Parent = () => {
    // 不良实践:InnerComponent 每次渲染时都会重新创建
    const InnerComponent = () => <div>Inner content</div>;

    return <InnerComponent />;
};

由于组件的“type”(函数引用)每次渲染时都在变化,React 将其视为完全不同的组件,每次都会卸载并重新挂载。

  1. 为什么组合模式有效:组合模式利用了 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 的组件树,因为它在一个单独的分支中。

  1. 使用键实现高级状态保留:基于我们对键的理解,我们可以实现一些高级模式。
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 识别和更新组件的规则。