• 在React中使用index作为key会有什么问题?
  • 发布于 2个月前
  • 124 热度
    0 评论
背景
我们平时在react中遍历list,大多数情况使用index作为key,在某些场景会对list有删除或增加的操作,可能会有渲染性能的问题,本文对index作为key存在的问题进行分析以及为什么建议使用id作为key。

index作为key有什么问题
这里需要对react diff算法有一定的了解,在下面的demo中, 数组中有 A,B,C三个元素,当我们点击button的时候,往list中添加了D元素,理想情况下是创建D节点,A,B,C三个节点复用。
App () {
  const [list, setList] = useState(['A', 'B', 'C'])

  const onClick = () => {
    setList(['D'].concat(list))
  }
    
  return (
    <div>
      <button onClick={onClick}>Change</button>
      {
        list.map((item, idx) => {
          return <p id={idx}>{item}</p>
        })
      }
    </div>
  )
}
实际发现A,B也重新渲染了,这不是我们希望的。

原因是在react diff算法第一轮的遍历中,会把oldFiber和newChildren的key进行对比,只有key不同时,才会结束遍历。如果key相同节点内容不同则更新fiber节点。过程如下:
1.key = 0:key相同 节点A !== D 更新节点
2.key = 1:key相同 节点B !== A 更新节点
3.key = 2:key相同 节点C !== B 更新节点

4.oldFiber 遍历完结束第一轮遍历


在reconcileChildrenArray中第一轮遍历如下,key=0,1,2都能遍历到,oldFiber遍历完结束第一轮遍历。遍历过程中执行updateSlot,updateSlot的作用是优先对比key,key相同则复用或者更新节点,不同则返回null, 上述例子中oldFiber和newChildren的key=0,1,2都是相等的,因此会更新节点。这就从源码层面解释D,A,B节点为啥都是更新的了。
// children diff算法入口
function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<*>,
  lanes: Lanes,
): Fiber | null {
  // ...
  // diff算法第一轮遍历, 在这个例子中oldFiber遍历完退出遍历
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    if (oldFiber.index > newIdx) {
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      nextOldFiber = oldFiber.sibling;
    }
    // updateSlot 中新旧节点key不同则返回null
    const newFiber = updateSlot(
      returnFiber,
      oldFiber,
      newChildren[newIdx],
      lanes,
    );
    if (newFiber === null) {
      // 没有newFiber结束第一轮遍历
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      break;
    }
    // ...
  }
}
function updateSlot(
  returnFiber: Fiber,
  oldFiber: Fiber | null,
  newChild: any,
  lanes: Lanes,
): Fiber | 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;
        }
      }
      //堆代码 duidaima.com
    }
  // ...
  return null;
}
id作为key
下面例子中,使用id作为key
function App () {
  const [list, setList] = useState([{ id: 0, val: 'A' }, { id: 1, val:  'B' }, { id: 2, val: 'C' }])

  const onClick = () => {
    setList([{id: 3, val: 'B'}].concat(list))
  }
    
  return (
    <div>
      <button onClick={onClick}>Change</button>
      {
        list.map(item => {
          return <p key={item.id}>{item.val}</p>
        })
      }
    </div>
  )
}
我们点击button,发现只有D节点是新增的,A,B节点是复用之前的,这才是我们期望的。

结合上面的分析,在 olderFiber_A.key(A节点的key)为0,newChildren_D.key为3,对比两个key不相等,updateSlot返回null, 因此newFiber === null, 直接break跳出第一轮遍历。
// updateSlot 中新旧节点key不同则返回null
const newFiber = updateSlot(
  returnFiber,
  oldFiber,
  newChildren[newIdx],
  lanes,
);
if (newFiber === null) {
  // 没有newFiber结束第一轮遍历
  if (oldFiber === null) {
    oldFiber = nextOldFiber;
  }
  break;
}
走到后面复用的逻辑,所有的oldFiber节点生成一个map,为了快速查找把key作为索引。updateFromMap会优先复用existingChildren里的节点,没有复用的就创建新节点,因此这里A,B,C节点是可以复用的,D节点是新创建的。这就从源码层面解释了只有D节点是变化的。
function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<*>,
  lanes: Lanes,
): Fiber | null {
  // ...
 
  // 所有的oldFiber 生成一个map, 为了快速查找把key作为索引 
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

  // Keep scanning and use the map to restore deleted items as moves.
  for (; newIdx < newChildren.length; newIdx++) {
    // 在existingChildren中查找可以复用的节点,找不到就重新生成
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx],
      lanes,
    );
     // ...
  }
  // ...
}
总结
使用id为key, fiber节点和key的对应关系是稳定的,在react diff算法中会复用节点。使用index为key, fiber节点和key的对应关系是不稳定的,在增加或删除节点的情况,fiber节点和key的对应关系会发生变化,会造成不必要的额外渲染,因此建议在对list有增加或删除的场景中尽量使用id作为key, 提高react渲染性能
用户评论