react 面试必问 - react key
前言
「react key」相关问题几乎是面试的老生常谈了。也许你会说这是面试的八股文问题而已,有什么好说的。但是,实际上,正确去理解它对日常的开发工作还是有帮助的。一来能够帮助我们避免踩到坑里面,二来还有妙用之处。
什么是 react key 呢?
「react key」是一个组件实例的唯一标识,服务于 react 内部实现的一个特性。从 jsx 上来看, key
属性似乎是一个组件 prop,但是在官方定义上,它不是(类似的,还有 ref
属性)。因为,在我们组件实现的内部,你是无法通过props.key
来访问到它的。
react key 有什么用?
上面说,「react key 服务于 react 内部实现」。更具体地来说,组件的key
属性是为了提高 diff算法
在渲染列表时候的性能。有了它,react 内部就知道相比上一个渲染周期,当前的渲染周期插入,移动或者删除哪些节点。然后,我们就通过复用相应的组件实例来复用之前的 DOM 对象,减少不必要的 DOM 操作所产生的开销,从而提高界面更新的性能。
拿官方的示例代码举例子。假设我们现在有以下的 react element tree:
旧列表1
<ul>
<li>first</li>
<li>second</li>
</ul>
现在我们往列表的尾部追加一个节点 <li>third</li>
,得到一个新列表:
新列表1
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
在没有引入 key
这个特性之前,react 内部是采用「按节点顺序比对」的。这种实现方式碰上当前的业务场景,是没有问题的。因为,我们遍历新列表的第一个节点,我们就拿它跟旧数组的第一个节点进行比对,结果发现完全一样。所以,react 就决定复用之前的组件实例和 DOM 对象。
接着往下走,我们会遍历新列表的第二个节点,我们就拿它跟旧数组的第二个节点进行比对,结果发现也是完全一样。所以,react 也决定复用之前的组件实例和 DOM 对象。
到了新列表的第三个节点,react 在旧列表上找不到对应的节点。所以,react 决定新建组件实例和 DOM 对象。
以上流程,显然没有什么性能问题 - 因为我们保证了最小的界面更新动作。
但是,如果某种业务场景下,我们往旧列表的头部插入一个节点,例如这样:
新列表2
<ul>
<li>third</li>
<li>first</li>
<li>second</li>
</ul>
这种情况下,如果还是按照「按节点顺序比对」的话,那么结果是:
- 新列表
<li>third</li>
跟 旧列表<li>first</li>
内容不同 -》需要进行 DOM 更新 - 新列表
<li>first</li>
跟 旧列表<li>second</li>
内容不同 -》需要进行 DOM 更新 - 旧列表中没有
<li>second</li>
-》需要进行创建组件实例和 DOM 对象
上面示例中,新旧节点的子树只是文本而已,如果是更复杂的自定义组件,那么 react 的性能损耗会更加严重。比如,我们的新旧列表是这样的:
// 旧列表
<ul>
<li><LargeComponentA /></li>
<li><LargeComponentB /></li>
</ul>
// 新列表
<ul>
<li><LargeComponentC /></li>
<li><LargeComponentA /></li>
<li><LargeComponentB /></li>
</ul>
上面的示例中,在渲染新列表,<LargeComponentA />
和 <LargeComponentB />
又会被重新创建一直对应的组件实例和 DOM 对象。显然,这造成了巨大的,且不必要的性能浪费。
此时,react key
就应运而生。通过这个key
属性,react 让开发者来告诉 react 自己,哪些节点是在新旧渲染周期上的数据表现上是一模一样的,从而来在新的渲染周期去复用旧的组件实例和 DOM 对象。简单来说,我们现在是依据 「key
属性值的比较」而不是「节点在列表的顺序」来判断在新的渲染周期里面所复用的组件实例和 DOM 对象是什么。
如果我们给上面的示例都附上了一个列表内唯一的 key
值之后,我们的代码是这样的:
// 旧列表
<ul>
<li key="A"><LargeComponentA /></li>
<li key="B"><LargeComponentB /></li>
</ul>
// 新列表
<ul>
<li key="C"><LargeComponentC /></li>
<li key="A"><LargeComponentA /></li>
<li key="B"><LargeComponentB /></li>
</ul>
那么,在新的实现中,diff 过程是这样的:
- 旧列表中有
key
为C
的节点吗?没有,那么我们就创建新的组件实例和 DOM 对象; - 旧列表中有
key
为A
的节点吗?有,我们接着递归比对它俩的子树吧。结果是完全一样的,那么复用之前旧的组件实例和 DOM 对象; - 旧列表中有
key
为B
的节点吗?有,我们接着递归比对它俩的子树吧。结果是完全一样的,那么复用之前旧的组件实例和 DOM 对象;
显而易见,引入了key
特性后,我们实现了我们想要的性能表现 - 确实是只需要为节点 <li key="C"><LargeComponentC /></li>
创建组件实例和 DOM 对象,其他节点完全可以复用之前旧的组件实例和 DOM 对象。
不过,严谨点地说,决定是否复用之前旧的组件实例和 DOM 对象,key
值的比较只是其中一个条件而已,还有另外一个条件是「element type」的比较。下面我们从源码实现的视角看看。
从源码中看 key
的 作用
在 react-reconciler
这个包里面的 ReactChildFiber.old.js
文件中,我们可以看到「单节点 reconcile 」的源码。下面,我们只关注key
相关的代码,所以做了代码上的精简:
// packages/react-reconciler/src/ReactChildFiber.old.js
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
if (child.key === key) {
// 省略部分代码......
if (child.elementType === elementType ) {
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
return existing;
}
} else {
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 省略部分代码......
}
从上面源码可以看出,在 diff 算法的实现过程中,如果 key
和 element type
都是相同的话,则通过 useFiber()
函数,基于旧 fiber 节点和新 element 的 props 来复用旧 fiber 节点,否则直接删除旧 fiber 节点,创建新的 fiber 节点。而复用或者创建新的 fiber 节点就意味着复用旧的 DOM 对象或者创建新的 DOM 对象( fiber 节点所关联的 DOM 对象存放在它的 stateNode
属性上)。
为什么不建议使用数组的 index 值作为 key?
react 官方关于 key
的说法是「react key 最好是稳定的,在列表范围内唯一标识当前的列表元素数据」。这里,就衍生了一个面试常遇到的问题:“为什么不用使用数组的 index 值作为 key 的值呢?”。简单来回答:“因为这么做会很容易踩到坑”。注意这里的措辞:“容易踩到坑”。也就说,这么做并不是 100% 错误的,得看应用场景。如果面试官信誓旦旦地训导你说:“使用数组的 index 值作为 key 一定会导致程序出错”,那么你看着这篇文章后,你就可以反驳他这种不严谨的说法。
回到这个问题上。为什么不建议使用数组的 index 值作为 key 呢? 因为:
- 一旦遇到数组重排(直接重排或者因为插入,删除元素导致的被动重排)
- 且展示数组元素的 UI 组件里面包含了「非受控组件」
的时候,那么界面上就会出现渲染逻辑上的 bug。
下面,我们手写一个 demo 来验证一下:
import * as React from 'react';
const { useState, useEffect, useRef } = React;
const map4FirstRender = (window.map4FirstRender = {});
const map4SecondRender = (window.map4SecondRender = {});
function Item(props: { id: number; text: string; len: number }) {
const inputRef = useRef(null);
useEffect(() => {
if (props.len === 2) {
map4FirstRender[props.id] = inputRef.current;
} else {
map4SecondRender[props.id] = inputRef.current;
}
}, [props.len]);
return (
<div style={{marginBottom: 10}}>
<label>ID : {props.id} </label> + <span>非受控组件 input </span>
// 注意,是 `defaultValue` prop 让 `<input />` 组件成为了非受控组件
<input ref={inputRef} defaultValue={props.text} />
</div>
);
}
export default function App() {
const [list, setList] = useState([
{ id: 1, text: '第 1 项' },
{ id: 2, text: '第 2 项' },
]);
return (
<div>
<button
style={{marginBottom: 20}}
onClick={() => {
const newList = list.slice();
newList.splice(1, 0, { id: 1.1, text: '第 1.1 项' });
setList(newList);
}}
>
在数组中间添加一个数组元素
</button>
{list.map((item, index) => (
<Item key={index} {...item} len={list.length} />
))}
</div>
);
}
可以看到,在这个 demo 中做到了以下几点:
- 第一,我们使用数组的 index 值作为 key 的值
- 第二,渲染数组元素的 UI 组件里面包含非受控组件:
<input ref={inputRef} defaultValue={props.text} />
- 第三,我们通过往数组的中间插入了一个元素来触发列表的重渲染
那点击「在数组中间添加一个数组元素」按钮,结果是怎样的呢?结果如下:
我们期待新增节点的 <input >
框的初始值为「第 1.1 项」,但是实际上却是「第 2 项」,为什么会这样子呢?
相信你也猜到了。原因就是,新插入的节点的 key
值为 1
,而这个值正是上一个渲染周期中 ID 为 2
的节点的 key
值。所以,react 在这里复用了之前的 DOM 对象,并更新 DOM 对象上的属性。你也许会问, 那为什对于<input >
框来说,defaultValue
的值没有得到预期的更新呢? 这是因为defaultValue
的语义决定了它的值只会在 DOM 节点初次创建的时候才会生效。而此时,react 复用了之前的 DOM 节点,并没有发生 DOM 节点的创建。所以,react 就会忽略掉这个值。最终,导致了界面的更新达不到我们的预期值。
为了验证,react 真的复用上一次渲染周期中 ID 为 2
的节点所关联的 DOM 对象,我在代码中加入了两个全局变量: map4FirstRender
和 map4SecondRender
。点击按钮后,我们不妨在浏览器的控制台打印一下,看看 ID 为 1.1
的节点所关联的 DOM 对象是否就是上一轮渲染周期中ID 为 2
的节点所关联的 DOM 对象:
还是不相信?我们不妨手动操作一下 window.map4FirstRender[2]
DOM 对象的 value 属性,看看新插入的节点是否会得到更新:
可以看到,我们成功修改了新增节点里面的 input 框的值。从而证明了第二轮渲染周期中,新增节点(即 ID 为 1.1 的节点)复用了上个渲染周期中,跟它具有相同 key 值(1
)的 DOM 对象。而这个复用,造成了界面显示上出现了 bug。
值得指出的是,我在上面强调的是「渲染数组元素的 UI 组件里面包含非受控组件」。如果我们把 defaultValue
去掉,它还是「非受控组件」,还是会出现 bug。不信的话,我们在第一渲染中,在 ID 为 2
的节点中输入一些文字,然后再点击按钮,看看这些文字是不是也被「转移」到了新节点中来。
那假如,「渲染数组元素的 UI 组件里面包含的是受控组件」呢,比如,我们把defaultValue
改成 value
。结果是,界面显示是符合预期的。因为这是「受控组件」,React 会根据最新的 value
值做出 DOM 属性的更新。
小结
回到问题上来。为什么不建议使用数组的 index 值作为 key?因为特定情况下,这么做会导致上一个渲染周期中的组件实例和 DOM 对象被意外复用,从而导致了界面显示的 bug。
那哪种特定情况呢?答曰:“以下情况:”
- 列表元素会发生重排(无论被动还是主动)
- 渲染列表元素的 UI 组件包含「非受控组件」
什么情况下能使用数组的 index 值作为 key
读到这里,你肯定明白了为什么我们只是说「不建议」使用数组的 index 值作为 key 的值,而不是说「一定不能」使用数组的 index 值作为 key 的值了吧。
那么,理论上说,我们什么情况下能使用数组的 index 值作为 key 的值呢?以下的情况之一就可以这么做:
- 列表只会渲染一次
- 列表的元素不会发生重排
但是,实际上为数组元素找一个唯一标识的 ID 值也不难,大不了就生成一个。所以,拿一个「唯一标识的 ID 值」作为 react key 的值显然是一件一劳永逸且属于最佳实践的事情。
react key 妙用
可能你也知道,react key 并不只能用于列表渲染场景上,它也可以用于单个组件的渲染场景上!
react 在 reconcil 过程中,如果当前渲染周期的 key
值跟上一轮渲染周期的 key
值不相等的话,react 会卸载当前组件实例,重新从头开始创建一个新的组件实例。以下 demo 示例就可以验证这一点:
import * as React from 'react';
function Counter() {
console.log('Counter called');
const [count, setCount] = React.useState(() => {
console.log('Counter useState initializer');
return 0;
});
const increment = () => setCount((c) => c + 1);
React.useEffect(() => {
console.log('Counter useEffect callback');
return () => {
console.log('Counter useEffect cleanup');
};
}, []);
console.log('Counter returning react elements');
return <button onClick={increment}>{count}</button>;
}
export default function CounterParent() {
const [counterKey, setCounterKey] = React.useState(0);
return (
<div>
<button onClick={()=> {setCounterKey(c=> c +1)}}>reset</button>
<Counter key={counterKey} />
</div>
);
}
先点击<Counter>
组件的按钮,再点击<CounterParent>
组件的按钮,控制台的打印如下:
// 点击`<Counter>` 组件的按钮
Counter called
Counter returning react elements
// 点击`<CounterParent>` 组件的按钮
// 组件开始渲染
Counter called
Counter useState initializer
Counter returning react elements
// 卸载旧的组件实例
Counter useEffect cleanup
// 新的组件实例已经挂载到 fiber 节点上
Counter useEffect callback
主动去改变组件的key
属性值,我们能够达到「卸载旧的组件实例和 DOM 对象,重新创建新的组件实例和 DOM 对象」的效果。利用这一点,我们可以实现类似上面的「状态重置类」的任务。
还有某些情况,我们在同一个组件上去更新不同的数据的时候,你会发现更新失效,界面还是显示上一次的旧数据。如果事发紧急,那么我们就可以一个能够区分不同渲染周期的 ID 值作为这个组件的 key
值。通过这样,我们就会重新挂载组件实例,从而达到预期的组件更新效果。
总结
-
「react key」是一个组件实例的唯一标识,主要用于提高
diff算法
应用在渲染列表时候的性能。 -
当应用场景同时满足以下两个条件的时候,「使用数组
index
作为key
值」的做法是会造成界面显示上的 bug:- 列表元素会发生重排(无论被动还是主动);
- 渲染列表元素的 UI 组件包含「非受控组件」。
-
在不同的渲染周期去改变组件的
key
值,能够卸载旧的组件实例,重新创建新的组件实例。利用这一点,我们能够实现「组件状态重置」和「正确的数据更新」等效果。 -
无论何时,在列表渲染的业务场景下,为列表元素组件设置一个「唯一标识的 ID 值」作为 react key 的值显然是一件一劳永逸,且属于最佳实践的事情。
参考资料
转载自:https://juejin.cn/post/7203254152189083705