likes
comments
collection
share

React 源码解读之 key 的作用是什么,能省略吗?

作者站长头像
站长
· 阅读数 33

「这是我参与2022首次更文挑战的第5天,活动详情查看:2022首次更文挑战」。

react 版本:v17.0.3

在React组件开发过程中,如果没有给列表添加key属性值,那么在控制台中就会报出如下的错误提示:

React 源码解读之 key 的作用是什么,能省略吗?

这个key是什么呢?为什么在列表中不添加key就会报错呢?那么其它元素不加key是否也会报错呢?带着这些问题,我们来探究一下这个key到底是什么。

key 是什么

我们先从两个方面来看看key是什么。

key 是ReactElement对象的属性

在 React 中使用的JSX语法仅仅只是 React.createElement(component, props, ...children) 函数的语法糖。JSX代码会被jsx函数转译成ReactElement对象。下面,我们来看看ReactElement对象的构造函数:

// packages/react/src/ReactElement.js

const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    // This tag allows us to uniquely identify this as a React Element
    $$typeof: REACT_ELEMENT_TYPE,

    // Built-in properties that belong on the element
    type: type,
    key: key,
    ref: ref,
    props: props,

    // Record the component responsible for creating this element.
    _owner: owner,
  };

  // ...

  return element;
};

可以看到,key 是 ReactElement 对象的一个属性,默认值在 jsx() 函数中定义为null,在调用 ReactElement 构造函数时传入:

// packages/react/src/ReactElement.js

export function jsx(type, config, maybeKey) {
  let propName;

  // Reserved names are extracted
  const props = {};

  let key = null;
  let ref = null;

 
  if (maybeKey !== undefined) {
    // ...
    
    // 将外界传入的 key 转换成字符串
    key = '' + maybeKey;
  }

  if (hasValidKey(config)) {
    // ...
    key = '' + config.key;
  }

  // ...

  return ReactElement(
    type,
    key,
    ref,
    undefined,
    undefined,
    ReactCurrentOwner.current,
    props,
  );
}

由 jsx() 函数我们可以知道,如果外界没有显式指定key,那么 key 的默认值为 null,如果外界显式指定了 key,则将这个 key 转换成字符串,然后在调用 ReactElement 构造函数时传给 ReactElement 对象。

在 jsx() 函数中,通过调用 hasValidKey 对 key 进行了校验:

// packages/react/src/ReactElement.js

function hasValidKey(config) {
  if (__DEV__) {
    if (hasOwnProperty.call(config, 'key')) {
      const getter = Object.getOwnPropertyDescriptor(config, 'key').get;
      if (getter && getter.isReactWarning) {
        return false;
      }
    }
  }
  return config.key !== undefined;
}

由代码可以看到,对于单一的ReactElement元素,即使没有指定key,在控制台中也不会报错。

key 是fiber对象的属性

Fiber 是 React 16 中新的协调引擎,它的主要目的是使 VIrtual DOM 可以进行增量渲染。fiber 对象是在 reconciler 过程中通过 FiberNode 构造函数构造的,其构造函数如下:

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Instance
  this.tag = tag;
  this.key = key;  // key 是fiber对象的一个属性
  this.elementType = null;
  this.type = null;
  this.stateNode = null;

  // Fiber
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  // ...
}

可以看到,key 也是 fiber 对象的一个属性,key的值在构造函数被调用时由外部传入,值可能是 null,也可能是实际的值。

key 的作用是什么

在 React 的 diff 算法中,key 的作用是用于判断节点是否可复用,从而减少不必要的diff,提高 diff 的效率。

在《React源码解读之Diff算法》一文中,我们知道最终执行diff算法是在 reconcileChildFibers 函数中,在该函数中,分别对单节点和多节点进行了新旧节点的diff比较。通过 key 判断节点是否可复用就发生在单节点和多节点的diff比较中。

单节点中的key

单节点的diff比较是通过 reconcileSingleElement 函数完成的,该函数源码如下:

// react-reconciler/src/ReactChildFiber.new.js

function reconcileSingleElement(
    returnFiber: Fiber,  // 父Fiber
    currentFirstChild: Fiber | null, // 父Fiber 下第一个开始对比的旧的子fiber
    element: ReactElement,  // 当前的 ReactElement 内容
    lanes: Lanes, // 更新的优先级
): Fiber {
    const key = element.key;
    let child = currentFirstChild;
    // 处理 旧的fiber由多个节点变成新的fiber 一个节点的情况
    // 循环遍历父fiber下旧的子fiber节点,直至遍历完或者找到 key 和 type都和新节点相同的情况
    while (child !== null) {
        // TODO: If key === null and child.key === null, then this only applies to
        // the first item in the list.

        // 旧fiber节点的 key 和 新fiber节点的key 相同
        if (child.key === key) {
            const elementType = element.type;
            // 新fiber节点的 type 类型为 REACT_FRAGMENT_TYPE 类型
            if (elementType === REACT_FRAGMENT_TYPE) {
                if (child.tag === Fragment) {
                    // 如果新的 ReactElement 和 旧fiber 都是 fragment 类型且 key 相同
                    // 对旧fiber后面的所有兄弟节点添加 Deletion 副作用标记,在 DOM 更新时删除
                    deleteRemainingChildren(returnFiber, child.sibling);

                    // 通过 useFiber,基于旧的fiber 和 新的props.children,克隆生成一个新的fiber
                    // 新的fiber的index为0,sibling为 null
                    // 这就是所谓的 fiber复用
                    const existing = useFiber(child, element.props.children);
                    existing.return = returnFiber;
                    // ...
                    return existing;
                }
            } else {
                // 此else 分支里  旧fiber节点的 key 和 新fiber节点的key 相同
                if (
                    // child.elementType是旧fiber节点的 type 类型
                    // elementType 是新fiber节点的 type 类型
                    // 如果新的 ReactElement 和 旧fiber 的 key 和 type 都相等
                    child.elementType === elementType ||
                    // Keep this check inline so it only runs on the false path:
                    (__DEV__
                        ? isCompatibleFamilyForHotReloading(child, element)
                        : false) ||
                   
                    (enableLazyElements &&
                        typeof elementType === 'object' &&
                        elementType !== null &&
                        elementType.$$typeof === REACT_LAZY_TYPE &&
                        resolveLazy(elementType) === child.type)
                ) {
                    // 对旧fiber后面的所有兄弟节点添加 Deletion 副作用标记,在 DOM 更新时删除
                    deleteRemainingChildren(returnFiber, child.sibling);
                    // 通过 useFiber 复用旧节点,并返回新节点
                    const existing = useFiber(child, element.props);
                    existing.ref = coerceRef(returnFiber, child, element);
                    existing.return = returnFiber;
                    // ...
                    return existing;
                }
            }
            // Didn't match.
            // 如果新旧fiber节点的 key 相同但是 type 不同,说明不匹配, 删除旧fiber节点及其后面的兄弟fiber节点
            deleteRemainingChildren(returnFiber, child);
            break;
        } else {
            // 如果新旧fiber节点的 key 不相同,则删除当前fiber节点及其后面的兄弟节点
            deleteChild(returnFiber, child);
        }
        // 继续遍历其兄弟节点
        child = child.sibling;
    }

    // 旧fiber节点都遍历完之后说明没有匹配到 key 和 type 都相同的 fiber
    if (element.type === REACT_FRAGMENT_TYPE) {
        // 如果新节点是 fragment 类型,调用 createFiberFromFragment 创建新的 fragment 类型的新fiber节点
        const created = createFiberFromFragment(
            element.props.children,
            returnFiber.mode,
            lanes,
            element.key,
        );
        // 新子fiber节点的父节点指向 returnFiber
        created.return = returnFiber;
        return created;
    } else {
        // 不是 fragment 类型的节点,创建新的子fiber 节点
        const created = createFiberFromElement(element, returnFiber.mode, lanes);
        created.ref = coerceRef(returnFiber, currentFirstChild, element);
        created.return = returnFiber;
        return created;
    }
}

可以看到,在reconcileSingleElement函数中,首先判断新旧fiber节点的 key 是否相同。如果key相同,则通过 useFiber 函数,基于旧fiber节点和新内容element的props,复用旧节点,否则直接删除旧节点,创建新的节点。

对于复用旧节点,key 的值也是复用旧节点的key值,而对于创建新节点,key的值则是新内容element的key,它随着element对象被传入 fiber 的构造函数中。

多节点中的key

多节点的diff比较是通过 reconcileChildrenArray 函数完成的,下面贴出删减后只与 key 有关的代码:

// react-reconciler/src/ReactChildFiber.new.js

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

    // 在开发环境下,会校验 key 是否存在且合法,否则会报 warning
    if (__DEV__) {
        // First, validate keys.
        let knownKeys = null;
        for (let i = 0; i < newChildren.length; i++) {
            const child = newChildren[i];
            knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);
        }
    }

    // 最终要返回的第一个子fiber
    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++) {
        // 根据 oldFiber的index 和 newChildren的下标,找到要对比更新的 oldFiber
        if (oldFiber.index > newIdx) {
            nextOldFiber = oldFiber;
            oldFiber = null;
        } else {
            nextOldFiber = oldFiber.sibling;
        }

        // 调用 updateSlot 来 diff oldFiber 和 新的child,生成新的fiber
        // updateSlot 与 REACT_ELEMENT_TYPE 类型和纯文本类型的 diff 类似,如果 oldFiber 可复用
        // 则根据 oldFiber 和 child 的props 生成新的fiber,否则返回 null
        const newFiber = updateSlot(
            returnFiber,
            oldFiber,
            newChildren[newIdx],
            lanes,
        );

        // newFiber 为 null,说明 oldFiber 不可复用,退出第一轮的循环
        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) {
            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.
                // 匹配到了,但不可复用,删除旧的 fiber节点
                deleteChild(returnFiber, oldFiber);
            }
        }

        //...
    }

    if (newIdx === newChildren.length) {
        // We've reached the end of the new children. We can delete the rest.
        // newChildren 遍历完了,那么剩下的 oldFiber 都是可以删除的,将其删除
        deleteRemainingChildren(returnFiber, oldFiber);
        return resultingFirstChild;
    }

    // oldFiber 遍历完了,newChildren 还没遍历完,继续遍历 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.

        // oldFiber 都遍历完了,那么 newChildren 剩下的节点都是需要新增的节点
        // 遍历剩下的newChildren,通过 createChild 创建新的 fiber 节点
        for (; newIdx < newChildren.length; newIdx++) {
            const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
            if (newFiber === null) {
                continue;
            }
            
            // ...
        }
        return resultingFirstChild;
    }

    // Add all children to a key map for quick lookups.

    // oldFiber 和 newChildren 都未遍历完
    // 调用 mapRemainingChildren 生成一个以 oldFiber的key为 key,oldFiber 为 value 的 map
    // 目的是为了第二次循环可以从oldFiber中顺利的找到可复用节点
    const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

    // Keep scanning and use the map to restore deleted items as moves.

    // oldFiber 和 newChildren 都未遍历完,则对剩下的 newChildren 进行遍历
    for (; newIdx < newChildren.length; newIdx++) {
        // 找到 mapRemainingChildren 中 key 相等的fiber,创建新fiber 复用
        const newFiber = updateFromMap(
            existingChildren,
            returnFiber,
            newIdx,
            newChildren[newIdx],
            lanes,
        );
        
        // ...
    }

    if (shouldTrackSideEffects) {
        // Any existing children that weren't consumed above were deleted. We need
        // to add them to the deletion list.
        // existingChildren中都是旧的fiber,给剩余的旧fiber打上 Deletion 副作用标签
        existingChildren.forEach(child => deleteChild(returnFiber, child));
    }

    return resultingFirstChild;
}

在 reconcileChildrenArray 函数中,如果是在开发环境,会检验 key 是否存在且合法,否则就会报本文开头给出的错误提示。

在多节点diff比较中,同样是通过 key 来判断是否可复用节点。如果key相同,则通过 useFiber 函数,基于旧fiber节点和新内容element的props,复用旧节点,否则直接删除旧节点,创建新的节点。

在 reconcileChildrenArray 函数中,有3处函数调用与 key 有关,它们分别是:updateSlot()、createChild()、updateFromMap() 。

updateSlot

// react-reconciler/src/ReactChildFiber.new.js

function updateSlot(
    returnFiber: Fiber,
    oldFiber: Fiber | null,
    newChild: any,
    lanes: Lanes,
): Fiber | null {
    // Update the fiber if the keys match, otherwise return null.

    const key = oldFiber !== null ? oldFiber.key : null;

    // 新的fiber节点是纯文本类型
    if (typeof newChild === 'string' || 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;
        }
        // 如果 oldFiber为null或者 oldFiber不是文本节点,则新建Fiber节点
        // 否则克隆oldFiber,生成新的fiber,从而复用oldFiber
        return updateTextNode(returnFiber, oldFiber, '' + newChild, lanes);
    }

    if (typeof newChild === 'object' && newChild !== null) {
        switch (newChild.$$typeof) {
            // 普通 react 元素
            case REACT_ELEMENT_TYPE: {
                if (newChild.key === key) {
                    // key 相同并且elementType 也相同,则复用旧fiber节点
                    return updateElement(returnFiber, oldFiber, newChild, lanes);
                } else {
                    return null;
                }
            }
           
            // ...
        }

       // ...
    }

   // ...

    return null;
}

createChild

// react-reconciler/src/ReactChildFiber.new.js

function createChild(
    returnFiber: Fiber,
    newChild: any,
    lanes: Lanes,
): Fiber | null {
    
    // ...

    if (typeof newChild === 'object' && newChild !== null) {
        switch (newChild.$$typeof) {
            case REACT_ELEMENT_TYPE: {
                // 调用 createFiberFromElement 创建新的fiber节点,新fiber节点的key为 newChild(新element内容) 的key
                const created = createFiberFromElement(
                    newChild,
                    returnFiber.mode,
                    lanes,
                );
                created.ref = coerceRef(returnFiber, null, newChild);
                created.return = returnFiber;
                return created;
            }

            // ...
        }

        // ...
        
    }

    // ...

    return null;
}

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: {
                const matchedFiber =
                    existingChildren.get(
                        newChild.key === null ? newIdx : newChild.key,
                    ) || null;
                // key 相同并且 elementType 也相同,则复用旧fiber节点
                return updateElement(returnFiber, matchedFiber, newChild, lanes);
            }
            
            // ...
        }

        // ...
    }

    // ...

    return null;
}

在 updateSlot、updateFromMap 中,如果 key 相同并且 elementType 也相同,则复用旧节点,否则直接创建新的节点。在 createChild 中则是直接创建新的节点。

如果是复用旧节点,key 的值是复用旧节点的key值,如果是创建新节点,key的值则是新内容element的key,它随着element对象被传入 fiber 的构造函数中。

总结

key 主要用于diff算法中,它是fiber对象的唯一标识,其作用是用于判断节点是否可复用,从而减少不必要的 diff,提高diff 的效率。因此,在开发中,我们应该主动设置key,尤其是在列表中。

在 key 的使用上我们也需要注意:

  • key 应该是唯一的,比如使用 id、手机号、身份证号、学号等唯一值作为key

  • key 不要使用随机值 (随机数在下一次 render 时,会重新生成一个新数字)

  • 避免使用 index 作为 key,否则会导致非受控组件的 state (比如输入框) 可能相互篡改,会出现无法预期的变动