React 源码- key 有什么作用, 可以省略吗?

964 阅读4分钟

这是我参与8月更文挑战的第19天,活动详情查看:8月更文挑战

在react组件开发的过程中, key是一个常用的属性值, 多用于列表开发. 本文从源码的角度, 分析keyreact内部是如何使用的, key是否可以省略.

ReactElement对象

我们在编程时直接书写的jsx代码, 实际上是会被编译成ReactElement对象, 所以keyReactElement对象的一个属性.

构造函数

在把jsx转换成ReactElement对象的语法时, 有一个兼容问题. 会根据编译器的不同策略, 编译成2种方案.

  1. 最新的转译策略: 会将jsx语法的代码, 转译成jsx()函数包裹

    jsx函数: 只保留与key相关的代码(其余源码本节不讨论)

       /**
       * https://github.com/reactjs/rfcs/pull/107
       * @param {*} type
       * @param {object} props
       * @param {string} key
       */
       export function jsx(type, config, maybeKey) {
         let propName;
    
         // 1. key的默认值是null
         let key = null;
    
         // Currently, key can be spread in as a prop. This causes a potential
         // issue if key is also explicitly declared (ie. <div {...props} key="Hi" />
         // or <div key="Hi" {...props} /> ). We want to deprecate key spread,
         // but as an intermediary step, we will use jsxDEV for everything except
         // <div {...props} key="Hi" />, because we aren't currently able to tell if
         // key is explicitly declared to be undefined or not.
         if (maybeKey !== undefined) {
           key = '' + maybeKey;
         }
    
         if (hasValidKey(config)) {
           // 2. 将key转换成字符串
           key = '' + config.key; 
         }
         // 3. 将key传入构造函数
         return ReactElement(
           type,
           key,
           ref,
           undefined,
           undefined,
           ReactCurrentOwner.current,
           props,
         );
       }
    
  2. 传统的转译策略: 会将jsx语法的代码, 转译成React.createElement()函数包裹

    React.createElement()函数: 只保留与key相关的代码(其余源码本节不讨论)

    /**
      * Create and return a new ReactElement of the given type.
      * See https://reactjs.org/docs/react-api.html#createelement
      */
    export function createElement(type, config, children) {
      let propName;
    
      // Reserved names are extracted
      const props = {};
    
      let key = null;
      let ref = null;
      let self = null;
      let source = null;
    
      if (config != null) {
        if (hasValidKey(config)) {
          key = '' + config.key; // key转换成字符串
        }
      }
    
      return ReactElement(
        type,
        key,
        ref,
        self,
        source,
        ReactCurrentOwner.current,
        props,
      );
    }
    

可以看到无论采取哪种编译方式, 核心逻辑都是一致的:

  1. key的默认值是null
  2. 如果外界有显示指定的key, 则将key转换成字符串类型.
  3. 调用ReactElement这个构造函数, 并且将key传入.
// ReactElement的构造函数: 本节就先只关注其中的key属性
const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    $$typeof: REACT_ELEMENT_TYPE,
    type: type,
    key: key,
    ref: ref,
    props: props,
    _owner: owner,
  };
  return element;
};

源码看到这里, 虽然还只是个皮毛, 但是起码知道了key的默认值是null. 所以任何一个reactElement对象, 内部都是有key值的, 只是一般情况下(非list结构)没人显示去传入一个key.

Fiber对象

react的核心运行逻辑, 是一个从输入到输出的过程(回顾reconciler 运作流程). 编程直接操作的jsxreactElement对象,我们(程序员)的数据模型是jsx, 而react内核的数据模型是fiber树形结构. 所以要深入认识key还需要从fiber的视角继续来看.

fiber对象是在fiber树构造循环过程中构造的, 其构造函数如下:

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {

  this.tag = tag;
  this.key = key;  // 重点: key也是`fiber`对象的一个属性

  // ... 
  this.elementType = null;
  this.type = null;
  this.stateNode = null;
  // ... 省略无关代码
}

可以看到, key也是fiber对象的一个属性. 这里和reactElement的情况有所不同:

  1. reactElement中的key是由jsx编译而来, key是由程序员直接控制的(及时是动态生成, 那也是直接控制)
  2. fiber对象是由react内核在运行时创建的, 所以fiber.key也是react内核进行设置的, 程序员没有直接控制.

逻辑来到这里, 有2个疑问:

  1. fiber.key是由react内核设置, 那他的值是否和reactElement.key相同?
  2. 如果reactElement.key = null, 那么fiber.key就一定是null吗?

要继续跟进这些问题, 还得从fiber的创建说起. 上文提到了, fiber对象的创建发生在fiber树构造循环阶段中, 具体来讲, 是在reconcilerChildren调和函数中进行创建.

reconcilerChildren调和函数

reconcilerChildrenreact中的一个明星函数, 最热点的问题就是diff算法原理, 事实上, key的作用完全就是为了diff算法服务的.

注意: 本节只分析key相关的逻辑, 对于调和函数的算法原理, 请回顾算法章节React 算法之调和算法

调和函数源码(本节示例, 只摘取了部分代码):

function ChildReconciler(shouldTrackSideEffects) {

  function reconcileChildFibers(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChild: any,
    lanes: Lanes,
  ): Fiber | null {
    // Handle object types
    const isObject = typeof newChild === 'object' && newChild !== null;

    if (isObject) {
      switch (newChild.$$typeof) {
        case REACT_ELEMENT_TYPE:
          // newChild是单节点
          return placeSingleChild(
            reconcileSingleElement(
              returnFiber,
              currentFirstChild,
              newChild,
              lanes,
            ),
          );
      }
    }
    //  newChild是多节点
    if (isArray(newChild)) {
      return reconcileChildrenArray(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes,
      );
    }
    // ...
  }

  return reconcileChildFibers;
}

单节点

这里先看单节点的情况reconcileSingleElement(只保留与key有关的逻辑):

function reconcileSingleElement(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    element: ReactElement,
    lanes: Lanes,
  ): Fiber {
    const key = element.key;
    let child = currentFirstChild;
    while (child !== null) {
      //重点1: key是单节点是否复用的第一判断条件
      if (child.key === key) { 
        switch (child.tag) {
          default: {
            if (child.elementType === element.type) { // 第二判断条件
              deleteRemainingChildren(returnFiber, child.sibling);
              // 节点复用: 调用useFiber
              const existing = useFiber(child, element.props);
              existing.ref = coerceRef(returnFiber, child, element);
              existing.return = returnFiber;
              return existing;
            }
            break;
          }
        }
        // Didn't match.
        deleteRemainingChildren(returnFiber, child);
        break;
      } 
      child = child.sibling;
    }
    // 重点2: fiber节点创建, `key`是随着`element`对象被传入`fiber`的构造函数
    const created = createFiberFromElement(element, returnFiber.mode, lanes);
    created.ref = coerceRef(returnFiber, currentFirstChild, element);
    created.return = returnFiber;
    return created;
  }

可以看到, 对于单节点来讲, 有2个重点:

  1. key是单节点是否复用的第一判断条件(第二判断条件是type是否改变).
    • 如果key不同, 其他条件是完全不看的
  2. 在新建节点时, key随着element对象被传入fiber的构造函数.

所以到这里才是key的最核心作用, 是调和函数中, 针对单节点是否可以复用的第一判断条件.

另外我们可以得到, fiber.keyreactElement.key的拷贝, 他们是完全相等的(包括null默认值).

多节点

继续查看多节点相关的逻辑:

 function reconcileChildrenArray(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChildren: Array<*>,
    lanes: Lanes,
  ): Fiber | null {

    if (__DEV__) {
      // First, validate keys.
      let knownKeys = null;
      for (let i = 0; i < newChildren.length; i++) {
        const child = newChildren[i];
        // 1. 在dev环境下, 执行warnOnInvalidKey. 
        //  - 如果没有设置key, 会警告提示, 希望能显示设置key
        //  - 如果key重复, 会错误提示.
        knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);
      }
    }

    let resultingFirstChild: Fiber | null = null;
    let previousNewFiber: Fiber | null = null;

    let oldFiber = currentFirstChild;
    let lastPlacedIndex = 0;
    let newIdx = 0;
    let nextOldFiber = null;
    // 第一次循环: 只会在更新阶段发生
    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
        nextOldFiber = oldFiber.sibling;
      }
      // 1. 调用updateSlot, 处理公共序列中的fiber
      const newFiber = updateSlot(
        returnFiber,
        oldFiber,
        newChildren[newIdx],
        lanes,
      );
    }

    // 第二次循环
    if (oldFiber === null) {
      for (; newIdx < newChildren.length; newIdx++) {
        // 2. 调用createChild直接创建新fiber
        const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
      }
      return resultingFirstChild;
    }

    for (; newIdx < newChildren.length; newIdx++) {
      // 3. 调用updateFromMap处理非公共序列中的fiber
      const newFiber = updateFromMap(
        existingChildren,
        returnFiber,
        newIdx,
        newChildren[newIdx],
        lanes,
      );
    }

    return resultingFirstChild;
  }

reconcileChildrenArray中, 有3处调用与fiber有关(当然顺便就和key有关了), 它们分布是:

  1. updateSlot

        function updateSlot(
          returnFiber: Fiber,
          oldFiber: Fiber | null,
          newChild: any,
          lanes: Lanes,
        ): Fiber | null {
          const key = oldFiber !== null ? oldFiber.key : null;
    
          if (typeof newChild === 'object' && newChild !== null) {
            switch (newChild.$$typeof) {
              case REACT_ELEMENT_TYPE: {
                //重点: key用于是否复用的第一判断条件
                if (newChild.key === key) {
                  return updateElement(returnFiber, oldFiber, newChild, lanes);
                } else {
                  return null;
                }
              }
            }
          }
    
          return null;
        }
    
  2. createChild

      function createChild(
            returnFiber: Fiber,
            newChild: any,
            lanes: Lanes,
          ): Fiber | null {
            if (typeof newChild === 'object' && newChild !== null) {
              switch (newChild.$$typeof) {
                case REACT_ELEMENT_TYPE: {
                  // 重点: 调用构造函数进行创建
                  const created = createFiberFromElement(
                    newChild,
                    returnFiber.mode,
                    lanes,
                  );
                  return created;
                }
              }
            }
    
            return null;
          }
    
  3. updateFromMap

        function updateFromMap(
          existingChildren: Map<string | number, Fiber>,
          returnFiber: Fiber,
          newIdx: number,
          newChild: any,
          lanes: Lanes,
        ): Fiber | null {
    
          if (typeof newChild === 'object' && newChild !== null) {
            switch (newChild.$$typeof) {
              case REACT_ELEMENT_TYPE: {
                //重点: key用于是否复用的第一判断条件
                const matchedFiber =
                  existingChildren.get(
                    newChild.key === null ? newIdx : newChild.key,
                  ) || null;
                return updateElement(returnFiber, matchedFiber, newChild, lanes);
              }
          }
          return null;
        }
    

其中, 与key相关的重点都在注释中说明了, 需要注意的是updateFromMap这是第二次循环中对于非公共序列的解析, 如果reactElement没有显示设置key, 也就是其中newChild.key === null, 这时候, 会用index进行查找.

所以在多节点情况下, key任然是用于是否复用的第一判断条件, 如果key不同是肯定不会复用的.

总结

本节从源码的角度, 分别从reactElement对象fiber对象2个视角进行展开, 分析key在react内核中的使用情况. 最终在调和函数reconcilerChildren中, key得到了最终的应用, 作为节点复用的第一判断条件.