这个谜题的答案
现在,我们回到开头的谜之代码
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" .../>
)}
</>
)
}
如果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组件。在React看来,真正发生变化的是id:从"company-tax-id-number"到"person-tax-id-number"。
所以,此时React会用新的数据更新Input组件,也就是重新渲染。而与Input相关的事物并没有被销毁。这在视觉上看就是:如果我们在输入框输入内容、点击勾选框,文本都依然存在。
虽然组件这样的行为并不算差。这个表现可以通过重新渲染来复用刚刚输入的值,而非重新挂载。但是在这个场景中,我想确保每次点击勾选框时,文本都会重置。
至少有两个方法可以达到这一效果:使用数组 和 使用 key关键字。
协调器与数组
现在,我们知道数组是虚拟DOM树里的成员。但是,我们不可能写一个每个组件都只返回一个元素的React应用。我们需要讨论元素数组,以及元素数组在重新渲染过程中的表现。我们的Form组件里也有数组:
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" .../>
)}
</>
)
}
它返回了一个片段(就是: <>...</>)。这个片段的子组件是一个数组。此处的勾选框被隐藏了。完整的代码是这样的:
const Form = () => {
const [isCompany, setIsCompany] = useState(false);
return (
<>
<Checkbox onChange={() => setIsCompany(!isCompany)} />
{/* 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" .../>
)}
</>
)
}
在重新渲染过程中,当React遍历到了值为数组的子组件时,它会遍历这个数组,并依据类型关键字比较该数组的前值与后值。
如果我点击了勾选框,触发了Form的重新渲染,React会看到这样的数组:
[
{
type: Checkbox,
},
{
type: Input, // our conditional input
}
]
之后,React会逐个检查数组成员。第一个元素的type的前值是 Checkbox,后值也是Checkbox.React会复用这个组件并重新渲染它。第二个元素,亦复如是。
即便一些元素是这样条件式渲染的:
isCompany ? <Input /> : null;
React也会有稳定的数组成员。所以,我们可以把组件这样重写:
const Form = () => {
const [isCompany, setIsCompany] = useState(false);
return (
<>
<Checkbox onChange={() => setIsCompany(!isCompany)} />
{/* checkbox somewhere here */}
{isCompany ? (
<Input id="company-tax-id-number" placeholder="Enter you Company Tax Id" .../>
) : null}
{!isCompany ? (
<Input id="person-tax-id-number" placeholder="Enter you personal Tax Id" .../>
) : null}
</>
)
}
这个数组将永远只有三个成员,第一个成员是Checkbox,第二个成员是Input或null,第三个成员是Input或null。
所以,在状态变化并重新渲染这整个表单时,到底发生了什么?
发生变化前,isCompany为false:
[{ type: Checkbox}, null, { type: Input }];
发生变化后,isCompany为true:
[{ type: Checkbox}, { type: Input }, null]
而React一个一个比对前后值时,会是:
- 第一个成员,前值后值都是
Checkbox-> 重新渲染Checkbox - 第二个成员,前值是
null,后值是Input-> 挂载Input - 第三个成员,前值是
Input,后值是null-> 卸载Input
如此一来,我们就实现了isCompany变动时,文本的值也重置的效果了。
代码示例: advanced-react.com/examples/06…
协调器与key关键字
另一个解决这个bug的方法是: 使用key关键字。
对于使用React来写列表的人来说,key关键字已经是再熟悉不过了。React强制我们为列表添加key关键字:
const data =['1', '2'];
const Component = () => {
// key is mandatory here!
return data.map((value) => <Input key={value} />)
}
而其对应的虚拟DOM也是很清晰的:
[
{ type: Input }, // "1" data item
{ type: Input }, // "2" data item
]
但像这样的动态列表存在的问题在于,它们是动态的。我们可以对它们重新排序,在开头或末尾添加新的项目,而且通常可以随意对它们进行操作。
现在,React要面对一个有趣的任务:数组中的所有组件都是一样的类型。如何区分哪个是哪个?如果数组的顺序发生了变化:
[
{ type: Input }, // "2" data item now, but React desn't know that
{ type: Input }, // "1" data item now, but React desn't know that
]
如何确保正确的已有元素被重复使用呢?因为如果仅仅依靠该数组中元素的顺序,它会将第二个元素的数据用于第一个元素的实例,反之亦然。如果这些项目(元素)有状态,这将会导致奇怪的行为:状态会保留在第一个项目中。如果你在第一个输入字段中输入了某些内容,然后重新排列数组,输入的文本仍会保留在第一个输入框中。
这正是为什么我们需要key关键字:它是React在进行前后比对时区分每一个数组成员的标识符。如果一个元素的"key"和"type"都没有发生变化,React就可以更好的复用这个元素。
在这个数组中,有了key的数组应该是这样的。调整顺序前:
[
{ type: Input, key: '1' }, // "1" data item
{ type: Input, key: '2' }, // "2" data item
]
调整顺序后:
[
{ type: Input, key: '2' }, // "2" data item, React knows that beacause of "key"
{ type: Input, key: '1' }, // "1" data item, React knows that beacause of "key"
]
现在,有了key关键字,React知道了在重新渲染时,它需要复用已经创造的元素,把它移到第一个位置。它只要互换这两个inputDOM节点即可。我们在第一个input输入的文本,会被挪到第二个文本框的位置。
代码示例: advanced-react.com/examples/06…
key关键字与被缓存的列表
有一个普遍的误解是:key关键字不是是用来提升应用性能的。为动态列表添加key关键字可以防止数组重新渲染。但是从上面的例子,大家可以知道,key关键字不是这样发生作用的。key关键字的作用在于,帮助React知道哪些元素在重新渲染时可以复用。重新渲染依然会发生,就像渲染在其他组件的子组件一样。
如果我们想避免列表的重新渲染,我们需要使用React.memo。对于静态数组,这是非常简单的:只要把需要缓存的元素项包裹在React.memo里,然后为数组赋予id属性或者其在数组的下标即可。因为这些值在静态列表里都是稳定的。
const data = [
{ id: 'business', placeholder: 'Business Tax' },
{ id: 'person', placeholder: 'Person Tax' },
];
const InputMemo = React.memo(Input);
const Component = () => {
// array's index is fine here, the array is static
return data.mao((value, index) => {
<InputMemo
key={index}
placeholder={value.placeholder}
/>
})
}
如果Parent组件的重新渲染被触发了,没有一个InputMemo会重新渲染:它们被包裹在React.memo里面,针对每一个成员的key关键字并没有变化。
如果缓存的是动态数组,情况就更有趣了,而这这是key关键字发挥作用的时候。当我们通过为数组重新排序来触发重新渲染时,会发生什么?
// array befor re-render
[
{ id: 'business', placeholder: 'Business Tax' },
{ id: 'person', placeholder: 'Person Tax' },
];
// array after re-render
[
{ id: 'person', placeholder: 'Person Tax' },
{ id: 'business', placeholder: 'Business Tax' },
];
如果我们还是使用index作为key的值,那么在React视角,在重新渲染前和重新渲染后,key= "0"的元素始终是列表的第一个元素。但是,属性placeholder将会从Business Tax 变为Person Tax。那么,即使这个元素被缓存了,但是属性发生了变化,所以React会把它视作没有被缓存一样重新渲染。
修复这个bug其实很简单:我们只要确保key能更好的匹配每一个成员。在这个例子中,我们可以使用id作为key的值:
const Parent = () => {
// if array can be sorted, or number of its items can change,
then "index" as "key" is not a good idea
// we need to use something that identifies an array item instead
return sortedData.map((value, index) => (
<InputMemo
key={value.id}
placeholder={value.placeholder}
/>
));
};
如果列表数据没有唯一性标识符,我们需要在这个重新渲染的组件外重新遍历这个数组,并手动添加id。
在我们输入的情况中,如果我们使用 id 作为键(key),那个 key="business" 的项仍会带有 placeholder="Business Tax" 这个属性,只是它在数组中的位置会有所不同。所以 React 只会交换相关联的 DOM 节点位置,但实际的组件并不会重新渲染。
如果我们在数组开头添加另一个输入项,情况也完全一样。如果我们使用数组的索引作为键(key),那么从 React 的角度来看,原本 placeholder 属性为 Business Tax 的那个项,其属性会从 Business Tax 变为 New tax;而原本 placeholder 属性为 Person Tax 的项会变成 Business Tax。所以它们两者都会重新渲染。并且那个带有 placeholder 属性值为 Person Tax 的新项将会从头开始挂载。
并且如果我们改为使用 id 作为键(key),那么 “Business Tax” 和 “Person Tax” 这两项都会保留它们各自的键,由于它们已经被 memoized(记忆化)了,所以它们不会重新渲染。而那个键为 “New tax” 的新项将会从头开始挂载。