為什麼不推薦在 React 中使用 index 作為 key

語言: CN / TW / HK

本文已參與「新人創作禮」活動,一起開啟掘金創作之路。

我們在開發 React 專案時,根據規範,對於列表、表格型別的元素,每項需要指定 key 屬性,否則會出現 warning 報錯。正如 ant design 的 Table 元件文件中描述的那樣,當不指定 key 時,可能會出現未知錯誤。

ant-design-table-key.png

我們需要儘量給每條資料提供一個 key 屬性,在實際專案中,一般資料是從後臺獲取的,所以我們可以使用唯一的標識 id 作為 key 值。但如果資料沒有 id 屬性的話,也不推薦使用 index 作為 key 。 React 官網上也有相關的解釋說明,不建議使用 index 作為 key ,可以去深入瞭解一下。本文將討論不推薦的原因,主要分為 2 點。

We don’t recommend using indexes for keys if the order of items may change. This can negatively impact performance and may cause issues with component state. Check out Robin Pokorny’s article for an in-depth explanation on the negative impacts of using an index as a key. If you choose not to assign an explicit key to list items then React will default to using indexes as keys.

效能

在討論效能問題前,我們先看一個案例。

```jsx function App() { const [data, setData] = useState([ { name: 'Tom', id: 'id1' }, { name: 'Sam', id: 'id2' }, { name: 'Ben', id: 'id3' }, { name: 'Pam', id: 'id4' }, ]);

return (

    {data.map(({ name }, index) => (
  • {name}
  • ))}


); } ```

在這個案例中,我們使用 map 函式進行生成多個 li 標籤,每個標籤的 key 值用 index 進行繫結。當點選按鈕的時候,會將 data 中的第一項進行刪除,觸發重新渲染。

從截圖中可以看到,確實可以達到我們想要的效果,但是如果我們細究一下 React 實際渲染更新做的事情,會發現效率不高。

```html

  • Tom
  • Sam
  • Ben
  • Pam
  • Sam
  • Ben
  • Pam
  • ```

    我們知道 React 更新機制是先比較虛擬 DOM ,然後通過計算差異,再對真實 DOM 進行操作。

    如上述程式碼所示,更新前後, index 依舊從 0 開始, React 進行逐條比較,發現了 2 條同樣 key=0li 標籤,然後遞迴比較內部,發現內部的文字由 Tom 改為了 Sam ,因此需要找到 key=0li 標籤,並進行真實 DOM 操作將內部的文字改為 Sam ,此時完成本條資料的更新。然後繼續比較 key=1key=2 的資料,並進行更新。最後,原本的 key=3li 標籤在新的虛擬 DOM 中,已經不存在了,於是執行了 DOM 刪除。

    ```html

  • Tom
  • Sam
  • Ben
  • Pam
  • Sam
  • Ben
  • Pam
  • ```

    如果我們使用資料的 id 作為 key ,一切就不一樣了。 React 一開始就發現 key=id1 的資料沒有了,就會進行刪除的操作。而其他 3 條資料,在進行比較後,會發現無變化,因此不會產生真實 DOM 的更新。

    小結一下,在這個案例中,我們想要將第一條資料進行刪除,觸發頁面上的元素變化。如果我們使用 index 作為 key ,會導致所有的真實 DOM 都發生變化;如果我們使用 id 作為 key ,則可以保證最小代價的更新,效率更高。

    更新不符預期

    如果只是效率問題,可能不是我們需要優先解決的,但是如果更新也會出錯的話,那我們就無法忽視了。這裡會列舉 2 個例子來說明該問題。

    輸入框內容

    ```jsx function App() { const [data, setData] = useState([ { name: 'Tom', id: 'id1' }, { name: 'Sam', id: 'id2' }, { name: 'Ben', id: 'id3' }, { name: 'Pam', id: 'id4' }, ]);

    return (

      {data.map(({ name }, index) => (
    • {name}
    • ))}


    ); } ```

    類似之前的案例,但在遍歷資料時,這次我們增加一個輸入框的渲染。測試案例時,我們首先在每個輸入框內輸入各自的內容,然後再點選按鈕刪除第一項。

    可以發現,看起來第一項 Tom 確實被刪除了,但是 input 標籤內的輸入值依舊保持為 1 。

    ```html

  • Tom
  • Sam
  • Ben
  • Pam
  • Sam
  • Ben
  • Pam
  • ```

    原因也很容易理解,正如之前的例子一樣,雖然看起來是第一項被刪除了,但實際上, React 在計算差異時,最終刪除的其實是最後一項。看起來是第一條被刪除的原因是, React 遞迴的比較內部的差異,然後更新了文字內容。而從結果上,我們也可以發現 input 標籤在前後比較中,被認為未發生變化,因此輸入值依舊保留著我們更新前輸入的內容。而最後一項 li 標籤被刪除,所以 input 標籤也同時被刪除了。

    文字標記

    有時候,我們會在網頁中進行一些標記,類似劃詞翻譯、主體識別等需求,當涉及資料修改的情況,使用 index 作為 key 容易出現問題。

    ```jsx function App() { const [data, setData] = useState([ { name: 'Tom', id: 'id1' }, { name: 'Sam', id: 'id2' }, { name: 'Ben', id: 'id3' }, { name: 'Pam', id: 'id4' }, ]);

    return (

      {data.map(({ name }, index) => (
    • {name}
    • ))}


    ); } ```

    在上述案例中,我們把第一項中的 m 進行標記,通過增加 a 標籤包裹的形式,此時進行刪除第一項會發現嚴重的更新錯誤。

    我們可以發現第一項並沒有被刪除,反而是第二項被刪除了。

    ```html

  • Tom
  • Sam
  • Ben
  • Pam
  • Tom
  • Sam
  • Ben
  • Pam
  • Sam
  • Ben
  • Pam
  • ```

    我們知道 React 更新的時候會首先比較虛擬 DOM ,然後計算如何更新,再將更新過程對映成真實 DOM 的操作,並最終完成更新。標註行為在這個案例中是直接通過修改 DOM 的形式進行操作的, React 並不知道發生了變化,因此在計算虛擬 DOM 變化的時候,依舊是用的 <li key="0">Tom<input/></li> 而不是 <li key="0">To<a>m</a><input/></li> 。在更新前,此處還有個特殊點在於, li 標籤下的 Tom 被視為一個隱式的節點,當發生更新的時候,預期的操作是,將該隱式節點替換成 Sam 。然而經過標註後,節點被破壞了,變成了 To 和 span 2 個節點。找不到 Tom 節點導致 React 無法完成預期的更新操作。

    如果希望解決這樣的問題,可以試著將 input 標籤去除,此時由於 li 標籤只有一個子節點 Tom ,因此 React 可以直接修改 liinnerHTML 完成更新。或者我們保留 input 標籤,但是給 Tom 外層增加一個 span 標籤,此時 React 更新的時候會修改 li 內第一個標籤,即 span 標籤進行更新。

    當然最好還是避免使用 index 作為 key

    小結一下,在這兩個案例中,發生更新的元素內由於存在無法被判斷變化的元素,比如 input ,或者元素髮生了 React 無法預知的修改導致與虛擬 DOM 不再匹配。在更新元素時,會出現更新不符預期的情況。

    總結

    • 不推薦使用 index 作為 key ,除非不涉及任何更新修改
    • 使用 index 作為 key 會導致渲染效率的問題
    • 使用 index 作為 key 會導致更新不符預期的問題

    如果讀者有興趣的話,可以去深度瞭解一下 React 的 diffing 演算法。

    本文所用程式碼可在本人倉庫找到:gitee