React key究竟有什么作用?深入源码不背概念,五个问题刷新你对于key的认知
壹 ❀ 引
我在【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;
}
但需要注意的是,并不是只要是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>
);
}
}
简单来说,我们分别使用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
上。
上图就是当第一个fiber
更新完成之后,通过stateNode
访问到input
的value
,这就是为啥导致这个bug
的原因。
我们通过两张图来描述当数组前插入元素时,使用index
或者不提供key
默认null
时,与使用独一无二key
的diff
差异:
叁 ❀ 总
那么到这里,我们解释了文章开头的五个问题,也通过源码解开了key
的神秘面纱。简单点来说,key
并没有大家所想的那么聪明,但对于list
的diff
而言又极其重要,react
的diff
始终遵守逐层对比,也正因为key
的存在,不管list
如何改变顺序,只有key
独一无二,react
总是能正确的去更新或者新建它们,这才是key
存在的核心意义。
那么到这里,关于key
的介绍到此结束。