具代表性的面試主題

前端面試:React key 如何控制狀態保留與重新掛載?

前端中等
OfferCC 編輯團隊發佈 更新

題幹

一個 React 頁面包含可排序、篩選和插入的可編輯列表,每列都有本地草稿與展開狀態;頁面還會在不同使用者的詳情表單之間切換。請解釋 key 如何影響元件身分和狀態保留,為什麼陣列索引或隨機值會產生錯誤,以及何時應該透過改變 key 主動重新掛載。

題幹與適用場景

一個管理頁面渲染可編輯使用者列表。每個 Row 內部保存尚未提交的輸入草稿和展開狀態。使用者可以在列表頂部 插入記錄、刪除中間記錄、排序和篩選。頁面右側還有詳情表單,切換 userId 時應清空上一位使用者未提交的本地 狀態。

請解釋:

  1. React 在相鄰兩次渲染之間如何判斷一個元件是否還是原來的元件。
  2. 為什麼 key={index} 會讓草稿或展開狀態跑到另一筆記錄。
  3. 為什麼 key={Math.random()} 會讓元件反覆重新掛載。
  4. 何時應使用穩定業務 ID 保留狀態,何時應改變 key 主動重設整個子樹。
  5. 如何驗證方案在插入、刪除、排序、篩選和切換實體時都正確。

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舊身分被移除,新身分掛載,子樹狀態重設
同一位置、不同元件類型舊子樹被替換,狀態重設
列表沒有明確 keyReact 退回按位置匹配,動態列表容易錯配

這不表示 key 是元件收到的普通 prop。key 是 React 自己使用的提示,子元件讀不到它;如果 Row 還需要業務 ID,必須另傳 rowId={item.id}。

key 也只在目前父級中定義身分。兩個不同列表都可以出現 key="user-42";同一列表的兄弟節點則不能重複。

第二步:用可編輯列表重現索引鍵錯誤。

錯誤實作:

tsx
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。

問題不在於「索引不是字串」,而在於索引表達的是槽位,業務需要表達的是使用者。刪除、排序和篩選都會改變槽位與 實體的對應關係。

正確實作:

tsx
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:

tsx
import { Fragment } from 'react'

users.map((user) => (
  <Fragment key={user.id}>
    <UserHeading user={user} />
    <EditableRow user={user} />
  </Fragment>
))

第三步:選擇穩定 key,而不是在 render 中製造 key。

優先級通常是:

  1. 後端或資料庫提供的穩定記錄 ID。
  2. 資料進入客戶端時已經攜帶的、在業務生命週期內不變的唯一標識。
  3. 僅存在本地的新記錄,在「建立記錄」事件中生成 UUID,然後隨記錄一起持久保存。
  4. 只有在組合欄位確實構成不可變且同級唯一的業務身分時,才使用組合 key。

下面的寫法會在每次 render 產生新身分:

tsx
<EditableRow key={Math.random()} user={user} />

結果不是普通的重新渲染,而是舊列被移除、新列重新掛載。所有本地狀態和使用者輸入都會遺失,DOM 也會重建。 Date.now() 或在 render 中呼叫 crypto.randomUUID() 有同樣問題。UUID 可以用,但必須在建立資料項目時生成並 保存,不能在渲染資料項目時生成。

陣列索引只在非常窄的邊界內可接受:列表在整個生命週期中不會插入、刪除、重排或篩選,順序本身就是身分,而且 列內沒有需要跟隨業務實體的狀態。只要未來會變化,就應補上真實 ID,而不是靠這組假設長期維持正確性。

第四步:用 key 表達「這是另一份表單」。

錯誤修復常見於詳情頁:

tsx
function Profile({ userId }: { userId: string }) {
  const [comment, setComment] = useState('')

  useEffect(() => {
    setComment('')
  }, [userId])

  return <CommentForm value={comment} onChange={setComment} />
}

這個 Effect 會先讓元件樹用舊 comment 渲染,再在 Effect 執行後觸發第二次渲染。更重要的是,詳情頁深處可能還 有附件、驗證錯誤和子表單狀態,父元件清一個 comment 無法保證整個子樹都重設。

如果產品定義是「不同使用者擁有獨立的新表單」,應在身分邊界上設定 key:

tsx
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 會替換 整個子樹。

另一個常見錯誤是在元件內部定義元件:

tsx
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 存在列表上層;否則列滾出視窗被卸載時,本地狀態仍會消失。

公開來源

同類題目