React中对列表进行渲染的时候,key值的问题已经是一个老生常谈的问题了,趁着这次重新整理React的机会,把列表渲染中key值相关的原理和问题都梳理一遍.
都2202年了,不会有人还不知道吧~
本文会依次回答以下几个问题:
- 为什么列表渲染需要加
key? React拿到key以后怎么做优化?- 用数组索引作为key会有什么意外情况?
- 不加
key会出现什么问题? - 有相同的
key会出现什么问题?
一、为什么列表渲染需要加key
在渲染列表的业务中,往往只会带来一部分item的更改。对应到React中,渲染列表产生的fiber节点也只需要一部分更改。而React讲求的是以最小的DOM代价去更新视图,渲染列表的场景,自然会去想方设法复用那些没有改变的item。怎么识别出这些item呢,那就是给每个item起一个唯一的名字,然后进行复用或者其他工作,这个名字就是key属性。如果没有key,渲染列表的时候,React不知道哪个改变了哪个没改变,只能默认用数组索引作为标识,来渲染列表。这就会在一些情况下导致渲染出错。因此,需要明确的指出key值来渲染列表。从逻辑上,我们回答了第一个问题,为什么列表渲染需需要key。
接着我们会从源码层面上来佐证,开发环境上(开发生产逻辑上没区别),jsxDev调用ReactElement生成element的时候,将key转换为了字符串,生成的element里面就带上了key,在这里React中会有验证key是否存在的过程。
在生成完成当前渲染阶段的element以后,进行React的fiber过程,key是主导是否复用fiber节点的因素之一,在下一节“React用了key来做了哪些优化”进行解析。我们能回答第一个问题了,为什么列表渲染需要key:因为React复用fiber节点的时候需要用标识找到原来的fiber节点。
二、怎么用key做优化
生成element以后,需要经过React fiber过程,在这个过程中就会使用到key值。查看源码,主要是在diff阶段使用key值来做复用的逻辑,因为本文写的是列表处的key值,对于单节点的key就先不管。先贴一下源码中的fiber处理列表的逻辑:
// packages/react-reconciler/src/ReactChildFiber.new.js
// fiber处理list的逻辑
function reconcileChildrenArray(
returnFiber: Fiber, // 原来的挂载节点 一般指的是list的父节点
currentFirstChild: Fiber | null, // 当前list fiber节点的第一个
newChildren: Array<*>, // react-jsx更新后的list element
lanes: Lanes, // 优先级 暂时不管
): Fiber | null {
debugger
// This algorithm can't optimize by searching from both ends since we
// don't have backpointers on fibers. I'm trying to see how far we can get
// with that model. If it ends up not being worth the tradeoffs, we can
// add it later.
// Even with a two ended optimization, we'd want to optimize for the case
// where there are few changes and brute force the comparison instead of
// going for the Map. It'd like to explore hitting that path first in
// forward-only mode and only go for the Map once we notice that we need
// lots of look ahead. This doesn't handle reversal as well as two ended
// search but that's unusual. Besides, for the two ended optimization to
// work on Iterables, we'd need to copy the whole set.
// In this first iteration, we'll just live with hitting the bad case
// (adding everything to a Map) in for every insert/move.
// If you change this code, also update reconcileChildrenIterator() which
// uses the same algorithm.
// 开发环境验证了list中每项的key 放入了集合knownKeys
if (__DEV__) {
let knownKeys = null;
for (let i = 0; i < newChildren.length; i++) {
const child = newChildren[i];
knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);
}
}
// 该函数需要返回的fiber节点,更新后,列表中第一个item所得到的fiber节点
let resultingFirstChild: Fiber | null = null;
// 更新后的前面一个的fiber节点
let previousNewFiber: Fiber | null = null;
// 更新之前的oldFiber就是传入的当前第一个fiber节点
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0;
let newIdx = 0;
let nextOldFiber = null;
/**
* 1. 优先处理更新的情况
* 当更新的时候 currentFirstChild 不会为空 旧的fiber节点不为空的时候 遍历新的list elements
* 第一次挂载的时候 currentFirstChild 为空
*/
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// 根据oldFiber的情况找到下一次需要更新的fiber
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
// 生成新的节点
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
// 生成的新节点为空,则进入下一轮
if (newFiber === null) {
// TODO: This breaks on empty slots like null children. That's
// unfortunate because it triggers the slow path all the time. We need
// a better way to communicate whether this was a miss or null,
// boolean, undefined, etc.
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
// 如果更新有副作用产生
if (shouldTrackSideEffects) {
// 如果新的节点和老节点对应补上,则失效,删除父节点中oldFiber后面的节点 (这里可以看下alternate属性)
if (oldFiber && newFiber.alternate === null) {
// We matched the slot, but we didn't reuse the existing fiber, so we
// need to delete the existing child.
deleteChild(returnFiber, oldFiber);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// TODO: Move out of the loop. This only happens for the first run.
resultingFirstChild = newFiber;
} else {
// TODO: Defer siblings if we're not at the right index for this slot.
// I.e. if we had null values before, then we want to defer this
// for each null value. However, we also don't want to call updateSlot
// with the previous one.
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
// newChildren遍历完成
if (newIdx === newChildren.length) {
// We've reached the end of the new children. We can delete the rest.
deleteRemainingChildren(returnFiber, oldFiber);
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
}
/**
* 2. 当oldFiber为空的时候 比如挂载的时候或者OldFiber完成了但是newChildren没完成,这个时候直接插入
*/
if (oldFiber === null) {
// If we don't have any more existing children we can choose a fast path
// since the rest will all be insertions.
for (; newIdx < newChildren.length; newIdx++) {
// 直接生成新的fiber节点
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 生成的新节点作为上一个新节点的sibling 兄弟节点
// 同时将这个新节点更新为“上一个新的节点”
// “链表”操作
if (previousNewFiber === null) {
// TODO: Move out of the loop. This only happens for the first run.
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
// 挂载:计算出所有子的fiber以后 返回第一个
return resultingFirstChild;
}
// Add all children to a key map for quick lookups.
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 3. oldFiber和newChildren都有剩余的情况:比如列表从 [1,2,3,4]更新到[2,3,4,5] 只有2,3,4能复用 oldFiber的1和newChildren的5还没处理
// Keep scanning and use the map to restore deleted items as moves.
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
// The new fiber is a work in progress, but if there exists a
// current, that means that we reused the fiber. We need to delete
// it from the child list so that we don't add it to the deletion
// list.
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
if (shouldTrackSideEffects) {
// Any existing children that weren't consumed above were deleted. We need
// to add them to the deletion list.
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
}
从源码得知需要考虑更新、挂载、添加删除等操作,生成新的newFiber。需要关注更新fiber节点的时候对应的updateSlot、newChildren没处理完时的createChild、放入map以后调用的updateFromMap
。因为关于key这一块的逻辑其实是大同小异的,所以我们只看updateSlot:
// packages/react-reconciler/src/ReactChildFiber.new.js
// updateSlot部分逻辑
function updateSlot(
returnFiber: Fiber,
oldFiber: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
// Update the fiber if the keys match, otherwise return null.
// 拿到原来的key
const key = oldFiber !== null ? oldFiber.key : null;
// 如果是text node 文本节点直接更新(不需要也不能有key)
if (
(typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number'
) {
// Text nodes don't have keys. If the previous node is implicitly keyed
// we can continue to replace it without aborting even if it is not a text
// node.
if (key !== null) {
return null;
}
// updateTextNode逻辑 如果oldFiber为空或者不是text node 创建个新的fiber节点返回
// 如果是的 则useFiber 复用oldFiber
return updateTextNode(returnFiber, oldFiber, '' + newChild, lanes);
}
// 如果不是文本节点
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
// key相同的时候才有可能复用
if (newChild.key === key) {
// 更新fiber
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
// key不同的时候直接返回null react认为该节点已经变化了
return null;
}
}
...
}
// packages/react-reconciler/src/ReactChildFiber.new.js
// 更新element的函数
function updateElement(
returnFiber: Fiber,
current: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
return updateFragment(
returnFiber,
current,
element.props.children,
lanes,
element.key,
);
}
if (current !== null) {
if (
// 进入updateElement 已经判断了key
// fiber复用逻辑 type相同 或者开发环境中打开了热重载
current.elementType === elementType ||
// Keep this check inline so it only runs on the false path:
(__DEV__
? isCompatibleFamilyForHotReloading(current, element)
: false) ||
// Lazy types should reconcile their resolved type.
// We need to do this after the Hot Reloading check above,
// because hot reloading has different semantics than prod because
// it doesn't resuspend. So we can't let the call below suspend.
(enableLazyElements &&
typeof elementType === 'object' &&
elementType !== null &&
elementType.$$typeof === REACT_LAZY_TYPE &&
resolveLazy(elementType) === current.type)
) {
// 复用fiber
// Move based on index
const existing = useFiber(current, element.props);
existing.ref = coerceRef(returnFiber, current, element);
existing.return = returnFiber;
if (__DEV__) {
existing._debugSource = element._source;
existing._debugOwner = element._owner;
}
return existing;
}
}
// type不一致则重新创建fiber用来返回
// Insert
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, current, element);
created.return = returnFiber;
return created;
}
所以我们可以回答第二个问题了,针对渲染列表的场景,React怎么去做优化:在从旧节点oldFiber到新element生成新节点newFiber的过程(这就是React fiber的过程)中,首先判断是否为TextNode,如果是文本节点,则直接复用。如果不是TextNode节点,则判断key值是否相等,key值不等的时候,React认为元素已经发生变化,updateSlot返回null,reconcileChildrenArray进入下一轮;key值相等的时候,判断element的type和原来的fiber的type是否一致,一致则复用,不一致则重新建新的fiber返回。
复用这个概念这里着重说一下:复用不是直接把整个节点一层不变抄过来,而且会传入newChildren的props。复用的过程是:
// 注意看这里又有alternate属性了 这个属性一定要去了解一下
let workInProgress = current.alternate;
这也和官方的文档对应上:
三、索引值做key会有什么问题
这里我们引用一个官方提供的例子来说明,首先将item中的input(注意看此时input是非受控的)删除,只看纯展示性组件的情况:
完全正常,分析原因:在我们新增或者排序的场景中,当fiber走到复用逻辑的时候,因为用的索引作为key,所以key值能够对上,然后type能够对上,这个时候就会复用了,再次提醒,不是直接把整个节点抄过来,而且会传入newChildren的props。
所以文本会更新,节点也复用了(记得官方的时钟例子吗,仔细想想是不是一样的)。如果这里想明白了,那么,在完全受控的场景,也是一样的道理,也不会有任何问题:
而在非受控的情况下,就会出现问题了,当我们在某一项input中输入值以后,做增、删或者排序操作,都会触发重新渲染复用逻辑,这个时候React不知道有输入的input改变了,因为input不受控,还是会继续复用。这就会导致input和后面的时间文本对不上,出现问题。若我们将key值改为数据里面的id,那么无论是受控还是非受控的情况,都不会有问题:
到这里回答了第三个问题,索引作为key的时候会出现什么情况:如果渲染列表里的item是完全受控的,那么不会有影响;如果有非受控组件,则在排序、新增或者删除的等引起复用的情况下,会出现“错位”现象,原因是React在不知道有改变的情况下复用了fiber节点,而这也是他做不到的,需要开发者协助。
四、不加key或者重复key有什么问题
第四个问题不加key会有什么问题:在React中,如果渲染列表没有key,则React会以索引作为默认key,所以这个问题和“索引做key”是一样的。
第二个问题,重复key会有什么问题,这里可以去想一下,还是从type和key都一样走复用逻辑上想,如果恰巧两个item的type和key都相同了,那么在fiber过程中去复用了的也会出现“错位”现象。