深入探索React中'key'属性
前言
最近在学习react(18.2.0)源码,通过对react协调元素过程的理解,对'key'这一属性有了新的认识,也明白之前的关于它的一些困惑,比如
- 有没有办法不添加key,并且不被react发现吗,如果能绕过react的检查,实际效果和加
key是一样的吗? - 为啥通过
map方法生成的jsx才需要加key?一个组件下也有同级的子元素却不需要,比如下面的代码片段:
<div>
<h1>hello</h1>
<ul>
{
[1,2,3].map(item=><li key={item}>{item}</li>)
}
</ul>
<h2>hello</h2>
</div>
从代码逻辑上来看,
[1,2,3].map(item=><li key={item}>{item}</li>)
生成的结果是
[
<li key={1}>{1}</li>
<li key={2}>{2}</li>
<li key={3}>{3}</li>
]
这个格式忽略key的话看着和第一级的子元素(如下)也没啥区别
<h1>hello</h1>
<ul></ul>
<h2>hello</h2>
3. 为啥就算不加key程序跑的好像也没啥问题?
带着这些问题,我们一起去探索一下吧
第一个问题:有没有办法不添加key,并且不被react发现吗,如果能绕过react的检查,实际效果是一样的吗?
对于这个问题,使用的实例代码如下
const App = () => {
return (
<div>
<h1>hello</h1>
<ul>
{[1, 2, 3].map((item) => (
<li>{item}</li>
))}
</ul>
<h2>hello</h2>
</div>
);
};
细心的你应该发现了,我通过[1, 2, 3].map生成元素时没有为其添加key属性,没有任何意外的话,会得到一个大家应该都遇到过的警告
通过在源码中debug(react.development.js),会发现报这个错的代码来自下面这个简化后的方法
function validateChildKeys(node, parentType) {
if (Array.isArray(node)) {
// 当 node为 {[1, 2, 3].map((item) => (<li>{item}</li>))}是的逻辑
for (var i = 0; i < node.length; i++) {
var child = node[i];
if (isValidElement(child)) {
validateExplicitKey(child, parentType); // 缺失'key'的警告发生在这里
}
}
} else if (isValidElement(node)) {
// 单个元素的逻辑,比如示例代码中的'div','h1','h2',只要是合法的元素,就算没有key属性也没关系
if (node._store) {
node._store.validated = true;
}
} else if (node) {
var iteratorFn = getIteratorFn(node);
...
}
}
function validateExplicitKey(element, parentType) {
// 如果有元素有key,就会结束函数而不会警告,
if (!element._store || element._store.validated || element.key != null) {
return;
}
...
// key缺失警告
{
error('Each child in a list should have a unique "key" prop.' + '%s%s See https://fb.me/react-warning-keys for more information.', currentComponentErrorInfo, childOwner);
}
...
}
为了明白这个问题发生的原因,我们先看看示例代码被babel编译后的样子吧
const App = ()=>{
return
React.createElement("div", null,
React.createElement("h1", null, "hello"),
React.createElement("ul", null, [1, 2, 3].map(item=>
React.createElement("li", null, item))),
React.createElement("h2", null, "hello"));
};
我们可以看到创建顶层'div'是通过传入创建h1,ul,h2的列表,类似fun(arg1,arg2,arg3), 而创建'ul'时传入的是一个数组,类似fun([<li>1</li>,<li>2</li>,<li>3</li>]), 再结合react创建元素的源码
function createElementWithValidation(type, props, children) {
var validType = isValidElementType(type);
...
var element = createElement.apply(this, arguments);
if (validType) {
for (var i = 2; i < arguments.length; i++) {
validateChildKeys(arguments[i], type); // 对子元素进行key校验,源码上面已经解释过了
}
}
...
return element;
}
function createElement(type, config, children) {
...
var childrenLength = arguments.length - 2;
if (childrenLength === 1) {
props.children = children;
} else if (childrenLength > 1) {
var childArray = Array(childrenLength);
for (var i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
{
if (Object.freeze) {
Object.freeze(childArray);
}
}
props.children = childArray;
}
//赋默认值
if (type && type.defaultProps) {
var defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
...
return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props);
}
可以看出,react对于子元素长度的判断是通过arguments.length来判断,所以fun([<li>1</li>,<li>2</li>,<li>3</li>]) 这种遍历生成元素的方式被判定为一个子元素,到这里我们明白了
<h1>hello</h1>
<ul></ul>
<h2>hello</h2>
{
[1,2,3].map(item=><li key={item}>{item}</li>)
}
这两段代码因为前者创建元素传递的列表,后者传递的是数组,导致react验证key的方式不同,所以后者报错了,那此时我们就应该想到如果后者也传递是列表,是不是就绕过react的验证了,自然而然的想到{...}拓展运算符,比如
<ul>
{
...[1,2,3].map(item=><li key={item}>{item}</li>)
}
</ul>
// 遗憾的是,只有在ts中可以这样直接用上面的方式,在jsx中,我们可以这样
{
React.createElement("ul", null, ...[1, 2, 3].map((temp, item) => {
return <li>{item}</li>
}))
}
通过上面的代码我们把遍历生成元素以列表的方式传递给了React.createElement,这样就规避了react的key检测,控制台也不会有警告了,那这种方式能完美解决key的问题吗,如果不能的话是为什么不能,这就涉及协调过程,在回答第三个问题中我会给出解释.
第二个问题:为啥通过map方法生成的jsx才需要加key?一个组件下也有同级的子元素却不需要
这是因为虽然我们没有给一般的元素设置key,此时react会给元素的key设置为null,源码中的判断条件是key全等,所以只要更新前后的元素key都为null,react也会尝试去复用之前的fiber;单个元素协调简化源码如下;
// 单节点协调, 比如上一个问题中示例代码里只有'div'元素会走这个逻辑
function reconcileSingleElement(returnFiber, currentFirstChild, element, expirationTime) {
var key = element.key;
var child = currentFirstChild;
while (child !== null) {
if (child.key === key) {
switch (child.tag) {
default:
{
if (child.elementType === element.type || (
isCompatibleFamilyForHotReloading(child, element) )) {
deleteRemainingChildren(returnFiber, child.sibling);
//元素复用
var _existing3 = useFiber(child, element.props);
_existing3.ref = coerceRef(returnFiber, child, element);
_existing3.return = returnFiber;
{
_existing3._debugSource = element._source;
_existing3._debugOwner = element._owner;
}
return _existing3;
}
break;
}
} // Didn't match.
deleteRemainingChildren(returnFiber, child);
break;
} else {
deleteChild(returnFiber, child);
}
child = child.sibling;
}
...
}
那对于父节点有多个子结点的情况呢,这涉及react协调多节点的过程,也是了解react协调过程的重点,我们来一起看一下react关于这里的源码
//多节点协调
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, expirationTime) {
//这里我们只要知道fiber就是一个元素的描述信息,react里每一个组件,元素都有自己的fiber,
//这些fiber通过树的方式组合起来也就是我们常说的虚拟dom
var resultingFirstChild = null; //协调的结果,也是函数的返回结果
var previousNewFiber = null; //记录前一个新的fiber
var oldFiber = currentFirstChild; // 记录旧的fiber
var lastPlacedIndex = 0; // 上一次更新的位置索引,用来判断元素的更新方式(复用,插入,移除)
var newIdx = 0; //新的fiber的索引
var nextOldFiber = null; // 下一旧的fiber
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
var newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], expirationTime);
//元素无法复用
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
deleteChild(returnFiber, oldFiber);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
//新的元素全部可以复用旧的元素
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
if (oldFiber === null) {
//有新增元素
for (; newIdx < newChildren.length; newIdx++) {
var _newFiber = createChild(returnFiber, newChildren[newIdx], expirationTime);
if (_newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(_newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// TODO: Move out of the loop. This only happens for the first run.
resultingFirstChild = _newFiber;
} else {
previousNewFiber.sibling = _newFiber;
}
previousNewFiber = _newFiber;
}
return resultingFirstChild;
}
var existingChildren = mapRemainingChildren(returnFiber, oldFiber);
//非多节点末尾插入或移除了某个节点
for (; newIdx < newChildren.length; newIdx++) {
var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], expirationTime);
if (_newFiber2 !== null) {
if (shouldTrackSideEffects) {
if (_newFiber2.alternate !== null) {
existingChildren.delete(_newFiber2.key === null ? newIdx : _newFiber2.key);
}
}
lastPlacedIndex = placeChild(_newFiber2, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = _newFiber2;
} else {
previousNewFiber.sibling = _newFiber2;
}
previousNewFiber = _newFiber2;
}
}
if (shouldTrackSideEffects) {
existingChildren.forEach(function (child) {
return deleteChild(returnFiber, child);
});
}
return resultingFirstChild;
}
通过对源码的学习,可以看到之所以不通过map方法产生的多节点子元素可以不加key,是因为它们是静态存在(一直存在)的,每次协调过程都是刚好被复用,也就是执行了最理想的协调逻辑
//新的元素全部可以复用旧的元素
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
第三个问题:为啥就算不加key程序跑的好像也没啥问题?
举一个例子
//更新前
[
<li>{1}</li>
<li>{2}</li>
<li>{3}</li>
]
//更新后
[
<li>{2}</li>
<li>{3}</li>
]
这几个不加key的元素在执行协调过程中,每个元素都会被标记为更新,其协调结束的位置是
//新的元素全部可以复用旧的元素
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
但如果是加了key的话,react就会把第1个li元素标记为删除,从而不用更新另外两个节点本身,其协调结束的位置是
if (shouldTrackSideEffects) {
existingChildren.forEach(function (child) {
return deleteChild(returnFiber, child);
});
}
return resultingFirstChild;
所以第三个问题就有了答案,不加key确实不会影响功能,但是会导致原本的协调过程出现差异,进而影响程序性能.同理,这也是为什么不推荐用'index'作为key原因,因为没法正确的反应元素怎么变化的.
再回到第一个问题中我们通过
{
React.createElement("ul", null, ...[1, 2, 3].map((temp, item) => {
return <li>{item}</li>
}))
}
避开key检测的方式,显然,是不完美的,所以还是得老老实实的加上'key'.
联系到自己的实际开发中,我有什么启发
<div>
{
isShow && <Child>
}
<ChildOther>
</div>