React/Vue里的key到底有什么用?看完這篇你就知道了!(附demo代碼)


網上有很多博客講到,React、Vue里的key,與 Virtual DOM 及 DOM diff 有關, 可以用來唯一標識DOM節點,提高diff效率,雲雲。

這大致是對的,但是,大多講得語焉不詳,像是在背答案。

具體怎么個提效法?為什么說用數組下標當作key是“反模式”?講了一堆,能不能來個眼見為實,show me the code?

本文以React為例,嘗試稍微刨一刨,但又不刨到太底層,以足夠幫助理解為度。

1. VNode diff

首先介紹 Virtual DOM 結點(后續簡稱Virtual Node, VNode)是如何創建出來的。

現實中的React項目幾乎都會用到JSX,而JSX不能直接執行,需要先經babel編譯成js代碼,比如:

<div className="content">Hello world!</div>

會被編譯成

React.createElement("div", {
    className: "content"
}, "Hello world!");

點擊這里查看在線編譯

所以,只要調用 React.createElement 這個靜態方法,就可以創建出一個VNode。

無需深入VNode 的具體數據結構,只要看看這個工廠方法的參數,就可以知道 DOM diff 到底 diff 了哪些內容。

根據React官方文檔,該方法可以接收≥3個參數:

  • 第一個參數是type,指定結點類型,如果是HTML原生結點,那么會是一個字符串,比如"div";如果是React組件,那么就會是一個class或function;
  • 第二個參數是props,是一個對象或者null。比如前面的例子中,div標簽上的"className"屬性就被加到這里來了;
  • 第三(及第四,第五,……)個參數是childNode,該結點的子節點。前面的例子中,div的子節點是一個內容為"Hello world!"的TextNode

是滴,DOM diff 具體diff 的東西,就是這幾個參數。為什么不會有別的?因為那樣不符合React的設計理念:Data => UI 單向映射。

2. 動態列表的diff困局

我們知道React在調用setState觸發render時,會對新舊 Virtual DOM 做比較,力爭以最小的代價完成新DOM渲染任務。

結合上面提到的幾個參數,具體比較過程大致是這樣的:

  • 首先比較type。如果type不同,那沒什么好說的,直接銷毀重新create一個;如果type相同,再往后看:
  • 其次比較props,如果有變化,那就把變化的部分update;如果沒變化,那就再往后看:
  • 最后比較子節點,同樣地,有變化就update,沒變化就啥都不做

這在DOM結構固定的一般情況下是很好用的,但當我們希望從一個list映射出列表、而且這個list里的項隨時可能變化時,就有點麻煩了。

比如說,原本list是這樣的:

[
  {name: 'Smith', job: 'Engineer'},
  {name: 'Alice', job: 'HR'},
  {name: 'Jenny', job: 'Designer'}
]

然后,Jenny被移到了最前面,那么Smith和Alice就相應后移了,變成了

[
  {name: 'Jenny', job: 'Designer'},
  {name: 'Smith', job: 'Engineer'},
  {name: 'Alice', job: 'HR'}
]

對於React來說,如果它不知道這三個結點“本來”是誰,只是按照位置對應關系逐個去檢查,會發現每個結點都變了:

  • Smith => Jenny
  • Alice => Smith
  • Jenny => Alice

於是React得出結論:列表中的所有結點,全都需要update,重新渲染!

且慢!有沒有更好的方法?

3. 借助key破局

如果,React“知道”這三個結點“本來”是誰,那么事情就會簡單很多:

不需要更新任何DOM結點,只需把Jenny對應的結點摘下來,再插入到新的位置,完事。

但React怎么會知道誰是誰呢?

這需要我們開發者手動告訴它,於是key出場了。

在做DOM diff 時,如果同一個父組件下的兩個VNode擁有同樣的key,就會被視為同一個結點,如果React據此判斷出,這個結點在列表中的排位發生了變化,就會像上面說的那樣,進行“摘下-插入”處理。

為了證明這一點,亮代碼!

首先上一個故意整出bug的版本:

class App extends React.Component {
  state = {
    list: [0, 1, 2]
  }

  add() {
    const list = this.state.list;
    this.setState({ list: [list.length, ...list] });
  }

  render() {
    return (
      <div className="App">
        <button onClick={() => this.add()}>Input sth below, then click me</button>
        <ul>
          {
// 注意:這里故意用index作為key,引發bug
this.state.list.map((item, index) => ( <li key={index}> <span>Item-{item}</span> <input type="text" /> </li> ) ) } </ul> </div> ); } }

ReactDOM.render(
<App />,
document.getElementById('root')
);

可以用 create-react-app起個項目,在本地試試這段代碼。演示效果如下,先在第二行文本框里輸入一些1:

然后,點擊上面的按鈕,會發現……

輸入了一串1的文本框沒有跟着Item-1走,而是留在了“原位”!

這就是用數組下標作key引發的典型bug。原因就在於新列表里Item-0和原列表里的Item-1擁有同樣的key,被React視為同一個結點,所以只是“就地”更新了子節點(文本),並沒有挪動結點的位置。

而這個bug的巧妙之處就在於使用了<input>,它可以在VNode的type、props、children均無變化的前提下,被用戶行為改變其樣式(輸入的內容),從而讓我們直觀地看到結點所處位置。感謝React官方提供了這個巧妙的case

好,下面我們來修復這個bug。

修復方法很簡單:把 key={index} 改成 key={item} 就行了。

保存,刷新重試,我們就可以得到:

這下,對應關系正確了,React正確地識別出了3個舊結點,直接把新結點插入到列表開頭,而舊結點沒有變化。

 

看到這里,你應該明白key到底有什么用,以及為什么index不宜做key了吧。

另外,如果沒有指定key,那么React會默認使用index作為key,所以,只要是動態列表,為了性能着想,請盡量用unique id作為key。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM