key 有什么作用, 可以省略吗?

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

ReactElement 对象

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

构造函数

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

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

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

    1. /**
    2. * https://github.com/reactjs/rfcs/pull/107
    3. * @param {*} type
    4. * @param {object} props
    5. * @param {string} key
    6. */
    7. export function jsx(type, config, maybeKey) {
    8. let propName;
    9. // 1. key的默认值是null
    10. let key = null;
    11. // Currently, key can be spread in as a prop. This causes a potential
    12. // issue if key is also explicitly declared (ie. <div {...props} key="Hi" />
    13. // or <div key="Hi" {...props} /> ). We want to deprecate key spread,
    14. // but as an intermediary step, we will use jsxDEV for everything except
    15. // <div {...props} key="Hi" />, because we aren't currently able to tell if
    16. // key is explicitly declared to be undefined or not.
    17. if (maybeKey !== undefined) {
    18. key = '' + maybeKey;
    19. }
    20. if (hasValidKey(config)) {
    21. // 2. 将key转换成字符串
    22. key = '' + config.key;
    23. }
    24. // 3. 将key传入构造函数
    25. return ReactElement(
    26. type,
    27. key,
    28. ref,
    29. undefined,
    30. undefined,
    31. ReactCurrentOwner.current,
    32. props,
    33. );
    34. }
  2. 传统的转译策略: 会将jsx语法的代码, 转译成React.createElement()函数包裹

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

    1. /**
    2. * Create and return a new ReactElement of the given type.
    3. * See https://reactjs.org/docs/react-api.html#createelement
    4. */
    5. export function createElement(type, config, children) {
    6. let propName;
    7. // Reserved names are extracted
    8. const props = {};
    9. let key = null;
    10. let ref = null;
    11. let self = null;
    12. let source = null;
    13. if (config != null) {
    14. if (hasValidKey(config)) {
    15. key = '' + config.key; // key转换成字符串
    16. }
    17. }
    18. return ReactElement(
    19. type,
    20. key,
    21. ref,
    22. self,
    23. source,
    24. ReactCurrentOwner.current,
    25. props,
    26. );
    27. }

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

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

源码看到这里, 虽然还只是个皮毛, 但是起码知道了key的默认值是null. 所以任何一个reactElement对象, 内部都是有key值的, 只是一般情况下(对于单节点)很少显式去传入一个 key.

Fiber 对象

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

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

  1. function FiberNode(
  2. tag: WorkTag,
  3. pendingProps: mixed,
  4. key: null | string,
  5. mode: TypeOfMode,
  6. ) {
  7. this.tag = tag;
  8. this.key = key; // 重点: key也是`fiber`对象的一个属性
  9. // ...
  10. this.elementType = null;
  11. this.type = null;
  12. this.stateNode = null;
  13. // ... 省略无关代码
  14. }

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

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

注意: fiber.keyreactElement.key的拷贝, 他们是完全相等的(包括null默认值).

接下来分析fiber创建, 剖析key在这个过程中的具体使用情况.

fiber对象的创建发生在fiber树构造循环阶段中, 具体来讲, 是在reconcileChildren调和函数中进行创建.

reconcileChildren 调和函数

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

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

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

  1. function ChildReconciler(shouldTrackSideEffects) {
  2. function reconcileChildFibers(
  3. returnFiber: Fiber,
  4. currentFirstChild: Fiber | null,
  5. newChild: any,
  6. lanes: Lanes,
  7. ): Fiber | null {
  8. // Handle object types
  9. const isObject = typeof newChild === 'object' && newChild !== null;
  10. if (isObject) {
  11. switch (newChild.$$typeof) {
  12. case REACT_ELEMENT_TYPE:
  13. // newChild是单节点
  14. return placeSingleChild(
  15. reconcileSingleElement(
  16. returnFiber,
  17. currentFirstChild,
  18. newChild,
  19. lanes,
  20. ),
  21. );
  22. }
  23. }
  24. // newChild是多节点
  25. if (isArray(newChild)) {
  26. return reconcileChildrenArray(
  27. returnFiber,
  28. currentFirstChild,
  29. newChild,
  30. lanes,
  31. );
  32. }
  33. // ...
  34. }
  35. return reconcileChildFibers;
  36. }

单节点

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

  1. function reconcileSingleElement(
  2. returnFiber: Fiber,
  3. currentFirstChild: Fiber | null,
  4. element: ReactElement,
  5. lanes: Lanes,
  6. ): Fiber {
  7. const key = element.key;
  8. let child = currentFirstChild;
  9. while (child !== null) {
  10. //重点1: key是单节点是否复用的第一判断条件
  11. if (child.key === key) {
  12. switch (child.tag) {
  13. default: {
  14. if (child.elementType === element.type) {
  15. // 第二判断条件
  16. deleteRemainingChildren(returnFiber, child.sibling);
  17. // 节点复用: 调用useFiber
  18. const existing = useFiber(child, element.props);
  19. existing.ref = coerceRef(returnFiber, child, element);
  20. existing.return = returnFiber;
  21. return existing;
  22. }
  23. break;
  24. }
  25. }
  26. // Didn't match.
  27. deleteRemainingChildren(returnFiber, child);
  28. break;
  29. }
  30. child = child.sibling;
  31. }
  32. // 重点2: fiber节点创建, `key`是随着`element`对象被传入`fiber`的构造函数
  33. const created = createFiberFromElement(element, returnFiber.mode, lanes);
  34. created.ref = coerceRef(returnFiber, currentFirstChild, element);
  35. created.return = returnFiber;
  36. return created;
  37. }

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

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

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

对于单节点来讲, key是可以省略的, react内部会设置成默认值null. 在进行diff时, 由于null===nulltrue, 前后renderkey是一致的, 可以进行复用比较.

如果单节点显式设置了key, 且两次render时的key如果不一致, 则无法复用.

多节点

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

  1. function reconcileChildrenArray(
  2. returnFiber: Fiber,
  3. currentFirstChild: Fiber | null,
  4. newChildren: Array<*>,
  5. lanes: Lanes,
  6. ): Fiber | null {
  7. if (__DEV__) {
  8. // First, validate keys.
  9. let knownKeys = null;
  10. for (let i = 0; i < newChildren.length; i++) {
  11. const child = newChildren[i];
  12. // 1. 在dev环境下, 执行warnOnInvalidKey.
  13. // - 如果没有设置key, 会警告提示, 希望能显式设置key
  14. // - 如果key重复, 会错误提示.
  15. knownKeys = warnOnInvalidKey(child, knownKeys, returnFiber);
  16. }
  17. }
  18. let resultingFirstChild: Fiber | null = null;
  19. let previousNewFiber: Fiber | null = null;
  20. let oldFiber = currentFirstChild;
  21. let lastPlacedIndex = 0;
  22. let newIdx = 0;
  23. let nextOldFiber = null;
  24. // 第一次循环: 只会在更新阶段发生
  25. for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  26. if (oldFiber.index > newIdx) {
  27. nextOldFiber = oldFiber;
  28. oldFiber = null;
  29. } else {
  30. nextOldFiber = oldFiber.sibling;
  31. }
  32. // 1. 调用updateSlot, 处理公共序列中的fiber
  33. const newFiber = updateSlot(
  34. returnFiber,
  35. oldFiber,
  36. newChildren[newIdx],
  37. lanes,
  38. );
  39. if (newFiber === null) {
  40. // 如果无法复用, 则退出公共序列的遍历
  41. if (oldFiber === null) {
  42. oldFiber = nextOldFiber;
  43. }
  44. break;
  45. }
  46. }
  47. // 第二次循环
  48. if (oldFiber === null) {
  49. for (; newIdx < newChildren.length; newIdx++) {
  50. // 2. 调用createChild直接创建新fiber
  51. const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
  52. }
  53. return resultingFirstChild;
  54. }
  55. for (; newIdx < newChildren.length; newIdx++) {
  56. // 3. 调用updateFromMap处理非公共序列中的fiber
  57. const newFiber = updateFromMap(
  58. existingChildren,
  59. returnFiber,
  60. newIdx,
  61. newChildren[newIdx],
  62. lanes,
  63. );
  64. }
  65. return resultingFirstChild;
  66. }

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

  1. updateSlot

    1. function updateSlot(
    2. returnFiber: Fiber,
    3. oldFiber: Fiber | null,
    4. newChild: any,
    5. lanes: Lanes,
    6. ): Fiber | null {
    7. const key = oldFiber !== null ? oldFiber.key : null;
    8. if (typeof newChild === 'object' && newChild !== null) {
    9. switch (newChild.$$typeof) {
    10. case REACT_ELEMENT_TYPE: {
    11. //重点: key用于是否复用的第一判断条件
    12. if (newChild.key === key) {
    13. return updateElement(returnFiber, oldFiber, newChild, lanes);
    14. } else {
    15. return null;
    16. }
    17. }
    18. }
    19. }
    20. return null;
    21. }
  2. createChild

    1. function createChild(
    2. returnFiber: Fiber,
    3. newChild: any,
    4. lanes: Lanes,
    5. ): Fiber | null {
    6. if (typeof newChild === 'object' && newChild !== null) {
    7. switch (newChild.$$typeof) {
    8. case REACT_ELEMENT_TYPE: {
    9. // 重点: 调用构造函数进行创建
    10. const created = createFiberFromElement(
    11. newChild,
    12. returnFiber.mode,
    13. lanes,
    14. );
    15. return created;
    16. }
    17. }
    18. }
    19. return null;
    20. }
  3. updateFromMap

    1. function updateFromMap(
    2. existingChildren: Map<string | number, Fiber>,
    3. returnFiber: Fiber,
    4. newIdx: number,
    5. newChild: any,
    6. lanes: Lanes,
    7. ): Fiber | null {
    8. if (typeof newChild === 'object' && newChild !== null) {
    9. switch (newChild.$$typeof) {
    10. case REACT_ELEMENT_TYPE: {
    11. //重点: key用于是否复用的第一判断条件
    12. const matchedFiber =
    13. existingChildren.get(
    14. newChild.key === null ? newIdx : newChild.key,
    15. ) || null;
    16. return updateElement(returnFiber, matchedFiber, newChild, lanes);
    17. }
    18. }
    19. return null;
    20. }
    21. }

    针对多节点的diff算法可以分为3步骤(请回顾算法章节React 算法之调和算法):

  4. 第一次循环: 比较公共序列
    • 从左到右逐一遍历, 遇到一个无法复用的节点则退出循环.
  5. 第二次循环: 比较非公共序列
    • 在第一次循环的基础上, 如果oldFiber队列遍历完了, 证明newChildren队列中剩余的对象全部都是新增.
      • 此时继续遍历剩余的newChildren队列即可, 没有额外的diff比较.
    • 在第一次循环的基础上, 如果oldFiber队列没有遍历完, 需要将oldFiber队列中剩余的对象都添加到一个map集合中, 以oldFiber.key作为键.
      • 此时则在遍历剩余的newChildren队列时, 需要用newChild.keymap集合中进行查找, 如果匹配上了, 就将oldFibermap中取出来, 同newChild进行diff比较.
  6. 清理工作
    • 在第二次循环结束后, 如果map集合中还有剩余的oldFiber,则可以证明这些oldFiber都是被删除的节点, 需要打上删除标记.

通过回顾diff算法的原理, 可以得到key在多节点情况下的特性:

  1. 新队列newChildren中的每一个对象(即reactElement对象)都需要同旧队列oldFiber中有相同key值的对象(即oldFiber对象)进行是否可复用的比较. key就是新旧对象能够对应起来的唯一标识.
  2. 如果省略key或者直接使用列表index作为key, 表现是一样的(key=null时, 会采用index代替key进行比较). 在新旧对象比较时, 只能按照index顺序进行比较, 复用的成功率大大降低, 大列表会出现性能问题.
    • 例如一个排序的场景: oldFiber队列有100个, newChildren队列有100个(但是打乱了顺序). 由于没有设置key, 就会导致newChildren中的第n个必然要和oldFiber队列中的第n个进行比较, 这时它们的key完全一致(都是null), 由于顺序变了导致props不同, 所以新的fiber完全要走更新逻辑(理论上比新创建一个的性能还要耗).
    • 同样是排序场景可以出现的bug: 上面的场景只是性能差(又不是不能用), key使用不当还会造成bug
      • 还是上述排序场景, 只是列表中的每一个item内部又是一个组件, 且其中某一个item使用了局部状态(比如class组件里面的state). 当第二次render时, fiber对象不会delete只会update导致新组件的state还沿用了上一次相同位置的旧组件的state, 造成了状态混乱.

总结

reactkey是服务于diff算法, 它的默认值是null, 在diff算法过程中, 新旧节点是否可以复用, 首先就会判定key是否相同, 其后才会进行其他条件的判定. 在源码中, 针对多节点(即列表组件)如果直接将key设置成index和不设置任何值的处理方案是一样的, 如果使用不当, 轻则造成性能损耗, 重则引起状态混乱造成bug.