前端周刊9-React协调机制:组件背后的隐形引擎

2025-04-25

英文原文:https://cekrem.github.io/posts/react-reconciliation-deep-dive/

在之前的文章中,我探讨了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 ? '取消' : '编辑'}
      </button>
      {
        isEditing ? (
          <input
            type='text'
            palceholder='请输入姓名'
            className = 'edit-input'
            />
        ) : (
          <input
            type='text'
            placeholder='请输入姓名'
            disabled
            className='view-input'
            />
        )
      }
    </div>
  )
  
}

当你与这个表单交互时,会出现一个有趣的现象:在编辑状态下输入内容后,点击“取消”按钮,再次点击“编辑”时,之前输入的文字依然存在!尽管这两个 **input**的属性不同(一个被禁、且类名不同),却依然保持了这个效果。

这是因为React会保留DOM元素和状态,只要它们在组件树中的相同位置属于同一类型(input)。React只是更新现有元素的属性,而不会重新创建它们。去CodePen体验:https://codepen.io/showmecode_ahh/pen/LEEyEdP

image-tsvw.png

但是如果我们改成这样:

{
  isEditing ? (
    <input type="text" placeholder="输入姓名" className="edit-input" />
  ) : (
    <div className="view-only-display">此处显示姓名</div>
  );
}

这个时候切换编辑模型,会导致完全不同的元素被挂载和卸载,所有用户输入都会丢失。

这个时候,揭示了React协调机制的核心原则:元素类型是确定身份的首要因素。理解这一概念是掌握React性能优化的关键。

元素树,不是虚拟DOM

你可能听说过React使用“虚拟DOM”来优化更新。这种理解方式虽然有助于思考,但是更准确的说法应该是:React内部维护着一个元素树——这是对于界面内容的一种轻量级描述。

当你编写如写JSX时:

const Component = () => {
  return (
    <div>
      <h1>你好</h1>
      <p>世界</p>
    <div/>
  )
}

React会将其转换为纯JavaScript对象:

{
  type: 'div',
  props: {
    children: [
      {
        type: 'h1',
        props: {
          children: '你好'
        }
      },
      {
        type: 'p',
        props: {
          children: '世界'
        }
      }
    ]
  }
}

对于div和input这类DOM元素,type是字符串。而对于自定义的React组件,type则直接指向组件函数本身:

{
  type: Input, //直接引用Input组件函数
  props: {
    id: 'company-tax-id',
    placeholder: '输入公司税号'
  }
}

如何协调工作

当React需要更新用户界面(状态变更或重新渲染后),它会:

  1. 通过调用组件函数生成新的元素树
  2. 将其与之前的元素树进行对比
  3. 计算出使真实DOM匹配新树所需的操作
  4. 高效执行这些DOM操作

比对算法遵循以下核心原则:

元素类型决定身份标识

React首先检查元素的“类型”。若类型发生变化,React将重建整个子树:

// 初次渲染
<div>
  <Counter />
</div>

// 二次渲染
<div>
  <Counter />
</div>

由于div变为span,React会销毁整个旧树,包括Counter,并从头构建新树。

组件在树中的位置至关重要

React的协调算法高度依赖组件在树结构中的位置。在差异比对过程中,位置是组件身份的首要标识

// showDetails 为 true: 渲染 UserProfile, showDetails 为 false: 改为渲染 LoginPrompt  
<>  
  {showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}  
</>  

在这个条件渲染示例中,React将片段的首个节点位置视为一个“插槽”。当showDetails从true变为false时,React会对比该位置前后的组件类型UserProfile与LoginPrompt。由于位置1的组件类型发生了变化,React会完成卸载旧组件(包括其状态)并挂载新组件。

这种基于位置的识别机制也解释了为何在简单场景下组件能保持状态:

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

无论isPrimary值如何变化,React始终能在相同位置检测到相同组件类型(UserProfile)。因此它会保留组件实例,仅更新其属性而非重新挂载。

这种基于位置的策略在多数场景下表现良好,但在以下情况会产生问题:

  • 组件位置动态变化时(如排序列表)
  • 需要保持组件在不同位置间移动时的状态
  • 需要精确控制组件何时被重新挂载

这正是React的key系统发挥作用的地方

Key属性覆盖基于位置的比较规则

通过key属性,开发者能够显式控制组件标识,这会覆盖React默认基于位置的识别机制

const TabContent = ({ activeTab, tabs }) => {
  return (
    <div className="tab-container">
      {tabs.map((tab) => (
        // key 会覆盖基于位置的比较逻辑
        <div key={tab.id} className="tab-content">
          {activeTab === tab.id ? (
            <UserProfile
              key="active-profile"
              userId={tab.userId}
              role={tab.role}
            />
          ) : (
            <div key="placeholder" className="placeholder">
              选中此标签页查看 {tab.userId} 的个人资料
            </div>
          )}
        </div>
      ))}
    </div>
  );
};

即便UserProfile组件在条件渲染中出现在不同位置,只要key相同,React就会将其视为同一组件。当标签页激活时,由于“active-profile”这个key保持不变,React就会保留组件状态,从而实现更流畅的标签页切换效果。

这展示了key的强大之处:它能维持组件身份与渲染树中的结构位置无关,是控制React协调组件层次结构的重要工具。

这里我做了一些demo,可以体验一下:https://codepen.io/showmecode_ahh/pen/azzWdro

Key的神奇能力

Key在列表中扮演着关键角色,但它对React的协调过程有着更深刻的影响。

为什么列表需要Key

当渲染列表时,React依赖键来追踪哪些元素被新增、删除或重新排序:

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

如果没有Key,React只能基于元素在数组中的位置进行比对。如果,你在列表头部插入一个新元素,React会认为所有元素的位置都发生了改变,从而导致整个列表重新渲染。

而有了Key,React就能准确识别相同的元素,无论它们在列表中的位置如何变化。

Key是否只用于列表

对于静态元素,React并不要求使用Key

// 不需要键  
<>  
  <Input />  
  <Input />  
</>  

这是因为React知道这些元素是静态的,它们在组件树中的位置是可预测的。

然而,Key的作用不仅限于列表。来看这个例子:

const Component = () => {  
  const [isReverse, setIsReverse] = useState(false);  

  return (  
    <>  
      <Input key={isReverse ? "unique-key" : null} />  
      <Input key={!isReverse ? "unique-key" : null} />  
    </>  
  );  
};  

isReverse状态切换时,“unique-key”会从一个输入框转移到另一个输入框,这会导致React将组件状态(例如输入内容、焦点等)在两者之间“迁移”

我做了一个demo,可以自己试试:https://codepen.io/showmecode_ahh/pen/qEEmbGd

动态与静态元素混合使用

开发者经常会担心向动态列表中添加元素是否导致其后静态元素的标识发生变化:

<>
  {items.map((item) => (
    <ListItem key={item.id} />
  ))}
  <StaticElement /> {/* 如果items变化,这个静态组件会重新挂载吗? */}
</>

React对此处理非常智能。它会将整个动态列表视为位于第一个位置的独立单元,因此无论列表如何变化,StaticElement总能保持自身的位置和标识。以下是React在内部的实际表达方式:

[
  // 整个动态数组被视为一个子元素
  [
    { type: ListItem, key: "1" },
    { type: ListItem, key: "2" },
  ],
  { type: StaticElement }, // 始终保持在第二位
]

即使对列表进行增删操作,StaticElement在父级数组中的位置也始终是第二位。这意味着当列表变化时,它不会重新挂载。这种巧妙的优化确保了静态元素不会因相邻动态列表的变化而被迫重新挂载。

控制DOM的Key策略

Key不仅限于列表渲染——在React中,它是控制组件与DOM元素身份的终极武器。但需要注意的是:Key与组件类型共同决定状态保留逻辑。如果两个组件key相同,但类型不同,React仍会触发卸载和重新挂载。这个时候,状态提升才是更优解。

场景一:跨视图父级保留状态(在此场景key无效

// 状态提升方案:通过父组件集中管理状态。这是React设计哲学中状态控制权的取舍问题——当子组件关系复杂时,应该让更高层级的组件掌握关键状态。
const TabContent = ({ activeTab }) => {
  // 标签页共享的状态
  const [sharedState, setSharedState] = useState({ /* 初始状态 */ });

  return (
    <div>
      {activeTab === "profile" && (
        <ProfileTab state={sharedState} onStateChange={setSharedState} />
      )}
      {activeTab === "settings" && (
        <SettingsTab state={sharedState} onStateChange={setSharedState} />
      )}
    </div>
  );
};

场景二:非受控组件的强制重置

// 通过 key 重置非受控组件的 DOM 实例
const UserForm = ({ userId }) => {
  return (
    <form>
      <input
        key={userId}          // key 变化触发 DOM 重建
        name="username"
        defaultValue=""       // 非受控输入特性
      />
    </form>
  );
};

当userId变化时,React会创建全新的输入框DOM元素。因为非受控组件的状态由DOM自身托管,旧状态自然被丢弃。

状态托管:一种强大的性能模式

状态托管是一种将状态保持在最接近其使用位置的模式。它通过确保只有直接接受状态变化影响的组件会更新,从而减少不必要的重新渲染。

我们看看以下示例:

// 性能较差 —— 每次筛选条件变化时整个应用重新渲染
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)都会重新渲染。因为filterText状态存储在App组件内,当它变化时,React会沿着组件树递归渲染所有子组件,优化方案是通过将筛选状态(filterText)托管到仅涉及searchBox和UserList的UserSection组件中:

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 />
    </>
  );
};

现在filterText变化,只有UserSection会重新渲染,ExpensiveComponent不受影响。这种模式不仅提升了性能,还让组件的指责更加清晰——每个组件仅管理自身相关的状态,提高代码的可维护性。

组件设计:为变更做优化

性能优化往往源于组件设计问题。若单个组件承担过多指责,就容易发生不必要的重新渲染。

在使用 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} />;  
};  

协调和整洁架构

以下是关于协调(Reconciliation)的深刻理解与整洁架构原则的完美契合:

  • 单一职责原则:每个组件应当只有一个变更理由。当组件专注于单一职责时,不易触发不必要的重新渲染。
  • 依赖倒置原则:组件应依赖与抽象而非具体的实现。通过组合方式优化性能会更加容易。
  • 接口隔离原则:组件的结构应当精简且有针对性。这能减少属性变更引发不必要的重新渲染

实践指南

基于对协调机制的深入探讨,以下为实践指南:

  • 将组件定义放在父组件外部,避免重新挂载
  • 下移状态以隔绝渲染范围
  • 保持同位置组件类型的一致性,防止意外卸载
  • 策略性使用key——不仅限于列表,任何需要控制组件身份的场景均可应用
  • 调试渲染问题时,从元素树和组件身份的角度考虑
  • 谨记React.memo仅是协调机制约束下的工具,并不会改变核心算法。

结论

理解React的协调算法,它揭示了组合模式为何高效、列表为什么需要key、以及组件嵌套定义为何存在问题。