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 認為它們是不同的組件實例,因而會創建新的組件實例,而不是重用現有實例。這對于有狀態的組件尤為重要。
// 如果初始列表是 [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:
// * 協調子節點,構建新的子fiber結構,并且返回新的子fiberfunction 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; // 存儲新生成的childlet 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 3if (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 5if (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 相同再比類型,復用成功則刪除其他老兄弟,否則刪到尾并新建。
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:
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)簡單列表
假設我們有一個簡單的列表:
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)錯誤案例演示
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 (<divstyle={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}><span style={{ width: 80 }}>{label}</span><inputplaceholder="在此輸入以觀察狀態"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>) }