React key究竟有什么作用?深入源码不背概念,五个问题刷新你对于key的认知
![](https://img2022.cnblogs.com/blog/1213309/202207/1213309-20220703165700777-2072041167.png)
壹 ❀ 引
我在【react】什么是fiber?fiber解决了什么问题?从源码角度深入了解fiber运行机制与diff执行一文中介绍了react
对于fiber
处理的协调与提交两个阶段,而在介绍协调时又顺带解释了另一个较为重要的概念diff
。那既然提到了diff
我们还会顺带问一问diff
中另一个有趣的概念key
,那么现在我来问大家,你是如何理解key
的,key
又有什么作用呢?请大家思考一会如何回答。
我想,超过一大半的人会说,key
在diff
时能起到标记的作用,比如往一个数组前面添加一个元素,react
通过key
能清晰知道它只用新增一个节点,而另外两个节点可以直接复用,从而极大优化性能。
正如官网在介绍key
时的例子所言:
当子元素拥有 key 时,React 使用 key 来匹配原有树上的子元素以及最新树上的子元素。以下例子在新增 key
之后使得之前的低效转换变得高效。
<!-- 更新前 -->
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
<!-- 更新后 -->
<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
现在 React 知道只有带着 '2014'
key 的元素是新元素,带着 '2015'
以及 '2016'
key 的元素仅仅移动了。
那么这个回答有问题吗?官方都这么说了,那大概是没问题的;但如果我是面试官,我会基于这个回答再抛出如下几个问题:
-
为什么
list
渲染时我们不提供key react
就会给出警告,而普通dom
结构不提供key
却不会如此?说说你的理解。 -
react
中的key
真的有这么聪明吗?在列表渲染时通过key react
就能知道哪些是新增的哪些是可以直接复用(仅仅是移动了)? -
我们知道
react
的diff
是逐层比较的,假设现在有一个数组为:const list = [ {key:2015,value:1}, {key:2016,value:2}, ]
我们在更新后
list
为:const list = [ {key:2014,value:0}, {key:2015,value:1}, {key:2016,value:2}, ]
按照逐层比较的概念,它应该是这样:
那岂不是每一次比较都会认为
key
不同?每一层对比后都得重新渲染?那所谓的优化又是怎么做的呢? -
按照
diff
逐层对比的逻辑,如果新旧节点的key
相等,则证明这个旧节点还可以复用。而我们不提供key
时,key
将默认为null
;既然你又是逐层对比,而此时null === null
也为true
,也能够复用,那为什么还要提供独一无二的key
? -
为什么不推荐使用
index
作为key
,原因是什么?
通过这五个问题,其实你能发现react
官方基于key
的解释其实是特别宏观的角度,如果你稍微了解过源码,你甚至会发现官方这个结论还有点经不住推敲,那么就让我们带着这几个问题投身于react
源码中,通过这几个问题来重新理解react
中的key
。
注意,本文的源码分析均基于react 17.0.2
版本,那么本文开始。
贰 ❀ 深入理解react中的key
如果你有留意react
官方文档,key
的解释是在介绍list
结构时所强调的概念,这也证明了key
对于非list
结构并不重要(一般我们直接不加key
),这也说明在源码层diff
一定会对于是否是list
做逻辑区分,简单点来说,针对非list
的源码逻辑处理,你加不加key
一点也不重要。
老实说,上文我抛出的五个问题的结论其实是彼此关联和依赖的,所以在解释这几个问题之前,我先给出二个比较核心的结论(后面会从源码层解释这个结论):
react
对于非list
结构的的新旧节点对比确实是逐层对比,但对于list
结构且假设添加了独一无二key
时并不一定如此。diff
对比是先对比key
,若key
不同直接重新创建节点,若key
相同则再对比type
(标签类型),如果type
不同同样重新创建;因此只有key type
都相同时,react
才会基于旧节点结合新props
生成新节点。
先记住这两个结论,下文我会连着结论以及上文的问题依次给出解释。
贰 ❀ 壹 为什么非list 结构不提供key不会有警告?
站在react
设计角度,结合我对于源码的理解,我来说说我的看法。
我们都知道list
的节点始终是动态生成的,每次数据的变更都会导致list
需要map
生成一份新的列表(宏观角度确实是重新遍历生成),站在react
的角度,它需要考虑list
数据规模大小是否会造成性能问题,所以在diff
源码层才有了当key
与type
都相同时,react
会利用旧fiber
节点的数据clone
一个新的fiber
节点,而不是重新创建一个全新的fiber
节点。
// 当diff判断新旧节点的key与type都相同时,会使用旧fiber节点以及新的props来clone生成一个全新的fiber
function useFiber(fiber, pendingProps) {
var clone = createWorkInProgress(fiber, pendingProps);
clone.index = 0;
clone.sibling = null;
return clone;
}
而对于list
结构,在某些情况下react
会使用key
来缓存旧fiber
节点便于后续对比,缓存的逻辑如下:
function mapRemainingChildren(returnFiber, currentFirstChild) {
// 创建一个map
var existingChildren = new Map();
// 这个是旧fiber节点
var existingChild = currentFirstChild;
// 只要旧fiber节点不会空,就一直遍历
while (existingChild !== null) {
if (existingChild.key !== null) {
// 如果fiber节点的key不会null,那就通过key==>fiber的形式存起来
existingChildren.set(existingChild.key, existingChild);
} else {
// 假设为null,那就用index==>fiber形式存起来
existingChildren.set(existingChild.index, existingChild);
}
// 将existingChild赋予成当前fiber的兄弟节点,然后继续while
existingChild = existingChild.sibling;
}
// 返回缓存后的map
return existingChildren;
}
![](https://img2022.cnblogs.com/blog/1213309/202207/1213309-20220703165853799-2096591862.png)
但需要注意的是,并不是只要是list
结构 react
就会利用key
缓存旧节点。经测试,只有当key
独一无二,且key
不相同时才会触发缓存逻辑,比如如下情况:
<!-- 更新前 -->
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
<!-- 更新后 -->
<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
第一次对比时,由于2014 !== 2015
,react
就会想,你小子是不是在数组前或者数组中间插入了新元素了,为了避免逐层对比导致接下来的每个节点都要重新创建,此时会跳出之前的diff
逻辑来到mapRemainingChildren
方法,然后把旧节点存在map
中,之后再借用map + key
来达到旧节点的对比与复用。
而如下例子是在数组之后插入了一个元素,这就导致2015 === 2015
,所以react
并不会走到缓存逻辑,毕竟你key
对比就已经相同了,之后判断type
都是li
,说明新旧节点可能就只有props
不同,那就直接复用更新就好了,没必要去缓存:
<!-- 更新前 -->
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
<!-- 更新后 -->
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
<li key="2016">Connecticut</li>
</ul>
所以来到非list
情况,dom
结构基本上是稳定的,你很难遇到dom
插入新节点的场景,更多变化的是模板语法中的变量或者其它样式,所以react
也根本没必要利用key
去存储这些不怎么变化的节点。
而且站在性能优化的角度,第一大忌就是提前的过度优化。你想想,是list
有key
还能用key
,那些非list
不提供key
你拿null
来存吗?都是null
的情况下react
怎么知道谁是谁,难道强硬规定所有dom
都需要提供key
?而且即便强制开发者都提供key
存所有fiber
节点,你还需要考虑map
对于内存占用以及是否会造成内存泄漏的问题,所以想想就知道这样的设计非常不合理。
一句话总结,对于非list
结构很难出现dom
经常变动的情况,逐层对比就已经满足新旧节点对比的需求;而对于list
结构数据会经常变动,当头部或中部插入新数据时,逐层对比会因为对比错位而失效,所以需要key
来缓存旧节点,从而借用map
修正逐层对比。
贰 ❀ 贰 react的key真的有那么聪明吗?
针对官方所给的例子,假设数组前添加了一个元素,通过key react
能知道只用新增一个,其它都只是移动了位置的结论,我更倾向于react
需要考虑react
初学者,且为了凸显key
的作用,所以描述上显得key
非常智能,但事实上并不是如此。
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
// 获取新虚拟dom的key
var key = element.key;
// 旧有的div fiber节点
var child = currentFirstChild;
// 遍历当前节点的以及它的所有兄弟节点,注意下面的child = child.sibling,不为空就一直遍历对比
while (child !== null) {
// 对于非list这里都是null,也相等
if (child.key === key) {
switch (child.tag) {
default:
{
// 只有元素的type类型也相等时,才会走更新fiber的逻辑
if (child.elementType === element.type || (
isCompatibleFamilyForHotReloading(child, element) )) {
deleteRemainingChildren(returnFiber, child.sibling);
// 根据新的虚拟dom的props来更新旧有fiber节点
var _existing3 = useFiber(child, element.props);
// ....
}
break;
}
}
deleteRemainingChildren(returnFiber, child);
break;
} else {
// 如果key不相等,直接在父节点上把自己整个都删掉
deleteChild(returnFiber, child);
}
// 将兄弟节点赋予child,继续走while遍历
child = child.sibling;
}
}
react
并不能根据key
相同就能断定旧有节点只是移动了,最简单推翻这个结论的例子就是key
相同但type
不同,比如:
<!-- 更新前 -->
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
<!-- 更新后 -->
<div>
<span key="2014">Connecticut</span>
<span key="2015">Duke</span>
<span key="2016">Villanova</span>
</div>
在前面的diff
过程我们也说了,因为list
对比某些情况还会借用key
来缓存旧fiber
节点,它起到一个标志作用,比较完key
还是需要比较type
是否相同,即便type
相同我们还不能保证props
是否相同,只要你能走到diff
这一步,必定是key、type
或者props
某一个变了,就一定得更新fiber
节点,这是毋庸置疑的,所以根本就不存在diff
过程中直接完整复用旧节点的说法。
官方的对于旧节点只是移动了其实具有一定的误导性,源码层还是走了clone
逻辑,只是相对重新创建代价更小。
贰 ❀ 叁 list 头部插入新元素的diff过程
针对第三个问题,前文也已经说过了,react
对于list
的diff
不一定是逐层的,当你没提供key
,或者key
提供的是index
,这会导致前后节点的key
始终相等,从而继续判断type
来决定是否更新复用旧fiber
节点。
而当list
对比且key
不同时(数组头部或者中间插入元素时),react
会先声明一个map
然后以此利用key
依次缓存旧fiber
节点,之后再根据新的虚拟dom
节点的顺序,通过key
从这个map
里获取旧fiber
节点,如果能获取到,那就看看type
是否相同,依次判断是否能用旧fiber
节点进行更新;如果通过key
从map
获取不到,那说明这个节点就是一个全新的,直接重新创建。
说到底,key
确实起到了标记的作用,但它的标记更多针对的是数组头部或者数组中间插入新数据的场景,只要key
不同了,react
就知晓不能继续逐层对比了,不然接下来肯定的key
肯定会全部不同导致全部重新创建,因此才能根据key
的独一无二建立旧fiber
的map
,并以此更新那些因插入导致原有对比顺序被打乱的旧节点。
接下来给大家展示下当数组头部插入新元素list
对比的部分源码,大家可以结合上文在数组头部插入key=2014
的例子来理解:
// 当子元素是数组时,会进入此方法进行diff
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
// mapRemainingChildren的源码上面解释过了,定义map根据key依次缓存旧节点,注意,只有头部或者中部插入元素,才会触发这里的逻辑
var existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 遍历新的虚拟dom节点
for (; newIdx < newChildren.length; newIdx++) {
// 通过遍历新虚拟dom节点,依次更新旧map存储的节点,具体定义如下
var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
// 删除部分不影响理解的逻辑
}
比如我们在数组前塞了一个key=2014
的新节点,react
在第一次对比是,发现2014! == 2015
,外加上这块又是数组diff
的逻辑,所以react
会猜测你是不是在数组前面或者中间插入了元素,从而导致key
不同,因此才会调用mapRemainingChildren
提前把旧fiber
存入map
。
结合例子,那么此时的newChildren
就是三个虚拟dom
,然后依次遍历,与mapRemainingChildren
返回的map
节点做对比更新。紧接着我们来看updateFromMap
的实现:
// updateFromMap具体实现
function updateFromMap(existingChildren, returnFiber, newIdx, newChild, lanes) {
// 如果新虚拟节点类型是数字或者字符串,走updateTextNode更新文本的逻辑
if (typeof newChild === 'string' || typeof newChild === 'number') {
var matchedFiber = existingChildren.get(newIdx) || null;
return updateTextNode(returnFiber, matchedFiber, '' + newChild, lanes);
}
// 如果新节点是对象类型
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
{
// 利用key(可能是key也可能是index)从map中获取对应的旧fiber节点
var _matchedFiber = existingChildren.get(newChild.key === null ? newIdx : newChild.key) || null;
// 更新旧fiber节点
return updateElement(returnFiber, _matchedFiber, newChild, lanes);
}
}
}
return null;
}
这个方法做的事情也很简单,判断新节点的类型,是数字或者字符串,那就走文本更新的方法,反之就走更新对象的方法。而在对象更新中,我们看到了existingChildren.get()
的逻辑,react
通过key
来获取旧的fiber
节点,之后又通过updateElement
来做进一步的更新:
function updateElement(returnFiber, current, element, lanes) {
// 判断旧fiber节点是否存在,存在就更新旧fiber节点,否则那就重新创建
if (current !== null) {
// 判断元素类型是否相同,比如前后都是li节点,证明dom类型没变,而
if (current.elementType === element.type || (
isCompatibleFamilyForHotReloading(current, element) )) {
// 根据新的props更新旧有的fiber节点
var existing = useFiber(current, element.props);
existing.ref = coerceRef(returnFiber, current, element);
existing.return = returnFiber;
return existing;
}
} // Insert
// 当旧fiber节点不存在时,既然对比不了,那就直接重新创建了
var created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, current, element);
created.return = returnFiber;
return created;
}
在updateElement
中我们看到了针对是否能从map
中获取到旧节点的不同处理,比如key=2014
在map
很显然就找不到,这就导致了current
是null
,于是就走了下面的createFiberFromElement
方法完全重新创建。
而当key
是2015
或者2016
时,因为current
就是之前的旧fiber
节点,于是走了var existing = useFiber(current, element.props)
旧节点更新逻辑,而不是重新创建。
贰 ❀ 肆 既然null===null,为什么还需要key?
其实说到这里,我想大家对于这个问题应该也有了一定的理解。对于非list
结构而言,确实是否提供key
并无重要,反正大家都是逐层对比;而对于list
而言,当存在数组头部或中间插入元素时,假设大家提供index
作为key
或者不提供key
,都会导致新旧节点的key
全部相等。这就导致了已经错位的节点强行逐层对比,本应该新建的节点因为key
相同而走了更新,本应该更新的节点因为key
相同结果走了新建。
贰 ❀ 伍 为什么不推荐使用index做为key?
理由在第四个问题已经回答过了,而且核心问题是因为本应该新建的结果你只做了更新,这种情况甚至还能导致bug
。官方在介绍key
时也给了一个导致bug
的例子,我们结合源码来深究为什么使用index
导致了这个bug
。
例子代码如下:
class Item extends React.Component {
render() {
return (
<div>
<div>
<input type="text" />
</div>
</div>
);
}
}
class Example extends React.Component {
constructor() {
super();
this.state = {
list: [
{ name: "听风是风", id: 1 },
{ name: "行星飞行", id: 2 },
],
};
}
addItem = () => {
const id = +new Date();
this.setState({
list: [{ name: "时间跳跃" + id + id, id }, ...this.state.list],
});
};
render() {
return (
<div className="example">
<button onClick={this.addItem}>clie me</button>
<div className="form">
<form>
<h3>
不好的做法 <code>key=index</code>
</h3>
{this.state.list.map((todo, index) => (
<Item {...todo} key={index} />
))}
</form>
<form>
<h3>
更好的做法 <code>key=id</code>
</h3>
{this.state.list.map((todo) => (
<Item {...todo} key={todo.id} />
))}
</form>
</div>
</div>
);
}
}
![](https://img2022.cnblogs.com/blog/1213309/202207/1213309-20220703165923359-1188423635.gif)
简单来说,我们分别使用index
以及独一无二的id
作为key
,然后我们分别在两个form
中的第一个input
属于一个值,之后点击按钮,分别在数组前插入了一个新数据,然后区别就出现了,index
的例子并没有按照预期完整重新创建一个input
,这个1
本应该属于第二个input
。
那么为什么造成了这个bug
呢?原因其实很简单,当使用了index
作为key
时,我们前文也说了,这个input
就应该重新创建,结果你用index
,0===0
为true
,type
又相同,所以diff
直接认为这是一次更新而不是重新创建。
在虚拟dom
一文中,我们强调了虚拟dom
为局部刷新提供了可能性,因为原生dom
属性非常多,如果递归去对比就格外复杂了,但虚拟dom
设计直接将我们需要对比的属性都聚焦在了props
中,所以即便diff
去更新props
也只是更新虚拟dom
的props
,像上文中的input
本身就是一个原生dom
,它的vaule
根本就不在diff
比较的范畴内。
而前面也说了,因为index
的缘故diff
会认为你只是更新,在fiber
节点中有一个stateNode
字段保存了对应真实dom
的属性,所以diff
在clone
节点时,直接将之前的stateNode
赋值给了更新后的fiber
节点,这就导致了这个1
依旧停留在了第一个input
上。
![](https://img2022.cnblogs.com/blog/1213309/202207/1213309-20220703165946439-1370173418.png)
上图就是当第一个fiber
更新完成之后,通过stateNode
访问到input
的value
,这就是为啥导致这个bug
的原因。
我们通过两张图来描述当数组前插入元素时,使用index
或者不提供key
默认null
时,与使用独一无二key
的diff
差异:
![](https://img2022.cnblogs.com/blog/1213309/202207/1213309-20220703170005536-1399005099.png)
![](https://img2022.cnblogs.com/blog/1213309/202207/1213309-20220703170011418-702138534.png)
叁 ❀ 总
那么到这里,我们解释了文章开头的五个问题,也通过源码解开了key
的神秘面纱。简单点来说,key
并没有大家所想的那么聪明,但对于list
的diff
而言又极其重要,react
的diff
始终遵守逐层对比,也正因为key
的存在,不管list
如何改变顺序,只有key
独一无二,react
总是能正确的去更新或者新建它们,这才是key
存在的核心意义。
那么到这里,关于key
的介绍到此结束。
相关文章
- HTTP协议漫谈 C#实现图(Graph) C#实现二叉查找树 浅谈进程同步和互斥的概念 C#实现平衡多路查找树(B树)
- 大话测试数据(二):概念测试数据的获取
- 《Spark大数据分析:核心概念、技术及实践》一1.2 数据序列化
- 《Spark大数据分析:核心概念、技术及实践》一 2.2 Scala基础
- 《Python数据挖掘:概念、方法与实践》一1.2 如何进行数据挖掘
- ucos源码分析--基础概念
- 函数调用基础概念原理
- Kafka的灵魂伴侣Logi-KafkaManger(1)之集群的接入及相关概念讲解
- 线程安全 相关概念
- 《微软云计算Windows Azure开发与部署权威指南》——6.5 AppFabric服务总线基础概念
- 《JavaScript核心概念及实践》——1.2 JavaScript语言特性
- WebSocket概念
- webpack核心概念(系列二)
- 把blender 动画 的概念 总结一下, 以偏好设置为切入口。高屋建瓴
- 组合导航原理-松组合+紧组合概念
- 角色、权限、账户的概念理解
- lvm 逻辑卷管理(原理概念篇)
- 《SQL中有关DQL、DML、DDL、DCL的概念与区别》
- 内部类的概念与分类(成员内部类,局部内部类,匿名内部类)