在前面的章节,我们简单涉略了React的协调器和比对算法。我们现在知道,当我们生成一个React元素(如 const a =
时,我们是在生成一个对象。类HTML的语法本质上是会转化成React.createElement
函数的语法糖。React.createElement
函数会返回一个描述对象。而这个描述对象的type字段会指明是组件,还是缓存组件,或者一个HTML标签。
此外,我们也知道:如果一个元素的类型没有变,且该元素的类型没有被React.memo
缓存,那么一个对象的引用发生变化,也会触发重新渲染。
但这只是个开头。比对的过程中,还有很多变量,而理解这个过程也是非常重要的。理解这个过程,将有助于我们修复一些隐藏的bug,实现一些表现优异的列表,在我们需要时重制状态,并且避免一些React中一些性能杀手。总体而言,这些并不是那么的显而易见,但都源自同一个主题:React如何决定哪个组件需要被重新渲染,哪个组件需要被删除,而哪个组件需要被添加在显示器上。
在这一章,我们会调研几个有趣的bug,去深入研究表现背后的原理,在这个过程中,我们将会学会:
- React的比对和协调器算法是如何工作的
- 当一个状态更新被触发时发生了什么,React需要重新渲染组件时发生了什么。
- 为什么我们不应该在其他组件内创造组件。
- 如何解决两个不同的组件共享相同的状态的bug
- React是如何渲染数组的,我们该如何影响这个过程。
- 设置key属性的目的是什么。
- 如何尽可能写出性能最出色的列表。
- 为什么我们应该在动态列表外使用key属性。
一个神秘的bug
让我们从一个神秘的bug开始。
想象一下,你要条件式地渲染一个组件。如果“某状态”为true
,展示这个组件。反之,展示其他内容。比如,我在为网站开发一个“登录”表单,而这个表单可以让个人或者公司登陆,以填写不同类型的税号。所以,我想在勾选了“是的,我在以公司的身份登录”后,展示公司税号的输入框。而当以自然人身份登录时,就不用展示税号输入框。
这个特性的代码如下:
js 代码解读复制代码const Form = () => {
const [isCompany, setIsCompany] = useState(false);
return (
<>
{/* checkbo somewhere here */}
{isCompany ? (
<Input id="company-tax-id-number" placeholder="Enter you Company Tax Id" .../>
) : (
<TextPlaceholder />
)}
>
)
}
从重新渲染和挂载的角度而言,isCompany
从false
到true
的过程中,到底发生了什么?
其实没什么惊奇的,发生的事情是很符合直觉的:Form
组件会重新渲染它自己,TextPlaceholder
会被卸载,而Input
会被挂载。如果我更isCompany的值,Input
会被卸载,而TextPlaceholder
会被挂载。
如果从行为的角度,这意味着如果点击了两次勾选框,我在Input
输入的内容将会消失。Input
有其内部状态来管理输入的文本,而这些状态会在该组件被卸载时销毁,在该组件被重建时重新生成。
但是,如果我想也收集自然人的税号,该怎么办?而且,自然人和公司的输入框需要看起来一样。两个Input
不同的是id和onChange回调函数。代码实现上,应该是这样:
js 代码解读复制代码const Form = () => {
const [isCompany, setIsCompany] = useState(false);
return (
<>
{/* checkbo 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" .../>
)}
>
)
}
这段代码背后发生了什么?
代码的运行结果也是很直观的:Input
组件并没有发生卸载。如果我在输入框输入了一些内容,又点击了勾选框,原来输入的内容依然存在!React认为这两个输入框是同一个东西,它并没有卸载第一个输入框再挂载第二个输入框,它只是用第二个输入框的数据重新渲染了第一个输入框。
代码示例: advanced-react.com/examples/06…
如果你对此一点儿都不感到惊讶,并且能毫不犹豫地说:“啊,对呀,是因为 [某个原因]”,那哇哦,我能找你要个签名吗?对于我们其余这些因为这种情况而眼皮直跳、隐隐头疼的人来说,是时候深入探究一下 React 的协调器(reconciliation)过程来寻找答案了。
比对算法(diffing)与协调器(Reconciliation)
这都源于DOM。或者,更准确的说,当我们在写React代码时,我们并不需要直接处理DOM。这是很方便的:我们只要写组件即可,不要手动操作DOM和自己比对属性。React会把我们写的组件转化成对应的DOM元素。当我们写下这样的代码:
js 代码解读复制代码const Input = ({ placeholder }) => {
return (
<input
type="text"
type="input-id"
placeholder={placeholder}
)
}
// somewhere else
<Input placeholder="Input something here" />
我们期望 React 能在 DOM 结构的恰当位置添加普通的 HTML 输入标签。如果我们更改 React 组件中的值,我们期望 React 能用新值更新我们的 DOM 元素,并能在屏幕上看到该新值。理想情况下,是即时就能看到的。所以,React 不能仅仅移除之前的输入框然后添加一个带有新数据的新输入框。那样的话速度会慢得离谱。相反,它需要识别出那个已经存在的输入框 DOM 元素,然后仅更新其属性就可以了。如果我们没有使用 React,我们就不得不做如下之类的事情:
js 代码解读复制代码const input = document.getElementById('input-id');
input.placeholder = 'new data';
在React里,我们不需要做这些,React会替我们做这些。React通过创造和处理“虚拟DOM(Virtual DOM)”来处理这些事情。虚拟DOM是一个涵盖了所有要渲染的组件及其属性和子组件的巨大对象 - 当然,这些组件的对象有着相同的形状。虚拟DOM是一个树结构。示例中的Input
组件的虚拟DOM是这样的:
js 代码解读复制代码{
type: "input", // type of element that we need to render
props: {...}, // input's props like id or placeholder
... // bunch of other interanl stuff
}
如果Input
组件渲染更多的内容:
js 代码解读复制代码const Input = () => {
return (
<>
<label htmlFor="input-id">{label}label>
<input type="text" id="input-id />
>
)
}
从React的视角而言,label
和input
不过是数组里的对象罢了
js 代码解读复制代码[
{
type: "label",
... // ohter stuff
},
{
type: "input",
... // ohter stuff
}
]
像input
和label
对应的type为字符串,React会把它们直接转为DOM元素。但是,如果我们处理的是React组件,它们则并不直接与DOM元素相关,所以React需要对此做一些处理。
js 代码解读复制代码const Component = () => {
return <Input />
}
在这个例子中,它的类型将为一个函数:
js 代码解读复制代码{
type: Input, // reference to that Input function we declared earlier
... // other stuff
}
之后,当React得到一个指令去挂载这个应用(初始渲染),它会遍历虚拟DOM树并做下面的事情:
- 如果类型为字符串,生成对应类型的DOM元素
- 如果类型为函数,调用该函数并遍历该函数返回的虚拟DOM树。
直到得到该虚拟DOM树的全部节点,比如,一个这样的组件:
js 代码解读复制代码const Component = () => {
return (
)
}
它的虚拟DOM是这样的:
js 代码解读复制代码{
type: 'div',
props: {
// children are props!
children: [
{
type: Input,
props: { id: "1", placeholder: "Text1" }
},
{
type: Input,
props: { id: "2", placeholder: "Text2" }
}
]
}
}
当它被转化成HTML元素时,是这样的
js 代码解读复制代码
<input placeholder="Text1" id="1" />
<Input placeholder="Text2" id="2" />
最后,当所有工作就绪后,React会使用JavaScript的appendChild
命令把这些DOM元素添加到真实的节点上。
协调器算法与状态更新
之后,有趣的事情开始了。如果虚拟DOM树上一个组件的状态更新被触发了。React需要更新所有共享了该状态的元素,或者新增或者删除相关元素。
之后,React开始遍历虚拟DOM树,从状态更新被触发的地方开始遍历。如果我们有这样的代码:
js 代码解读复制代码const Component = () => {
// return just one element
return <Input />
}
React在渲染时,会把Component
返回的对象理解为:
js 代码解读复制代码{
type: Input,
... // other internal stuff
}
React会在状态更新后,比较type的“前值”和“后值”。如果type的前后值都一样,React会把Input
标记为“需要更新”,并触发其重新渲染。如果type的前后值不一样,在重新渲染的过程中,React会卸载原来的组件,并挂载新的组件。在这个例子,type指向的值是一样的,因为它指向的函数索引并没有反思变化。
如果我做一个条件式渲染:
js 代码解读复制代码const Componet = () => {
if (isCompany) return <Input />;
return <TextPlaceholder />
}
之后,假设isCompany
从true
变为false
,React会比较这两个前后对象:
js 代码解读复制代码// Before update, isCompany was "true"
{
type: Input,
...
}
// After update, isCompany was "false"
{
type: TextPlaceholder,
...
}
你猜到结果了,对吧?“类型” 已经从(这里应该是前文提及的某种类型,原文未完整表述)变成了TextPlaceholder
引用,所以 React 会卸载Input
(这里推测前文提到的是Input
组件,原文此处缺失相应内容),并将与之相关的所有内容从 DOM 中移除。而且它会首次挂载新的TextPlaceholder
组件,并将其添加到 DOM 中。与输入字段相关的一切内容,包括它的状态以及你在其中输入的所有内容,都被销毁了。
为什么我们不能在组件内定义其他组件
现在我们可以很清晰的回答这个问题了。
js 代码解读复制代码const Component = () => {
const Input = () => <input />;
return <Input />
}
如果我们从协调器和定义对象的角度看这个问题,Component
返回的是这个:
js 代码解读复制代码{
type: Input,
}
在这个对象里,type属性指向的是一个函数。然而,这个函数是在Component
内定义的。这个函数组件是Component
的内部组件,会在每次重新渲染Component
时重新生成这个函数组件。所以React在比较这个函数组件的前后值时,它是在比较两个不同的函数:一个是重新渲染前生成的函数组件,一个是重新渲染后生成的函数组件。我们在第五章讲过,我们不能直接这样比对函数:
js 代码解读复制代码const a = () => {}
const b = () => {}
a === b; will always be false
结果,这个type的值在每次重新渲染时都是不一样的。React会卸载旧的组件,再挂载新的组件。
如果这个组件足够大,这个重新渲染人将会带来一个卡顿的视觉效果:它会快速消失又快速出现。这也是我们常说的“重新挂载”。而且,这对用户体验和性能都是不好的。重新挂载比普通的重新挂载要多花一倍多时间。此外,因为“前”组件及其相关的状态等被销毁了,这将会埋下一些难以定位的bug。
代码示例: advanced-react.com/examples/06…
在上面相关的代码示例中,你可以看到它是如何运行的:输入组件在每次按键时都会触发重新渲染,而且 “带有状态的组件(ComponentWithState)” 会被重新挂载。结果就是,如果你点击那个组件将其状态更改为 “活跃(active)”,然后开始输入内容,该状态就会消失。
像这样在其他组件内部声明组件可能是 React 中最影响性能的因素之一。
评论记录:
回复评论: