深入探索React中'key'属性

514 阅读5分钟

深入探索React中'key'属性

前言

最近在学习react(18.2.0)源码,通过对react协调元素过程的理解,对'key'这一属性有了新的认识,也明白之前的关于它的一些困惑,比如

  1. 有没有办法不添加key,并且不被react发现吗,如果能绕过react的检查,实际效果和加key是一样的吗?
  2. 为啥通过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属性,没有任何意外的话,会得到一个大家应该都遇到过的警告

noKeyWarn.png

通过在源码中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>

在我目前的项目中,有很多这种通过条件判断组件是否显示的代码,但是通过前面的学习,如果isShow是动态的话,会影响react的协调过程,从而一定程度上会影响程序性能,所以对于这种代码的使用可以小心一点.