重复的 React.Key 带来的多余数据

1,472 阅读2分钟

背景

某天来了个 onCall 📞 📞 📞 📞

业务同学花花 🧒🏻:table 表格加了 rowKey 属性之后渲染的数据不正确,我只有两个数据,渲染后展示了四个数据,不加属性 rowKey 就是正常的,我渲染的数据没有重复的,每个 id 都不一样。

  • 渲染数据如下:
[    {        "dimMetId": 1586869438216,        "dimMetName": "产品名称",    },    {        "dimMetId": 1586869438211,        "dimMetName": "产品 ID",    }]
  • 渲染结果:

组件库同学静静 👧🏻:用的组件版本是多少啊,以及 table 设置的属性和过滤前的原数据发我一下。

Table 使用方式:

    <Table
        columns={columns}
        data={data}
        rowKey={(record) => {
          return record.dimMetId;
        }}//去掉 rowKey 渲染正常
        ...
      />

原数据:

[
    ...
    {
        "dimMetId": 1586869438216,
        "dimMetName": "产品名称",
        "mapType": 0,
        "dataTypeName": "string",
        "ruleType": 0
    },
    {
        "dimMetId": 1586869438228,
        "dimMetName": "行 ID",
        "mapType": 0,
        "dataTypeName": "int",
        "ruleType": 0
    },
    ...
]

Bug 排查

静静拿到数据之后在本地搭建了一个 demo

打开控制台一看,呀,这不是有 warning 吗?两个 children 有相同的 key

具有相同 key 的不就是过滤后多出来的这两个数据吗,静静去原数据一搜,果不其然,类别、邮寄方式有两个重复的数据,删除一个就正常了。赶紧先解决花花的问题,告诉她原数据有重复导致了这个问题。

原因

检查工具🔧查看下报错:

看下 warnOnInvalidKey ,15293 行就是报错的代码

/**
* Warns if there is a duplicate or missing key
*/
  function warnOnInvalidKey(child, knownKeys, returnFiber) {
    {
      ...
      switch (child.$$typeof) {
        ...
            // 如果有重复的 key 会执行下面语句 knownKeys.has(key)
          error('Encountered two children with the same key, `%s`. ' + 'Keys should be unique so that components maintain their identity ' + 'across updates. Non-unique keys may cause children to be ' + 'duplicated and/or omitted — the behavior is unsupported and ' + 'could change in a future version.', key);
          ...
      }
    }
    return knownKeys;
  }

我们在 error 行打断点,发现第一次渲染会执行到 error ,但是输入产品过滤数据,第二次渲染时并不会执行到 error 行。没有重复的 key 项,在第二次渲染时都被删除了,而有重复项的在第二次渲染时被保留了下来,在文件 react-dom.development.js 中全局搜索 remove,找到 removeChild,在此处打断点验证,

我们更改下数据,方便观察,更改完后:

搜索 “产品” 可以看到数据被一个一个的删除

经过调试(省略...,打断点一个一个看),有个函数 recursivelyTraverseMutationEffects

function recursivelyTraverseMutationEffects(root, parentFiber, lanes) {
    ...
    if (deletions !== null) {
    for (var i = 0; i < deletions.length; i++) {
      var childToDelete = deletions[i]; 

      try {
        commitDeletionEffects (root, parentFiber, childToDelete); 
      } catch (error) {
        captureCommitPhaseError(childToDelete, parentFiber, error);
      }
    }
  }
  ...
}

deletions 长度刚好是5,和删掉的数据长度一致,我们看下这里为什么是 5 而不是我们想要的7删除7个。React 如何决定这个 deletions 数组的呢?全局搜索下 childToDelete 并打断点调试:

function ChildReconciler(shouldTrackSideEffects) {
    function deleteChild(returnFiber, childToDelete) {
        ...
        var deletions = returnFiber.deletions;
        if (deletions === null) {
          returnFiber. deletions = [childToDelete]; 
          returnFiber.flags |= ChildDeletion;
        } else {
          deletions.push(childToDelete);
        }
    }
    function deleteRemainingChildren(returnFiber, currentFirstChild) {
        var childToDelete = currentFirstChild;
        while (childToDelete !== null) {
        deleteChild(returnFiber, childToDelete);
        childToDelete = childToDelete.sibling;
       }
    }
    function mapRemainingChildren(returnFiber, currentFirstChild) {
        var existingChildren = new Map (); 
        var existingChild = currentFirstChild;
        while (existingChild !== null) {
            if (existingChild.key !== null) {
                existingChildren.set(existingChild.key, existingChild);
              } else {
                existingChildren.set(existingChild.index, existingChild);
              }
              existingChild = existingChild.sibling;
        }
    }    
    function reconcileChildrenArray(...) {
        ...
        var existingChildren = mapRemainingChildren(returnFiber, oldFiber);
        ...
          if (shouldTrackSideEffects) {
          // Any existing children that weren't consumed above were deleted. We need
          // to add them to the deletion list.
              existingChildren.forEach(function (child) {
                return deleteChild(returnFiber, child);
           });
        }
        deleteChild(...);
    }
    ...
}

可以看到 existingChildren 是一个 Map ,而 Map 不会有重复 key ,set 的时候会覆盖相同 key 值的数据,所以相同 key 的元素,后面的会覆盖前面的,删除的时候只删除了最后一个,前面一个被保留了。验证demo 👉 demo

export default () => {
  const list_source = [1, 2, 3, 4, 4, 5, 5];
  const [list, setList] = React.useState(() => list_source);
  const handleClick = () => {
    setList([8, 9, 10]);
  };
  return (
    <>
      <button onClick={handleClick}>button</button>
      {list.map((item, index) => {
        return (
          <li key={item} data-key={index}>
            {item}
          </li>
        );
      })}
    </>
  );
};

data-key 设置为 index ,点击 button 后

data-key 为 3、5 的元素未被删除,删除的是重复 key 的最后一个。

React 在渲染列表时,列表元素的 Key 重复了会怎样?