第六章 深入比对算法与协调器--上

在前面的章节,我们简单涉略了React的协调器和比对算法。我们现在知道,当我们生成一个React元素(如 const a = <Child />时,我们是在生成一个对象。类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 />
            )}
        </>
    )
}

从重新渲染和挂载的角度而言,isCompanyfalsetrue的过程中,到底发生了什么?

其实没什么惊奇的,发生的事情是很符合直觉的: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的视角而言,labelinput不过是数组里的对象罢了

js 复制代码
[
    {
        type: "label",
        ... // ohter stuff
    },
    {
        type: "input",
        ... // ohter stuff
    }
]

inputlabel对应的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 (
        <div>
         <Input placeholder="Text1" id="1">
         <Input placeholder="Text2" id="2">
        </div>
    )
}

它的虚拟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 复制代码
<div>
    <input placeholder="Text1" id="1" />
    <Input placeholder="Text2" id="2" />
</div>

最后,当所有工作就绪后,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 />
}

之后,假设isCompanytrue变为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 中最影响性能的因素之一。

相关推荐
Q8255649925 分钟前
‌无法运行CAD缺少依赖组件Microsoft Edge WebView2 Runtime‌,或您没有足够权限来运行 AutoCAD解决方案
前端·microsoft·edge
@前端小菜31 分钟前
探秘JavaScript:手写memoize函数全解析
开发语言·javascript·ecmascript
半点寒12W44 分钟前
css3过渡总结
前端·css·css3
刻刻帝的海角1 小时前
CSS 动画相关属性
前端·css
用户738228775191 小时前
构建一个基于SQL数据的问答系统:从入门到精通
前端
摇光931 小时前
js实现数据结构
开发语言·javascript·数据结构
源之缘-OFD先行者1 小时前
TypeScript 使用 VSCode 简介
javascript·vscode·typescript
潜龙在渊灬1 小时前
this指向和例外的箭头函数
前端·javascript·程序员
lgc6532 小时前
使用多模态大模型转换office文档
javascript·aigc
微臣愚钝2 小时前
前端【3】--CSS布局,CSS实现横向布局,盒子模型
前端·css