React 中 key 的作用

React 中 key 的作用是什么?

Date: August 31, 2025

Area: 原理


key 概念

在 React 中,key 用于识别哪些元素是变化、添加或删除的。

在列表渲染中,key 尤其重要,因为它能提高渲染性能和确保组件状态的一致性。


key 的作用

1)唯一性标识:

React 通过 key 唯一标识列表中的每个元素。当列表发生变化(增删改排序)时,React 会通过 key 快速判断:

  • 哪些元素是新增的(需要创建新 DOM 节点)
  • 哪些元素是移除的(需要销毁旧 DOM 节点)
  • 哪些元素是移动的(直接复用现有 DOM 节点,仅调整顺序)

如果没有 key,React 会默认使用数组索引(index)作为标识,这在动态列表中会导致 性能下降状态错误

2)保持组件状态:

使用 key 能确保组件在更新过程中状态的一致性。不同的 key 会使 React 认为它们是不同的组件实例,因而会创建新的组件实例,而不是重用现有实例。这对于有状态的组件尤为重要。

jsx 复制代码
// 如果初始列表是 [A, B],用索引 index 作为 key:
<ul>
  {items.map((item, index) => (
    <li key={index}>{item}</li>
  ))}
</ul>

// 在头部插入新元素变为 [C, A, B] 时:
// React 会认为 key=0 → C(重新创建)
// key=1 → A(复用原 key=0 的 DOM,但状态可能残留)
// 此时,原本属于 A 的输入框状态可能会错误地出现在 C 中。

3)高效的 Diff 算法:

在列表中使用 key 属性,React 可以通过 Diff 算法快速比较新旧元素,确定哪些元素需要重新渲染,哪些元素可以复用。这减少了不必要的 DOM 操作,从而提高渲染性能。


源码解析

以下是 React 源码中与 key 相关的关键部分:

1)生成 Fiber树

在生成 Fiber 树时,React 使用 key 来匹配新旧节点。

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

  • Code:

    jsx 复制代码
        // * 协调子节点,构建新的子fiber结构,并且返回新的子fiber
      function reconcileChildFibers(
        returnFiber: Fiber,
        currentFirstChild: Fiber | null, // 老fiber的第一个子节点
        newChild: any,
        lanes: Lanes,
      ): Fiber | null {
        // This indirection only exists so we can reset `thenableState` at the end.
        // It should get inlined by Closure.
        thenableIndexCounter = 0;
        const firstChildFiber = reconcileChildFibersImpl(
          returnFiber,
          currentFirstChild,
          newChild,
          lanes,
          null, // debugInfo
        );
    
        thenableState = null;
        // Don't bother to reset `thenableIndexCounter` to 0 because it always gets
        // set at the beginning.
        return firstChildFiber;
      }
      
      function reconcileChildrenArray(
        returnFiber: Fiber,
        currentFirstChild: Fiber | null,
        newChildren: Array<any>,
        lanes: Lanes,
        debugInfo: ReactDebugInfo | null,
      ): Fiber | null {
        let resultingFirstChild: Fiber | null = null; // 存储新生成的child
        let previousNewFiber: Fiber | null = null;
    
        let oldFiber = currentFirstChild;
        let lastPlacedIndex = 0;
        let newIdx = 0;
        let nextOldFiber = null;
        // ! 1. 从左边往右遍历,比较新老节点,如果节点可以复用,继续往右,否则就停止
        for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
          if (oldFiber.index > newIdx) {
            nextOldFiber = oldFiber;
            oldFiber = null;
          } else {
            nextOldFiber = oldFiber.sibling;
          }
          const newFiber = updateSlot(
            returnFiber,
            oldFiber,
            newChildren[newIdx],
            lanes,
            debugInfo,
          );
          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.
              deleteChild(returnFiber, oldFiber);
            }
          }
          lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
          if (previousNewFiber === null) {
            // TODO: Move out of the loop. This only happens for the first run.
            resultingFirstChild = newFiber;
          } else {
            // TODO: Defer siblings if we're not at the right index for this slot.
            // I.e. if we had null values before, then we want to defer this
            // for each null value. However, we also don't want to call updateSlot
            // with the previous one.
            previousNewFiber.sibling = newFiber;
          }
          previousNewFiber = newFiber;
          oldFiber = nextOldFiber;
        }
    
        // !2.1 新节点没了,(老节点还有)。则删除剩余的老节点即可
        // 0 1 2 3 4
        // 0 1 2 3
        if (newIdx === newChildren.length) {
          // We've reached the end of the new children. We can delete the rest.
          deleteRemainingChildren(returnFiber, oldFiber);
          if (getIsHydrating()) {
            const numberOfForks = newIdx;
            pushTreeFork(returnFiber, numberOfForks);
          }
          return resultingFirstChild;
        }
        // ! 2.2 (新节点还有),老节点没了
        // 0 1 2 3 4
        // 0 1 2 3 4 5
        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.
          for (; newIdx < newChildren.length; newIdx++) {
            const newFiber = createChild(
              returnFiber,
              newChildren[newIdx],
              lanes,
              debugInfo,
            );
            if (newFiber === null) {
              continue;
            }
            lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
            if (previousNewFiber === null) {
              // TODO: Move out of the loop. This only happens for the first run.
              resultingFirstChild = newFiber;
            } else {
              previousNewFiber.sibling = newFiber;
            }
            previousNewFiber = newFiber;
          }
          if (getIsHydrating()) {
            const numberOfForks = newIdx;
            pushTreeFork(returnFiber, numberOfForks);
          }
          return resultingFirstChild;
        }
    
        // !2.3 新老节点都还有节点,但是因为老fiber是链表,不方便快速get与delete,
        // !   因此把老fiber链表中的节点放入Map中,后续操作这个Map的get与delete
        // 0 1|   4 5
        // 0 1| 7 8 2 3
        // Add all children to a key map for quick lookups.
        const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
    
        // Keep scanning and use the map to restore deleted items as moves.
        for (; newIdx < newChildren.length; newIdx++) {
          const newFiber = updateFromMap(
            existingChildren,
            returnFiber,
            newIdx,
            newChildren[newIdx],
            lanes,
            debugInfo,
          );
          if (newFiber !== null) {
            if (shouldTrackSideEffects) {
              if (newFiber.alternate !== null) {
                // The new fiber is a work in progress, but if there exists a
                // current, that means that we reused the fiber. We need to delete
                // it from the child list so that we don't add it to the deletion
                // list.
                existingChildren.delete(
                  newFiber.key === null ? newIdx : newFiber.key,
                );
              }
            }
            lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
            if (previousNewFiber === null) {
              resultingFirstChild = newFiber;
            } else {
              previousNewFiber.sibling = newFiber;
            }
            previousNewFiber = newFiber;
          }
        }
    
         // !3. 如果是组件更新阶段,此时新节点已经遍历完了,能复用的老节点都用完了,
        // ! 则最后查找Map里是否还有元素,如果有,则证明是新节点里不能复用的,也就是要被删除的元素,此时删除这些元素就可以了
        if (shouldTrackSideEffects) {
          // Any existing children that weren't consumed above were deleted. We need
          // to add them to the deletion list.
          existingChildren.forEach(child => deleteChild(returnFiber, child));
        }
    
        if (getIsHydrating()) {
          const numberOfForks = newIdx;
          pushTreeFork(returnFiber, numberOfForks);
        }
        return resultingFirstChild;
      }

在 reconcileChildFibers 中的关键使用:

顶层"单个元素"分支(如 reconcileSingleElement):先在兄弟链表里按 key 查找可复用的老 Fiber;若 key 相同再比类型,复用成功则删除其他老兄弟,否则删到尾并新建。

jsx 复制代码
  function reconcileSingleElement(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    element: ReactElement,
    lanes: Lanes,
    debugInfo: ReactDebugInfo | null,
  ): Fiber {
    const key = element.key;
    let child = currentFirstChild;
    // 检查老的fiber单链表中是否有可以复用的节点
    while (child !== null) {
      if (child.key === key) {
        ...
        if (child.elementType === elementType || ... ) {
          deleteRemainingChildren(returnFiber, child.sibling);
          const existing = useFiber(child, element.props);
          ...
          return existing;
        }
        deleteRemainingChildren(returnFiber, child);
        break;
      } else {
        deleteChild(returnFiber, child);
      }
    }
    ...
  }
  • 顶层对 Fragment(无 key)特殊处理:若是未带 key 的顶层 Fragment,会直接把 children 取出来按数组/迭代器逻辑继续走。

2)比较新旧节点

在比较新旧节点时,React 通过 key 来确定节点是否相同:

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

  • Code:

    jsx 复制代码
      function updateSlot(
        returnFiber: Fiber,
        oldFiber: Fiber | null,
        newChild: any,
        lanes: Lanes,
        debugInfo: null | ReactDebugInfo,
      ): Fiber | null {
        // Update the fiber if the keys match, otherwise return null.
        const key = oldFiber !== null ? oldFiber.key : null;
    
        if (
          (typeof newChild === 'string' && newChild !== '') ||
          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;
          }
          return updateTextNode(
            returnFiber,
            oldFiber,
            '' + newChild,
            lanes,
            debugInfo,
          );
        }
    
        if (typeof newChild === 'object' && newChild !== null) {
          switch (newChild.$$typeof) {
            case REACT_ELEMENT_TYPE: {
              if (newChild.key === key) {
                return updateElement(
                  returnFiber,
                  oldFiber,
                  newChild,
                  lanes,
                  mergeDebugInfo(debugInfo, newChild._debugInfo),
                );
              } else {
                return null;
              }
            }
            case REACT_PORTAL_TYPE: {
              if (newChild.key === key) {
                return updatePortal(
                  returnFiber,
                  oldFiber,
                  newChild,
                  lanes,
                  debugInfo,
                );
              } else {
                return null;
              }
            }
            case REACT_LAZY_TYPE: {
              const payload = newChild._payload;
              const init = newChild._init;
              return updateSlot(
                returnFiber,
                oldFiber,
                init(payload),
                lanes,
                mergeDebugInfo(debugInfo, newChild._debugInfo),
              );
            }
          }
    
          if (isArray(newChild) || getIteratorFn(newChild)) {
            if (key !== null) {
              return null;
            }
    
            return updateFragment(
              returnFiber,
              oldFiber,
              newChild,
              lanes,
              null,
              mergeDebugInfo(debugInfo, newChild._debugInfo),
            );
          }
    
          // Usable node types
          //
          // Unwrap the inner value and recursively call this function again.
          if (typeof newChild.then === 'function') {
            const thenable: Thenable<any> = (newChild: any);
            return updateSlot(
              returnFiber,
              oldFiber,
              unwrapThenable(thenable),
              lanes,
              debugInfo,
            );
          }
    
          if (newChild.$$typeof === REACT_CONTEXT_TYPE) {
            const context: ReactContext<mixed> = (newChild: any);
            return updateSlot(
              returnFiber,
              oldFiber,
              readContextDuringReconcilation(returnFiber, context, lanes),
              lanes,
              debugInfo,
            );
          }
    
          throwOnInvalidObjectType(returnFiber, newChild);
        }
    
        if (__DEV__) {
          if (typeof newChild === 'function') {
            warnOnFunctionType(returnFiber, newChild);
          }
          if (typeof newChild === 'symbol') {
            warnOnSymbolType(returnFiber, newChild);
          }
        }
    
        return null;
      }

实际案例

1)简单列表

假设我们有一个简单的列表:

jsx 复制代码
const items = this.state.items.map(item => 
	<li key={item.id}>{ item.text }</li>
)

在上述代码中,每个

  • 元素都有一个唯一的 key。

如果 items 数组发生变化(如添加或删除元素),React将根据 key 来高效地更新DOM:

  • 当一个元素被删除时,React仅删除对应 key 的DOM节点。
  • 当一个元素被添加时,React 仅在相应的位置插入新的DOM节点。
  • 当一个元素被移动时,React 会识别到位置变化并重新排列 DOM 节点。

2)错误案例演示

tsx 复制代码
import React, { useState } from 'react'

// 错误案例:使用数组索引作为 key,导致组件在插入/重排时状态错乱
// 复现实验:
// 1) 在下方两个输入框分别输入不同文本(对应 A、B)
// 2) 点击"在头部插入 C" → 列表从 [A, B] 变为 [C, A, B]
// 3) 使用 index 作为 key 时:
//    key=0 → C(重新创建)
//    key=1 → A(复用原 key=0 的 DOM,状态可能残留)
//    因此原本属于 A 的输入框状态可能会错误地出现在 C 中

function InputItem({ label }: { label: string }) {
	const [text, setText] = useState<string>('')
	return (
		<div
			style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}
		>
			<span style={{ width: 80 }}>{label}</span>
			<input
				placeholder="在此输入以观察状态"
				value={text}
				onChange={e => setText(e.target.value)}
			/>
		</div>
	)
}

export default function TestDemo() {
	const [labels, setLabels] = useState<string[]>(['A', 'B'])

	const prependC = () => {
		setLabels(prev => ['C', ...prev])
	}

	return (
		<div style={{ padding: 16 }}>
			<h3>错误示例:使用 index 作为 key(头部插入触发状态错乱)</h3>
			<button onClick={prependC} style={{ marginBottom: 12 }}>
				在头部插入 C
			</button>
			{labels.map((label, index) => (
				// 错误:使用 index 作为 key,头部插入 C 后会发生状态错位
				<InputItem key={index} label={label} />
			))}
		</div>
	)
}
相关推荐
全栈技术负责人3 小时前
移动端富文本markdown中表格滚动与页面滚动的冲突处理:Touch 事件 + 鼠标滚轮精确控制方案
前端·javascript·计算机外设
前端拿破轮3 小时前
从零到一开发一个Chrome插件(二)
前端·面试·github
珍宝商店3 小时前
Vue.js 中深度选择器的区别与应用指南
前端·javascript·vue.js
天蓝色的鱼鱼3 小时前
Next.js 预渲染完全指南:SSG vs SSR,看完秒懂!
前端·next.js
月出3 小时前
无限循环滚动条 - 左出右进
前端
aiwery3 小时前
实现带并发限制的 Promise 调度器
前端·算法
ZTStory3 小时前
JS 处理生僻字字符 sm4 加密后 Java 解密字符乱码问题
javascript·掘金·金石计划
熊猫片沃子3 小时前
浅谈Vue 响应式原理
前端
货拉拉技术3 小时前
微前端中的错误堆栈问题探究
前端·javascript·vue.js