題幹與適用場景
一個管理頁面渲染可編輯使用者列表。每個 Row 內部保存尚未提交的輸入草稿和展開狀態。使用者可以在列表頂部 插入記錄、刪除中間記錄、排序和篩選。頁面右側還有詳情表單,切換 userId 時應清空上一位使用者未提交的本地 狀態。
請解釋:
- React 在相鄰兩次渲染之間如何判斷一個元件是否還是原來的元件。
- 為什麼
key={index}會讓草稿或展開狀態跑到另一筆記錄。 - 為什麼
key={Math.random()}會讓元件反覆重新掛載。 - 何時應使用穩定業務 ID 保留狀態,何時應改變 key 主動重設整個子樹。
- 如何驗證方案在插入、刪除、排序、篩選和切換實體時都正確。
2026 年 3 月和 5 月發布的 React 面試資料都把 key、索引鍵、協調和重新掛載列為直接考點。它表面上是列表語法 題,實際考察候選人能否建立「狀態屬於哪個邏輯實體」的模型,並把這個模型落到元件邊界和測試案例上。
本題歸類為 frontend,因為核心能力是理解 React 渲染樹中的元件身分、狀態生命週期和 DOM 重用行為。
面試官考察點
第一,候選人是否知道狀態與 JSX 標籤或業務物件本身沒有自動綁定。React 把狀態關聯到渲染樹中的位置;在同一 父級內,元素類型和 key 會共同影響 React 是否把新舊子節點視為同一個身分。
第二,能否區分「重新渲染」和「重新掛載」。props 或父元件變化可以讓同一個元件實例重新渲染並保留本地狀態; 元件類型或 key 改變則會建立新身分,舊子樹的本地狀態被銷毀,DOM 也可能被重新建立。
第三,能否從業務身分選擇 key。資料庫 ID、建立記錄時生成並持久保存的 UUID,通常可以表達「這仍是同一筆 記錄」。陣列位置、目前時間或渲染期間生成的隨機值表達不了穩定身分。
第四,強回答不會把「永遠不要改變 key」當規則。詳情表單從使用者 A 切換到使用者 B 時,兩份表單在業務上就是 不同實體;在正確邊界使用 key={userId} 可以一次重設整個表單子樹,比逐層用 Effect 清狀態更完整。
最後,候選人還要說明重新掛載的代價:焦點、捲動位置、未保存輸入和子元件狀態都會遺失。若產品要求切回實體時 恢復草稿,應把草稿提升到父級、按實體保存,或寫入外部儲存,而不是依賴被移除子樹的本地狀態。
回答前需要釐清的問題
- 列表是否會插入、刪除、排序或篩選? 只要順序或成員可能變化,陣列索引就不能穩定表示業務實體。
- 每列是否持有本地狀態或瀏覽器 DOM 狀態? 輸入框、展開狀態、動畫和非受控表單會讓錯誤重用更容易暴露。
- 資料是否有穩定且同級唯一的 ID? key 只需在同一父級的兄弟節點之間唯一,不要求全域唯一。
- 切換實體後要清空還是恢復草稿? 清空適合改變 key;恢復需要把草稿放到更長壽命的狀態層。
- 需要重設整個子樹,還是只調整一個欄位? 全量重設可用 key;局部調整應優先使用受控狀態或明確資料模型。
- ID 在何時生成? 本地新建項目可以在建立記錄時生成 UUID 並保存;不能在每次 render 中重新生成。
30 秒回答框架
「React 把狀態關聯到渲染樹位置。在同一父級下,元件類型和 key 幫助 React 判斷新舊節點是不是同一身分。同一個 穩定 key 即使移動位置,React 仍能把列狀態跟隨業務記錄;用陣列索引時,插入或排序後同一位置會對應另一筆記錄, 本地草稿就可能錯行。用隨機 key 會讓每次渲染都無法匹配,元件和 DOM 被反覆重建。
列表應該使用資料中的穩定 ID。若從使用者 A 的詳情表單切換到使用者 B,並且產品要求清空所有本地狀態,我會在 表單子樹邊界使用 key={userId},明確告訴 React 這是新實體。若要保留每位使用者的草稿,我會把草稿提升並按 userId 儲存,而不是取消正確的 key。最後用插入、刪除、重排、篩選、同 ID 重新渲染和不同 ID 切換測試狀態是否 跟隨預期實體。」
分步驟深入解答
第一步:建立元件身分模型。
可以把 React 的判斷簡化為下面的面試模型:
| 新舊渲染關係 | 典型結果 | |---|---| | 同一父級、同一元件類型、同一 key | 保留該身分的本地狀態,元件可以重新渲染 | | 同一父級、同一類型、不同 key | 舊身分被移除,新身分掛載,子樹狀態重設 | | 同一位置、不同元件類型 | 舊子樹被替換,狀態重設 | | 列表沒有明確 key | React 退回按位置匹配,動態列表容易錯配 |
這不表示 key 是元件收到的普通 prop。key 是 React 自己使用的提示,子元件讀不到它;如果 Row 還需要業務 ID,必須另傳 rowId={item.id}。
key 也只在目前父級中定義身分。兩個不同列表都可以出現 key="user-42";同一列表的兄弟節點則不能重複。
第二步:用可編輯列表重現索引鍵錯誤。
錯誤實作:
function UserList({ users }: { users: User[] }) {
return users.map((user, index) => (
<EditableRow key={index} user={user} />
))
}假設初始順序是 [Alice, Bob]。索引 0 對應的 EditableRow 保存了 Alice 的本地草稿。現在在頂部插入 Zoe, 新順序變為 [Zoe, Alice, Bob]。React 仍會把 key 0 的舊列與新陣列第 0 項匹配,於是原屬於 Alice 的本地 狀態可能出現在 Zoe 列;key 1 的狀態也可能從 Bob 跑到 Alice。
問題不在於「索引不是字串」,而在於索引表達的是槽位,業務需要表達的是使用者。刪除、排序和篩選都會改變槽位與 實體的對應關係。
正確實作:
function UserList({ users }: { users: User[] }) {
return users.map((user) => (
<EditableRow key={user.id} rowId={user.id} user={user} />
))
}插入 Zoe 後,Alice 的 key 仍是 Alice 的 ID。她可以從索引 0 移到 1,React 仍能把 Alice 的元件狀態與 Alice 匹配。
如果一筆記錄要返回多個同級節點,短 Fragment 語法不能設定 key,應使用明確 Fragment:
import { Fragment } from 'react'
users.map((user) => (
<Fragment key={user.id}>
<UserHeading user={user} />
<EditableRow user={user} />
</Fragment>
))第三步:選擇穩定 key,而不是在 render 中製造 key。
優先級通常是:
- 後端或資料庫提供的穩定記錄 ID。
- 資料進入客戶端時已經攜帶的、在業務生命週期內不變的唯一標識。
- 僅存在本地的新記錄,在「建立記錄」事件中生成 UUID,然後隨記錄一起持久保存。
- 只有在組合欄位確實構成不可變且同級唯一的業務身分時,才使用組合 key。
下面的寫法會在每次 render 產生新身分:
<EditableRow key={Math.random()} user={user} />結果不是普通的重新渲染,而是舊列被移除、新列重新掛載。所有本地狀態和使用者輸入都會遺失,DOM 也會重建。 Date.now() 或在 render 中呼叫 crypto.randomUUID() 有同樣問題。UUID 可以用,但必須在建立資料項目時生成並 保存,不能在渲染資料項目時生成。
陣列索引只在非常窄的邊界內可接受:列表在整個生命週期中不會插入、刪除、重排或篩選,順序本身就是身分,而且 列內沒有需要跟隨業務實體的狀態。只要未來會變化,就應補上真實 ID,而不是靠這組假設長期維持正確性。
第四步:用 key 表達「這是另一份表單」。
錯誤修復常見於詳情頁:
function Profile({ userId }: { userId: string }) {
const [comment, setComment] = useState('')
useEffect(() => {
setComment('')
}, [userId])
return <CommentForm value={comment} onChange={setComment} />
}這個 Effect 會先讓元件樹用舊 comment 渲染,再在 Effect 執行後觸發第二次渲染。更重要的是,詳情頁深處可能還 有附件、驗證錯誤和子表單狀態,父元件清一個 comment 無法保證整個子樹都重設。
如果產品定義是「不同使用者擁有獨立的新表單」,應在身分邊界上設定 key:
function ProfilePage({ userId }: { userId: string }) {
return <ProfileForm key={userId} userId={userId} />
}當 userId 改變,React 將新的 ProfileForm 視為不同身分,表單及所有後代本地狀態一起重設。同一個 userId 因父元件其他狀態變化而重新渲染時,key 不變,本地狀態仍被保留。
key 應放在真正需要重設的最小完整邊界。如果把 key 放到整頁根節點,導覽列、昂貴的資料展示和不相關的捲動狀態 也會被重建;如果只放在一個輸入框上,又可能漏掉同一表單的其他狀態。
第五步:先決定產品要保留什麼,再決定是否重新掛載。
「切換實體」不會自動等於「丟棄草稿」。可以用決策表回答:
| 產品語義 | 推薦狀態設計 | |---|---| | 切換到另一實體必須得到全新表單 | 在表單子樹使用實體 ID 作為 key | | 切回實體要恢復各自草稿 | key 仍按實體區分;草稿提升到父級並按 ID 保存 | | 重新整理頁面後也要恢復草稿 | 持久化到外部儲存,並定義過期與清理 | | 只重設單一欄位,其餘狀態保留 | 使用受控狀態或明確更新,不重新掛載整個子樹 | | 只因 props 改變而展示不同計算結果 | 直接在 render 中從 props 計算,避免複製狀態 |
key 解決身分邊界,不負責長期持久化。把狀態提升為 drafts[userId] 後,即使對應表單子樹被卸載,草稿仍由父級 保存;重新選擇該使用者時,再用保存值初始化或控制表單。
第六步:辨識其他意外重設來源。
即使 key 正確,改變元件類型也會重設狀態。例如在同一位置從 ProfileForm 切到 LoginPrompt,React 會替換 整個子樹。
另一個常見錯誤是在元件內部定義元件:
function ProfilePage() {
function ProfileForm() {
const [name, setName] = useState('')
return <input value={name} onChange={(event) => setName(event.target.value)} />
}
return <ProfileForm />
}每次 ProfilePage 渲染都會建立新的 ProfileForm 函數物件,React 會把它視為不同元件類型,導致輸入狀態 意外重設。元件定義應放到頂層。面試中若只盯著 key,可能漏掉這個同樣屬於身分判斷的問題。
第七步:把 key 的放置與虛擬列表結合起來。
虛擬列表會反覆重用少量可見槽位。若虛擬化元件允許提供 itemKey,它也應返回業務實體 ID,而不是視窗內索引。 否則捲動或資料重排後,槽位中的本地狀態可能被另一個實體繼承。
不過,對大列表更穩妥的方案通常是減少列內不可持久的本地業務狀態:編輯草稿按記錄 ID 放在列表上層,列元件只 讀取目前實體的草稿。這樣即使虛擬化庫按效能需要卸載不可見列,資料也不會隨元件生命週期遺失。
第八步:用狀態歸屬測試驗收。
不要只檢查 DOM 文字有沒有更新。為每個場景先寫清「狀態應該屬於哪個 ID」:
| 操作 | 預期結果 | |---|---| | 在 Alice 列輸入草稿,再在頂部插入 Zoe | 草稿仍只屬於 Alice | | 刪除列表中間一項 | 其他列的展開和輸入狀態不遷移 | | 按姓名倒序,再恢復原排序 | 每列狀態始終跟隨記錄 ID | | 篩掉 Alice,再恢復篩選 | 若狀態僅在列內,卸載後會遺失;若產品要求恢復,應從上層草稿庫恢復 | | 同一 userId 下觸發父元件重新渲染 | 表單本地狀態保留 | | 從 user A 切到 user B | 以 userId 為 key 的表單完整重設 | | 將 key 暫時改為隨機值並觸發父元件重新渲染 | 可觀察到輸入遺失和 DOM 重建,證明錯誤機制 | | 資料出現重複 ID | 在資料邊界警告或拒絕,避免兄弟節點 key 衝突 |
測試還應覆蓋焦點和非受控輸入。錯誤 key 有時不會改變 React state,卻會讓瀏覽器保留的 DOM 值或焦點落到錯誤 記錄上。驗收標準應圍繞使用者可見的實體連續性,而不是只統計 render 次數。
高品質示範回答
「我先把 key 當作元件身分的一部分,而不是消除 console warning 的屬性。React 把狀態關聯到渲染樹位置;在 同一父級下,同一元件類型和同一 key 通常表示同一身分,因此 props 更新或位置移動可以重新渲染並保留狀態。 類型或 key 改變則表示新身分,舊子樹狀態被銷毀,新子樹掛載。
在可編輯列表中,索引代表位置,不代表使用者。初始 [Alice, Bob] 用 key 0 和 1,若頂部插入 Zoe,舊 key 0 的列會被拿來表示 Zoe,Alice 的草稿或展開狀態就可能跑到 Zoe。應使用 user.id,讓 Alice 即使從位置 0 移動到位置 1,身分仍保持不變。隨機 key、時間戳或 render 時生成 UUID 會讓每次渲染都無法匹配,造成元件與 DOM 反覆重建,並遺失輸入。key 只需在同一父級唯一,也不會作為 prop 傳給子元件。
詳情表單要看產品語義。如果 user A 切到 user B 時必須清空整個表單,我會在 ProfileForm 邊界使用 key={userId},讓所有後代狀態一次重設,而不是在多個 Effect 中逐個清理。若切回 A 要恢復草稿,key 仍按 userId 區分,但草稿要提升到父級或外部儲存並按 userId 保存。
最後我會測試頂部插入、刪除、重排、篩選、同 ID 重新渲染和不同 ID 切換,並檢查輸入、展開狀態、焦點和草稿都 跟隨預期業務 ID。這樣可以證明 key 表達了正確身分,而不只是頁面看起來能更新。」
常見錯誤
- 把 key 只解釋為效能最佳化 → 核心問題是新舊子節點的身分匹配與狀態歸屬 → 先解釋狀態錯配,再談更新成本。
- 所有列表都使用陣列索引 → 插入、刪除、重排和篩選會改變位置與實體的關係 → 使用資料中的穩定 ID。
- 在 render 中生成 UUID 或隨機數 → key 每次變化,元件與 DOM 反覆重建 → 在建立資料項目時生成並保存 ID。
- 認為 key 必須全域唯一 → React 只要求兄弟節點之間唯一 → 按目前父級和列表邊界判斷衝突。
- 在子元件中讀取
props.key→ key 不會作為普通 prop 傳入 → 另傳rowId或userId。 - 用 Effect 逐個清空複雜表單 → 會出現舊狀態渲染和二次渲染,也容易漏掉後代狀態 → 在正確邊界改變 key。
- 為了清一個欄位重新掛載整頁 → 會遺失焦點、捲動和不相關狀態 → 縮小 key 邊界或明確更新局部狀態。
- 用穩定 key 就承諾篩選後保留列內狀態 → 被篩掉的元件可能已經卸載 → 需要恢復時把狀態提升或持久化。
追問及應對
追問 1:資料沒有後端 ID,應該用什麼 key?
在建立本地記錄的事件中生成 ID,例如 UUID,然後把它作為記錄欄位保存。後續渲染一直讀取這一個 ID。可以使用 真正不可變且同級唯一的業務欄位組合,但要先證明它不會修改或碰撞。不能在 map 的 render 過程中臨時生成。
追問 2:什麼時候陣列索引可以作為 key?
只有列表成員和順序在整個生命週期內固定、不會插入刪除篩選或重排,並且位置本身就是身分時才相對安全。靜態 展示的固定條目可能滿足這個邊界。動態業務資料應預設使用穩定實體 ID,因為需求一旦加入排序或編輯,索引假設 就會失效。
追問 3:只想在 userId 改變時清一個欄位,還要改變整個表單的 key 嗎?
不一定。key 會重設整個子樹;如果其他本地狀態必須保留,應使用受控欄位、從 props 直接計算,或在明確的資料 更新路徑中調整該欄位。選擇 key 的前提是「整個子樹代表另一實體,全部本地狀態都應重新開始」。
追問 4:改變 key 後,怎樣讓使用者切回時恢復草稿?
把草稿提升到父級,例如保存為 drafts[userId],或寫入具備過期和清理策略的外部儲存。表單 key 繼續用 userId 防止不同使用者共享本地狀態;新掛載的表單從對應使用者的草稿讀取初值。身分隔離和草稿持久化是兩個 獨立職責。
追問 5:虛擬列表已經重用 DOM,還需要穩定 key 嗎?
需要。虛擬化解決可見節點數量,不能改變業務身分。若函式庫提供 itemKey,應返回記錄 ID。對必須跨卸載保留 的編輯狀態,還應按 ID 存在列表上層;否則列滾出視窗被卸載時,本地狀態仍會消失。