likes
comments
collection
share

React 协调算法:它的工作原理以及为什么我们要去关心它

作者站长头像
站长
· 阅读数 14

原文链接:React reconciliation: how it works and why should we care,2023.05.11,by Nadia Makarevich

导读:这是一篇浅显易懂介绍 React 渲染机制的长文。为什么不能在组件内声明其他组件?为什么渲染列表时要用 key,其他地方却不要求加?读完本文就都懂了。放心,没有教你如何“造火箭”,只是教你如何更好地“拧螺丝”。

每次我自以为已经完全掌握 React 渲染原理时,现实总能给我带来惊喜。即使是一个简单的 if 语句也可能彻底颠覆你的思维。就在上周六,当我随意浏览 React 文档借以逃避“周末待办事项”清单上要做的事情时,这种情况再次发生了。又一次“等等,不可能吧”的疑问导致了我另一个周末计划消失在虚无之中,并紧接着开始深入了解并记录成文章。反正谁需要那些待办事项呢?毕竟它们并不重要,对吧?

下面就一起来看看摧毁我周末的这个小困惑——协调算法——它的工作原理,顺便再回答一些问题。大家有没有想过,为什么在其他组件中声明的组件,在每次父组件重新渲染时都会重新挂载?为什么列表之外我们不需要 key 属性,即使最终渲染的数据和行为表现都一样?现在是揭开谜团的时候了!

谜之处:组件的条件渲染

首先,让我们来看一个比较谜的地方。

话说我们有一个组件,会根据条件渲染。如果“某些情况”成立,就显示这个组件,不然,就显示其他内容。例如,我要为我的网站开发一个“注册”表单,这个表单中用户可以选择是否作为公司或个人进行注册。有一个需求,我只想在用户勾选“是的,我要以公司名义注册”的复选框后才显示“公司税号”输入框。而对个人呢,则显示文本:“幸运儿,你不必向我们提供税号哦。”

const Form = () => {
  const [isCompany, setIsCompany] = useState(false);

  return (
    <>
      ... // 这里有一个复选框
      {isCompany ? (
        <Input id="company-tax-id-number" placeholder="请输入公司税号" ... />
      ) : (
        <TextPlaceholder />
      )}
    </>
  )
}

如果用户从个人切换到公司的时候,也就是 isCompany 的值从默认的 false 变为 true,那么从渲染和挂载角度来看,会发生什么?

我想大家都知道的,我啰嗦一下:Form 组件会重新渲染,TextPlaceholder 组件会被卸载,Input 组件会被挂载;而如果我是取消勾选,Input 会被卸载,TextPlaceholder 会被挂载。

从表现上看,如果我之前在输入框中输入了一些内容,取消勾选又再次勾选,原本我在输入框中输入的内容都会消失。因为Input 用自己的内部状态来保存文本,在卸载时会销毁,重新创建时状态也就自然没有了。

如果我增加了个人的税号收集呢?那和公司的税号输入字段不是一样了?只不过是不同的 id、不同的 onChange 回调。自然地,我想到要这样做:

const Form = () => {
  const [isCompany, setIsCompany] = useState(false);

  return (
    <>
      ... // 这里有一个复选框
      {isCompany ? (
        <Input id="company-tax-id-number" placeholder="Enter you company Tax ID" ... />
      ) : (
        <Input id="person-tax-id-number" placeholder="Enter you personal Tax ID" ... />
      )}
    </>
  )
}

结果呢?

没有卸载发生!如果我在输入框中输入一些内容,然后勾选复选框,文本仍然存在!React 认为这两个输入框实际上是同一个,所以不会去卸载第一个、挂载第二个,而是会用第二个的数据重新渲染第一个。

点击右侧 CodeSandbox 链接查看完整示例:codesandbox.io/s/keen-fog-…。打开控制台,在勾选/取消勾选复选框时,查看组件的挂载情况——第一个例子会有挂载发生,但第二个例子不会。

如果你对此毫不惊讶,并且自信地说:“啊,这是因为……”。那么祝贺你,我能要你的签名吗?不过,对于像我这样菜鸡,看到这种状况就开始不由自主地眼皮抽搐头又痛了。

所以,是时候深入探讨 React 的协调算法(reconciliation algorithm)来获取答案了。

React 协调算法

之所以会引入协调算法,是因为直接操作 DOM 增删速度太慢了。我们使用 React 的目的是让它将我们提供的内容转换成页面上的 DOM 元素。

例如,编写下面的代码时:

const Input = ({ placeholder }) => {
  return <input type="text" id={id} />
}

// 在某个地方展示
<Input placeholder="Input something here" />

我们期望 React 在 DOM 结构中添加普通的 HTML input 标签,并将 placeholder 正确设置。如果我们更改了 React 组件中的 placeholder 值,React 应该使用新值更新 DOM 并在页面上展示。理想情况下应该是即时更新的。因此,我们不能只是简单的删除先前的 input 元素,然后添加一个包含新数据的新元素,那样会非常慢。相反,我们需要识别输入框的 DOM 元素并只更新它的属性。如果没有 React,我们会做类似于下面的操作:

const input = document.getElementById('input-id');
input.placeholder = "new data";

借助 React 我们不再需要手动完成这项工作,它会为我们处理,这是通过创建和修改我们称之为“虚拟 DOM”的来实现的。虚拟 DOM 只是一个巨大的树形结构对象,其中包含了所有要渲染的组件、它们的 props 以及子元素——也是相同结构的对象。

上例中 Input 组件结构类似下面这样:

{
  type: "input", // type of element that we need to render
  props: {...}, // input's props like id or placeholder
  ... // bunch of other internal stuff
}

如果 Input 渲染了多条内容:

const Input = () => {
  return <>
    <label htmlFor={id}>{label}</label>
    <input type="text" id={id} />
  </>
}

从 React 的角度来看,babelinput 组成了一个对象数组:

[
  {
    type: 'label',
    ... // other stuff
  },
  {
    type: 'input',
    ... // other stuff
  }
]

inputlabel 这样的 DOM 元素会有一个字符串类型的 type 属性,React 知道如何将它们转换为 DOM 元素。但是如果我们渲染的是 React 组件,那么与 DOM 元素没有直接关联了。这里就需要 React 以某种方式来解决这个问题。

const Component = () => {
  return <Input />
}

React 会将组件函数本身作为 type 属性值:

{
  type: Input, // reference to that Input function we declared earlier
  ... // other stuff
}

当 React 收到挂载应用程序的命令(初始渲染)时,它会遍历整个对象树并执行以下操作:

  • 如果 type 是字符串,则生成该类型对应的 HTML 元素
  • 如果 type 是函数(即 React 组件),就调用它并遍历返回的对象树,直到最终获得整个 DOM 节点树

比如下面这样一个组件:

const Component = () => {
  return (
    <div>
       <Input placeholder="Text1" id="1" />
       <Input placeholder="Text2" id="2" />
    </div>
  )
}

会被表示成:

{
  type: 'div',
  props: {
    // children are props!
    children: [
      {
        type: Input,
        props: { id: "1", placeholder: "Text1" }
      },
      {
        type: Input,
        props: { id: "2", placeholder: "Text2" }
      }
    ]
  }
}

挂载时,会解析为以下 HTML:

<div>
  <input placeholder="Text1" id="1" />
  <input placeholder="Text2" id="2" />
</div>

最后,一切准备就绪后,React 会使用 JavaScript 的 appendChild API 将这些 DOM 元素添加到文档中。

协调与状态更新

有趣的事情才刚刚开始。假设树中有一个包含状态的组件触发了更新(重新渲染)。React 会使用最近的新数据更新页面中的所有元素,或是添加删除一些元素。

因此,React 再次遍历对象树,从状态更新的位置开始。如果我们有以下代码:

const Component = () => {
  // return just one element
  return <Input />
}

当组件被渲染时,React 看到组件返回了这个对象:

{
  type: Input,
  ... // other internal stuff
}

React 会比较状态更新前后对象的 type 字段。如果类型相同,那么Input 组件会被标记为“需要更新”,并触发重新渲染。如果类型改变,重新渲染时,React 将删除(卸载)“先前”的组件并添加(挂载)“下一个”组件。当前状况下,type 是相同的,因为它是对同一个函数的引用。

如果要对 Input 进行条件判断。例如,返回另一个组件:

const Component = () => {

  if (isCompany) return <Input />

  return <TextPlaceholder />
}

假设,isCompany 的值从 true 变为 false,React 要比较的对象如下:

// Before update, isCompany was "true"
{
  type: Input,
  ...
}

// After update, isCompany is "false"
{
  type: TextPlaceholder,
  ...
}

你猜到结果了吗?type 的引用已经从 Input 改为 TextPlaceholder,因此 React 将卸载 Input 并从 DOM 中删除与其相关的所有内容(包括状态和输入内容),然后挂载新的 TextPlaceholder 组件,将其到添加 DOM 上。

谜底

现在让我们用新的知识来看一下一开始有些神秘莫测的代码:

const Form = () => {
  const [isCompany, setIsCompany] = useState(false);

  return (
    <>
      ... // checkbox somewhere here
      {isCompany ? (
        <Input id="company-tax-id-number" placeholder="Enter you company Tax ID" ... />
      ) : (
        <Input id="person-tax-id-number" placeholder="Enter you personal Tax ID" ... />
      )}
    </>
  )
}

如果 isCompany 变量从 true 改为 false,哪些对象会被比较呢?我们来看下:

isCompany 开始为 true

{
  type: Input,
  ... // the rest of the stuff, including props like id="company-tax-id-number"
}

后面变成 false

{
  type: Input,
  ... // the rest of the stuff, including props like id="person-tax-id-number"
}

从 React 的角度来看,type 并没有改变。前后两次都是引用的同一个函数:Input。唯一改变的是 id props:从 company-tax-id-number 变为 person-tax-id-numberplaceholder 也发生了变化。

在这种情况下,React 会做的事情——直接复用现有的 Input 组件,并使用新数据重新渲染。也就是说,与现有 Input 相关联的所有内容,如 DOM 元素或状态,仍然保留。没有任何东西被销毁。外在表现是:我在输入框中键入一些内容,点击复选框后,文本仍然存在。

这种行为不一定是坏事。假设我们就是需要重新渲染而不是重新挂载。但目前情况下,我们希望修复这种行为,确保每次切换时重新挂载输入框,因为从业务逻辑角度来看,它们是不同的实体,所以我不需要重用它们。

至少有两种简单方法可以解决这个问题:数组和 key 属性。

协调与数组

到目前为止,我们的组件只返回了一个元素。但是项目中通常会返回多个。所以,我们现在必须更详细地讨论当返回多个元素时的渲染行为。即使是我们简单的表单结构,也返回了一个数组:

const Form = () => {
  const [isCompany, setIsCompany] = useState(false);

  return (
    <>
      ... // checkbox somewhere here
      {isCompany ? (
        <Input id="company-tax-id-number" ... />
      ) : (
        <Input id="person-tax-id-number" ... />
      )}
    </>
  )
}

返回的 Fragment(<>...</>),包含的其实是一个数组结构:一个复选框和一个根据条件展示的输入框。

const Form = () => {
  const [isCompany, setIsCompany] = useState(false);

  return (
    <>
      <Checkbox onChange={() => setIsCompany(!isCompany)} />
      {isCompany ? (
        <Input id="company-tax-id-number" ... />
      ) : (
        <Input id="person-tax-id-number" ... />
      )}
    </>
  )
}

在重新渲染时,当 React 看到一个子元素数组而不是单个元素时,它会遍历这个数组,然后根据它们在数组中的位置比较“之前”和“之后”的元素及它们的 type

如果我点击复选框并触发表单重新渲染,React 会看到下面的数组结构:

[
  {
    type: Checkbox,
  },
  {
    type: Input, // our conditional input
  }
]

数组中的两个元素会逐一处理。第一个元素:之前的 type,是个复选框;之后的 type,同样是复选框,所以会重用。第二个元素,依此类推。

即使有一些元素以这种方式按条件渲染:

isCompany ? <Input /> : null

React 仍会保持元素在数组中的位置。只是有时,这个位置存储的是 null。如果我像这样重新编写表单:

const Form = () => {
  const [isCompany, setIsCompany] = useState(false);

  return (
    <>
      <Checkbox onChange={() => setIsCompany(!isCompany)} />
      {isCompany ? <Input id="company-tax-id-number" ... /> : null}
      {!isCompany ? <Input id="person-tax-id-number" ... /> : null}
    </>
  )
}

那么这里就是一个包含 3 个元素的数组:复选框、输入框或 null、输入框或 null

那么当状态改变,整个表单重新渲染时会发生什么呢?我们来看一下:

isCompany 从开始的 false

[
  { type: Checkbox },
  null,
  { type: Input }
]

变成 true

[
  { type: Checkbox },
  { type: Input },
  null
]

React 会逐项比较它们:

  • 第一项,渲染前是复选框,渲染后还是复选框
  • 第二项,渲染前是 null,渲染后是 Input
  • 第三项,渲染前是 Input 渲染后是 null

很神奇的,我们在只改变 Input 输出位置而不改变逻辑的情况下,bug 就被修复了,并且 Input 也表现出我们期望的行为!在 CodeSandbox 中查看完整示例:

codesandbox.io/s/re-positi…

协调与 key 属性

另一种修复这个 bug 的方法是使用 key 属性。

如果大家有编写过列表的经验,key 属性应该很熟悉了。当我们在遍历数组时,React 会强制我们添加 key 属性:

const data = ["1", "2"];

const Component = () => {
  // "key" is mandatory here!
  return data.map(value => <Input key={value} />)
}

现在这个组件的输出应该很清楚了:一个带 typeInput 对象数组:

[
  { type: Input }, // "1" data item
  { type: Input } // "2" data item
]

像这种动态列表,问题就在于它们是动态的。我们可以对数组重新排序、添加新项到开头或结尾,而且通常都会这么做。

现在 React 面临一个有趣的任务:该数组中的所有组件都是相同类型的。如果这些项目的顺序发生了变化,如何判断哪个是哪个呢?

[
  { type: Input }, // "2" data item now, but React doesn't know that
  { type: Input } // "1" data item now, but React doesn't know that
]

如何确保重复使用正确的现有元素?如果像之前那样仅依赖数组中元素的顺序,那么当这些元素都是有状态的,在打乱数组元素顺序后,就会导致奇怪的行为——如果你在第一个输入框中输入了一些内容,然后重新排序了数组,会发现输入的文本还是保留在第一个输入框中。

这就是我们需要 key 的原因:它基本上是 React 在重新渲染时用于标识子元素数组中的元素的唯一标识。如果一个元素既有 type 又有 key,那么在重新渲染期间,React 将根据这两个信息确定元素,并重复使用现有元素的状态和 DOM(无论它在数组的哪个位置),只要渲染“前”“后”能找到 keytype 值对应的那个元素就行。

以下面的数组为例,在重新排序之前数据如下:

[
  { type: Input, key: "1" }, // "1" data item
  { type: Input, key: "2" } // "2" data item
]

重新排序后:

[
  { type: Input, key: "2" }, // "2" data item, React knows that because of "key"
  { type: Input, key: "1" } // "1" data item, React knows that because of "key"
]

现在,有了 key,React 会知道重新渲染后需要重用曾经位于第一位置的那个元素。因此,它只会交换 Input DOM 节点的位置。我们在第一个元素中输入的文本也随之移动到第二个位置,关于 key 属性的更多例子,请参阅这篇文章

在 CodeSandbox 中查看完整示例:

codesandbox.io/s/key-attri…

为什么说这个 key 属性能解决上面表单组件中遇到的 bug 呢?事实上,key 只是元素的一个属性,它不限于在动态数组上使用。在任何子元素数组中,它都表现一致。正如我们已经发现的那样,表单中的对象定义从一开始就有问题:

const Form = () => {
  const [isCompany, setIsCompany] = useState(false);

  return (
    <>
      <Checkbox onChange={() => setIsCompany(!isCompany)} />
      {isCompany ? (
        <Input id="company-tax-id-number" ... />
      ) : (
        <Input id="person-tax-id-number" ... />
      )}
    </>
  )
}

子元素是一个数组对象:

[
  { type: Checkbox },
  { type: Input } // react thinks it's the same input between re-renders
]

我们只需要修复最初的这个定义错误,让 React 意识到那些在重新渲染之间的 Input 组件实际上是不同的组件,不应该被重用。所以,通过给这些 Input 添加一个 key,就可以完美地解决这个问题。

{isCompany ? (
  <Input id="company-tax-id-number" key="company-tax-id-number" ... />
) : (
  <Input id="person-tax-id-number" key="person-tax-id-number" ... />
)}

现在,重新渲染前后的子元素数组将会发生变化。

isCompany 从开始的 false

[
  { type: Checkbox },
  { type: Input, key: "person-tax-id-number" }
]

变为 true

[
  { type: Checkbox },
  { type: Input, key: "company-tax-id-number" }
]

由于前后两个 key 是不同的,React 将删除第一个 Input 并重新挂载第二个 Input。当我们在 Input 之间切换时,状态会被重置。

在 Codesandbox 中查看:

codesandbox.io/s/key-fix-w…

使用 key 来强制重用现有元素

还有一个很奇妙的地方,就是我们可以使用 key 属性实现元素重用。还记得下面这段代码吧,我们在 children 数组中的不同位置渲染 Input 元素修复了 bug。

const Form = () => {
  const [isCompany, setIsCompany] = useState(false);

  return (
    <>
      <Checkbox onChange={() => setIsCompany(!isCompany)} />
      {isCompany ? <Input id="company-tax-id-number" ... /> : null}
      {!isCompany ? <Input id="person-tax-id-number" ... /> : null}
    </>
  )
}

isCompany 状态值改变时,Input 组件将会卸载和挂载,因为它在数组中的位置发生了变化。但是如果我给这两个输入框都添加相同的 key 属性,那么神奇的事情发生了。

React 看到一个子元素数组在重新渲染前后,始终有一个具有相同 key 值的 Input 元素。因此,它会认为 Input 组件只是在数组中改变了位置,就会重用已经创建好的实例。如果我们输入了一些内容,即便 Input 在技术上不同,切换选项后输入内容也会被保留。

对于这个特定的示例,当然只是一种好奇的尝试,在实践中并没有什么用处。但我可以想象它被用于一些特定组件的性能调优(比如:Accordions、选项卡或 Gallery 组件)。

在 CodeSandbox 中查看

codesandbox.io/s/force-re-…

isCompany 从开始的 false

[
  { type: Checkbox },
  null,
  { type: Input, key: "tax-input" }
]

变为 true

[
  { type: Checkbox },
  { type: Input, key: "tax-input" }
  null
]

为什么我们在数组外部不需要 key?

既然困惑已经解开,作用机制也比较清晰了,我们可以更加愉快地利用协调算法。但仍然有一些小问题和疑问存在。例如,你是否注意到 React 从未强制要求你为任何组件添加 key,除非你是在遍历一个数组?

下面是一个组件定义:

const data = ["1", "2"];

const Component = () => {
  // "key" is mandatory here!
  return <>{data.map(value => <Input key={value} />)}</>
}

这是另一个组件定义:

const Component = () => {
  // no-one cares about "key" here
  return (
    <>
      <Input />
      <Input />
    </>
  )
}

两个组件完全相同:

[
  { type: Input },
  { type: Input },
]

那么,为什么在某些情况下我们需要一个 key 来让 React 正常工作,而另一些情况下又不需要呢?

区别在于第一个案例是动态数组。React 不知道你下次重新渲染时会对这个数组做什么:删除、添加或重新排序,又或者保持原样。因此它强制你使用 key 作为预防措施以避免你会操作数组。

你可能会绝对这我理解,到你要说什么? OK,你可以尝试使用相同的 key 条件渲染不在数组中的那些 Input 组件,看看效果如何。

const Component = () => {
  const [isReverse, setIsReverse] = useState(false);
  // no-one cares about "key" here
  return (
    <>
      <Input key={isReverse ? 'some-key' : null} />
      <Input key={!isReverse ? 'some-key' : null} />
    </>
  )
}

试着预测一下,如果我在这些输入框中输入了内容并切换了布尔开关 isReverse,会发生什么。我将它留作你的家庭作业 😅。

在 CodeSandbox 中查看代码:

codesandbox.io/s/playing-a…

动态数组和普通元素的结合

如果你仔细阅读了这篇文章,现在可能会有一点心悸。当我研究这些内容时,我确实有过这样的经历。因为……

  • 如果动态项目是被转换为一个子元素数组,那么它与普通元素放在一起不是没有任何区别吗
  • 如果我在动态数组后面放置了一个普通项目
  • 并且向数组中添加或删除一个项目,会发生什么

会不会导致这个数组后面的普通项目一直会重新挂载?😱 这不就导致很大的性能问题吗?

const data = ['1', '2'];

const Component = () => {

  return (
    <>
      {data.map((i) => <Input key={i} id={i} />)}
      // 这个 Input 会在数组中添加新项目后重新挂载吗 ? 
      <Input id="3" />
    </>
  )
}

如果 将它转换成由三个子元素组成的数组——前两个是动态的,最后一个是静态的。如果是这样,那么最终对象的定义差不多会是这个样子:

[
  { type: Input, key: 1 }, // input from the array
  { type: Input, key: 2 }, // input from the array
  { type: Input  }, // input after the array
]

如果我向 data 数组添加了一个项目,则第三个位置将会是数组的 key="3"Input 元素,而“后面手动”添加的 Input 将会移动到第四个位置。从 React 角度来说,它是要挂载新项的。

幸运的是,真实情况并非如此,React 其实没那么笨🥵😅。

当我们混合动态和静态元素时(类似上面代码),React 会为为动态数组创建一个小数组,而整个数组对象会作为子数组中的直接子元素。

结构类似下面这样:

[
  // 整个动态数组处在 children 数组中的第一个位置
  [{ type: Input, key: 1 }, { type: Input, key: 2 }],
  {
    type: Input // 这个 Input 被手动放在整个动态数组的后面
  }
]

我们手动放在这里的 Input 始终在 children 中的第二个位置,就不会重新挂载,也不会出现性能灾难。所以,小心脏没必要再不舒服了。

为什么我们不能在其他组件内定义组件?

关于协调算法,还有最后一个要说的,就是不能在其他组件内定义组件。为什么?我们来分析下。

我们是想要这么做:

const Component = () => {
  const Input = () => <input />;

  return <Input />
}

当然这就会导致一个问题,每当父组件(也就是这里的 Component)因为某些原因重新渲染时,内部定义的组件 Input 都会重新挂载一次,这对性能来说很糟糕,也会隐藏许多潜在问题。

从协调算法和对象定义的角度来看,Component 包含的 Input 组件代码如下:

{
  type: Input,
}

只是一个包含 type 属性的对象,引用了 Input 函数。因为这个 Input 是在 Componet 组件内创建的,所以只能在 Component 中使用,并且会在 Component 每次重新渲染时都重新创建。对 React 来说,前后两次创建的 Input 是不同的函数。这就像你做下面的判断比较一样:

const a = () => {};
const b = () => {};

a === b; // 结果总为 false

所以每次重新渲染时,Input 组件的 type 都会不同,因此 React 将删除“先前”的组件并挂载“后一个”组件,这就是为什么 Input 会被重新挂载的原因。

今天的内容就到这里了,希望从现在开始你看到的都是对象而不是 React 元素,并且可以通过查看它们在渲染输出中的位置,来预测哪个组件将重新渲染、哪个组件将重新挂载。下次有人在半夜叫醒你问这个问题时:“为什么我们需要在 React 中使用 key?你就能打他们一拳给出一个好答案了。不过你的同事可能会把你当作调试工具,所以要小心。

我还基于本文材料制作了一个视频。虽然没有像本文讲得那么详细,但有很棒的动画和视觉效果,对巩固本文知识可能有用。

当然,更多关于这方面的详细内容,可以参阅 Dan Abramov 大佬的这篇文章:《React as a UI Runtime》