網上有很多博客講到,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。