Jansiel Notes

面试官:为什么react的key不建议使用index

背景

Hi,大家好,我叫周zhou。 我们平时在react中遍历list,大多数情况使用index作为key,在某些场景会对list有删除或增加的操作,可能会有 渲染性能 的问题,本文对index作为key存在的问题进行分析以及为什么建议使用id作为key。

index作为key有什么问题

这里需要对react diff算法有一定的了解,大家可以参考卡颂老师的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 也重新渲染了,这不是我们希望的。

Jansiel_Essay_1718073600204

原因是在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;
        }
      }
      // ...
    }
  // ...
  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节点是复用之前的,这才是我们期望的。

Jansiel_Essay_1718073630906

结合上面的分析,在 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渲染性能。