React 协调算法:它的工作原理以及为什么我们要去关心它
原文链接: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 的角度来看,babel
和 input
组成了一个对象数组:
[
{
type: 'label',
... // other stuff
},
{
type: 'input',
... // other stuff
}
]
像 input
或 label
这样的 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-number
,placeholder
也发生了变化。
在这种情况下,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 中查看完整示例:
协调与 key 属性
另一种修复这个 bug 的方法是使用 key
属性。
如果大家有编写过列表的经验,key
属性应该很熟悉了。当我们在遍历数组时,React 会强制我们添加 key
属性:
const data = ["1", "2"];
const Component = () => {
// "key" is mandatory here!
return data.map(value => <Input key={value} />)
}
现在这个组件的输出应该很清楚了:一个带 type
的 Input
对象数组:
[
{ 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(无论它在数组的哪个位置),只要渲染“前”“后”能找到 key
、type
值对应的那个元素就行。
以下面的数组为例,在重新排序之前数据如下:
[
{ 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 中查看完整示例:
为什么说这个 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 中查看:
使用 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 中查看
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 中查看代码:
动态数组和普通元素的结合
如果你仔细阅读了这篇文章,现在可能会有一点心悸。当我研究这些内容时,我确实有过这样的经历。因为……
- 如果动态项目是被转换为一个子元素数组,那么它与普通元素放在一起不是没有任何区别吗
- 如果我在动态数组后面放置了一个普通项目
- 并且向数组中添加或删除一个项目,会发生什么
会不会导致这个数组后面的普通项目一直会重新挂载?😱 这不就导致很大的性能问题吗?
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》。
转载自:https://juejin.cn/post/7244818841502924857