一网打尽React渲染列表key值的问题(含源码解析)

1,835 阅读8分钟

React中对列表进行渲染的时候,key值的问题已经是一个老生常谈的问题了,趁着这次重新整理React的机会,把列表渲染中key值相关的原理和问题都梳理一遍.

都2202年了,不会有人还不知道吧~

本文会依次回答以下几个问题:

  1. 为什么列表渲染需要加key?
  2. React拿到key以后怎么做优化?
  3. 用数组索引作为key会有什么意外情况?
  4. 不加key会出现什么问题?
  5. 有相同的key会出现什么问题?

一、为什么列表渲染需要加key

在渲染列表的业务中,往往只会带来一部分item的更改。对应到React中,渲染列表产生的fiber节点也只需要一部分更改。而React讲求的是以最小的DOM代价去更新视图,渲染列表的场景,自然会去想方设法复用那些没有改变的item。怎么识别出这些item呢,那就是给每个item起一个唯一的名字,然后进行复用或者其他工作,这个名字就是key属性。如果没有key,渲染列表的时候,React不知道哪个改变了哪个没改变,只能默认用数组索引作为标识,来渲染列表。这就会在一些情况下导致渲染出错。因此,需要明确的指出key值来渲染列表。从逻辑上,我们回答了第一个问题,为什么列表渲染需需要key

接着我们会从源码层面上来佐证,开发环境上(开发生产逻辑上没区别),jsxDev调用ReactElement生成element的时候,将key转换为了字符串,生成的element里面就带上了key,在这里React中会有验证key是否存在的过程。

jsxDEV.png

验证属性.png

验证key.png

在生成完成当前渲染阶段的element以后,进行Reactfiber过程,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返回nullreconcileChildrenArray进入下一轮;key值相等的时候,判断elementtype和原来的fibertype是否一致,一致则复用,不一致则重新建新的fiber返回。

复用这个概念这里着重说一下:复用不是直接把整个节点一层不变抄过来,而且会传入newChildrenprops。复用的过程是:

// 注意看这里又有alternate属性了  这个属性一定要去了解一下
let workInProgress = current.alternate;

这也和官方的文档对应上: 权衡.png

三、索引值做key会有什么问题

这里我们引用一个官方提供的例子来说明,首先将item中的input(注意看此时input是非受控的)删除,只看纯展示性组件的情况:

纯展示情况.gif

完全正常,分析原因:在我们新增或者排序的场景中,当fiber走到复用逻辑的时候,因为用的索引作为key,所以key值能够对上,然后type能够对上,这个时候就会复用了,再次提醒,不是直接把整个节点抄过来,而且会传入newChildrenprops

所以文本会更新,节点也复用了(记得官方的时钟例子吗,仔细想想是不是一样的)。如果这里想明白了,那么,在完全受控的场景,也是一样的道理,也不会有任何问题:

受控场景.gif

而在非受控的情况下,就会出现问题了,当我们在某一项input中输入值以后,做增、删或者排序操作,都会触发重新渲染复用逻辑,这个时候React不知道有输入的input改变了,因为input不受控,还是会继续复用。这就会导致input和后面的时间文本对不上,出现问题。若我们将key值改为数据里面的id,那么无论是受控还是非受控的情况,都不会有问题:

id做key受控.gif

id做key不受控.gif

到这里回答了第三个问题,索引作为key的时候会出现什么情况:如果渲染列表里的item是完全受控的,那么不会有影响;如果有非受控组件,则在排序、新增或者删除的等引起复用的情况下,会出现“错位”现象,原因是React在不知道有改变的情况下复用了fiber节点,而这也是他做不到的,需要开发者协助。

四、不加key或者重复key有什么问题

第四个问题不加key会有什么问题:在React中,如果渲染列表没有key,则React会以索引作为默认key,所以这个问题和“索引做key”是一样的。

第二个问题,重复key会有什么问题,这里可以去想一下,还是从typekey都一样走复用逻辑上想,如果恰巧两个itemtypekey都相同了,那么在fiber过程中去复用了的也会出现“错位”现象。