React key究竟有什麼作用?深入源碼不背概念,五個問題刷新你對於key的認知

壹 ❀ 引
我在【react】什麼是fiber?fiber解決了什麼問題?從源碼角度深入了解fiber運行機制與diff執行一文中介紹了react對於fiber處理的協調與提交兩個階段,而在介紹協調時又順帶解釋了另一個較為重要的概念diff。那既然提到了diff我們還會順帶問一問diff中另一個有趣的概念key,那麼現在我來問大家,你是如何理解key的,key又有什麼作用呢?請大家思考一會如何回答。
我想,超過一大半的人會說,key在diff時能起到標記的作用,比如往一個數組前面添加一個元素,react通過key能清晰知道它只用新增一個節點,而另外兩個節點可以直接復用,從而極大優化性能。
正如官網在介紹key時的例子所言:
當子元素擁有 key 時,React 使用 key 來匹配原有樹上的子元素以及最新樹上的子元素。以下例子在新增 key 之後使得之前的低效轉換變得高效。
<!-- 更新前 -->
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
<!-- 更新後 -->
<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
現在 React 知道只有帶著 '2014' key 的元素是新元素,帶著 '2015' 以及 '2016' key 的元素僅僅移動了。
那麼這個回答有問題嗎?官方都這麼說了,那大概是沒問題的;但如果我是面試官,我會基於這個回答再拋出如下幾個問題:
-
為什麼
list渲染時我們不提供key react就會給出警告,而普通dom結構不提供key卻不會如此?說說你的理解。 -
react中的key真的有這麼聰明嗎?在列表渲染時通過key react就能知道哪些是新增的哪些是可以直接復用(僅僅是移動了)? -
我們知道
react的diff是逐層比較的,假設現在有一個數組為:const list = [ {key:2015,value:1}, {key:2016,value:2}, ]我們在更新後
list為:const list = [ {key:2014,value:0}, {key:2015,value:1}, {key:2016,value:2}, ]按照逐層比較的概念,它應該是這樣:

那豈不是每一次比較都會認為
key不同?每一層對比後都得重新渲染?那所謂的優化又是怎麼做的呢? -
按照
diff逐層對比的邏輯,如果新舊節點的key相等,則證明這箇舊節點還可以復用。而我們不提供key時,key將默認為null;既然你又是逐層對比,而此時null === null也為true,也能夠復用,那為什麼還要提供獨一無二的key? -
為什麼不推薦使用
index作為key,原因是什麼?
通過這五個問題,其實你能發現react官方基於key的解釋其實是特別宏觀的角度,如果你稍微了解過源碼,你甚至會發現官方這個結論還有點經不住推敲,那麼就讓我們帶著這幾個問題投身於react源碼中,通過這幾個問題來重新理解react中的key。
注意,本文的源碼分析均基於react 17.0.2版本,那麼本文開始。
貳 ❀ 深入理解react中的key
如果你有留意react官方文檔,key的解釋是在介紹list結構時所強調的概念,這也證明了key對於非list結構並不重要(一般我們直接不加key),這也說明在源碼層diff一定會對於是否是list做邏輯區分,簡單點來說,針對非list的源碼邏輯處理,你加不加key一點也不重要。
老實說,上文我拋出的五個問題的結論其實是彼此關聯和依賴的,所以在解釋這幾個問題之前,我先給出二個比較核心的結論(後面會從源碼層解釋這個結論):
react對於非list結構的的新舊節點對比確實是逐層對比,但對於list結構且假設添加了獨一無二key時並不一定如此。diff對比是先對比key,若key不同直接重新創建節點,若key相同則再對比type(標籤類型),如果type不同同樣重新創建;因此只有key type都相同時,react才會基於舊節點結合新props生成新節點。
先記住這兩個結論,下文我會連著結論以及上文的問題依次給出解釋。
貳 ❀ 壹 為什麼非list 結構不提供key不會有警告?
站在react設計角度,結合我對於源碼的理解,我來說說我的看法。
我們都知道list的節點始終是動態生成的,每次數據的變更都會導致list需要map生成一份新的列表(宏觀角度確實是重新遍歷生成),站在react的角度,它需要考慮list數據規模大小是否會造成性能問題,所以在diff源碼層才有了當key與type都相同時,react會利用舊fiber節點的數據clone一個新的fiber節點,而不是重新創建一個全新的fiber節點。
// 當diff判斷新舊節點的key與type都相同時,會使用舊fiber節點以及新的props來clone生成一個全新的fiber
function useFiber(fiber, pendingProps) {
var clone = createWorkInProgress(fiber, pendingProps);
clone.index = 0;
clone.sibling = null;
return clone;
}
而對於list結構,在某些情況下react會使用key來快取舊fiber節點便於後續對比,快取的邏輯如下:
function mapRemainingChildren(returnFiber, currentFirstChild) {
// 創建一個map
var existingChildren = new Map();
// 這個是舊fiber節點
var existingChild = currentFirstChild;
// 只要舊fiber節點不會空,就一直遍歷
while (existingChild !== null) {
if (existingChild.key !== null) {
// 如果fiber節點的key不會null,那就通過key==>fiber的形式存起來
existingChildren.set(existingChild.key, existingChild);
} else {
// 假設為null,那就用index==>fiber形式存起來
existingChildren.set(existingChild.index, existingChild);
}
// 將existingChild賦予成當前fiber的兄弟節點,然後繼續while
existingChild = existingChild.sibling;
}
// 返回快取後的map
return existingChildren;
}

但需要注意的是,並不是只要是list結構 react就會利用key快取舊節點。經測試,只有當key獨一無二,且key不相同時才會觸發快取邏輯,比如如下情況:
<!-- 更新前 -->
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
<!-- 更新後 -->
<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
第一次對比時,由於2014 !== 2015,react就會想,你小子是不是在數組前或者數組中間插入了新元素了,為了避免逐層對比導致接下來的每個節點都要重新創建,此時會跳出之前的diff邏輯來到mapRemainingChildren方法,然後把舊節點存在map中,之後再借用map + key來達到舊節點的對比與復用。
而如下例子是在數組之後插入了一個元素,這就導致2015 === 2015,所以react並不會走到快取邏輯,畢竟你key對比就已經相同了,之後判斷type都是li,說明新舊節點可能就只有props不同,那就直接復用更新就好了,沒必要去快取:
<!-- 更新前 -->
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
<!-- 更新後 -->
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
<li key="2016">Connecticut</li>
</ul>
所以來到非list情況,dom結構基本上是穩定的,你很難遇到dom插入新節點的場景,更多變化的是模板語法中的變數或者其它樣式,所以react也根本沒必要利用key去存儲這些不怎麼變化的節點。
而且站在性能優化的角度,第一大忌就是提前的過度優化。你想想,是list有key還能用key,那些非list不提供key你拿null來存嗎?都是null的情況下react怎麼知道誰是誰,難道強硬規定所有dom都需要提供key?而且即便強制開發者都提供key存所有fiber節點,你還需要考慮map對於記憶體佔用以及是否會造成記憶體泄漏的問題,所以想想就知道這樣的設計非常不合理。
一句話總結,對於非list結構很難出現dom經常變動的情況,逐層對比就已經滿足新舊節點對比的需求;而對於list結構數據會經常變動,當頭部或中部插入新數據時,逐層對比會因為對比錯位而失效,所以需要key來快取舊節點,從而借用map修正逐層對比。
貳 ❀ 貳 react的key真的有那麼聰明嗎?
針對官方所給的例子,假設數組前添加了一個元素,通過key react能知道只用新增一個,其它都只是移動了位置的結論,我更傾向於react需要考慮react初學者,且為了凸顯key的作用,所以描述上顯得key非常智慧,但事實上並不是如此。
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
// 獲取新虛擬dom的key
var key = element.key;
// 舊有的div fiber節點
var child = currentFirstChild;
// 遍歷當前節點的以及它的所有兄弟節點,注意下面的child = child.sibling,不為空就一直遍歷對比
while (child !== null) {
// 對於非list這裡都是null,也相等
if (child.key === key) {
switch (child.tag) {
default:
{
// 只有元素的type類型也相等時,才會走更新fiber的邏輯
if (child.elementType === element.type || (
isCompatibleFamilyForHotReloading(child, element) )) {
deleteRemainingChildren(returnFiber, child.sibling);
// 根據新的虛擬dom的props來更新舊有fiber節點
var _existing3 = useFiber(child, element.props);
// ....
}
break;
}
}
deleteRemainingChildren(returnFiber, child);
break;
} else {
// 如果key不相等,直接在父節點上把自己整個都刪掉
deleteChild(returnFiber, child);
}
// 將兄弟節點賦予child,繼續走while遍歷
child = child.sibling;
}
}
react並不能根據key相同就能斷定舊有節點只是移動了,最簡單推翻這個結論的例子就是key相同但type不同,比如:
<!-- 更新前 -->
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
<!-- 更新後 -->
<div>
<span key="2014">Connecticut</span>
<span key="2015">Duke</span>
<span key="2016">Villanova</span>
</div>
在前面的diff過程我們也說了,因為list對比某些情況還會借用key來快取舊fiber節點,它起到一個標誌作用,比較完key還是需要比較type是否相同,即便type相同我們還不能保證props是否相同,只要你能走到diff這一步,必定是key、type或者props某一個變了,就一定得更新fiber節點,這是毋庸置疑的,所以根本就不存在diff過程中直接完整復用舊節點的說法。
官方的對於舊節點只是移動了其實具有一定的誤導性,源碼層還是走了clone邏輯,只是相對重新創建代價更小。
貳 ❀ 叄 list 頭部插入新元素的diff過程
針對第三個問題,前文也已經說過了,react對於list的diff不一定是逐層的,當你沒提供key,或者key提供的是index,這會導致前後節點的key始終相等,從而繼續判斷type來決定是否更新復用舊fiber節點。
而當list對比且key不同時(數組頭部或者中間插入元素時),react會先聲明一個map然後以此利用key依次快取舊fiber節點,之後再根據新的虛擬dom節點的順序,通過key從這個map里獲取舊fiber節點,如果能獲取到,那就看看type是否相同,依次判斷是否能用舊fiber節點進行更新;如果通過key從map獲取不到,那說明這個節點就是一個全新的,直接重新創建。
說到底,key確實起到了標記的作用,但它的標記更多針對的是數組頭部或者數組中間插入新數據的場景,只要key不同了,react就知曉不能繼續逐層對比了,不然接下來肯定的key肯定會全部不同導致全部重新創建,因此才能根據key的獨一無二建立舊fiber的map,並以此更新那些因插入導致原有對比順序被打亂的舊節點。
接下來給大家展示下當數組頭部插入新元素list對比的部分源碼,大家可以結合上文在數組頭部插入key=2014的例子來理解:
// 當子元素是數組時,會進入此方法進行diff
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
// mapRemainingChildren的源碼上面解釋過了,定義map根據key依次快取舊節點,注意,只有頭部或者中部插入元素,才會觸發這裡的邏輯
var existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 遍歷新的虛擬dom節點
for (; newIdx < newChildren.length; newIdx++) {
// 通過遍歷新虛擬dom節點,依次更新舊map存儲的節點,具體定義如下
var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
// 刪除部分不影響理解的邏輯
}
比如我們在數組前塞了一個key=2014的新節點,react在第一次對比是,發現2014! == 2015,外加上這塊又是數組diff的邏輯,所以react會猜測你是不是在數組前面或者中間插入了元素,從而導致key不同,因此才會調用mapRemainingChildren提前把舊fiber存入map。
結合例子,那麼此時的newChildren就是三個虛擬dom,然後依次遍歷,與mapRemainingChildren返回的map節點做對比更新。緊接著我們來看updateFromMap的實現:
// updateFromMap具體實現
function updateFromMap(existingChildren, returnFiber, newIdx, newChild, lanes) {
// 如果新虛擬節點類型是數字或者字元串,走updateTextNode更新文本的邏輯
if (typeof newChild === 'string' || typeof newChild === 'number') {
var matchedFiber = existingChildren.get(newIdx) || null;
return updateTextNode(returnFiber, matchedFiber, '' + newChild, lanes);
}
// 如果新節點是對象類型
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
{
// 利用key(可能是key也可能是index)從map中獲取對應的舊fiber節點
var _matchedFiber = existingChildren.get(newChild.key === null ? newIdx : newChild.key) || null;
// 更新舊fiber節點
return updateElement(returnFiber, _matchedFiber, newChild, lanes);
}
}
}
return null;
}
這個方法做的事情也很簡單,判斷新節點的類型,是數字或者字元串,那就走文本更新的方法,反之就走更新對象的方法。而在對象更新中,我們看到了existingChildren.get()的邏輯,react通過key來獲取舊的fiber節點,之後又通過updateElement來做進一步的更新:
function updateElement(returnFiber, current, element, lanes) {
// 判斷舊fiber節點是否存在,存在就更新舊fiber節點,否則那就重新創建
if (current !== null) {
// 判斷元素類型是否相同,比如前後都是li節點,證明dom類型沒變,而
if (current.elementType === element.type || (
isCompatibleFamilyForHotReloading(current, element) )) {
// 根據新的props更新舊有的fiber節點
var existing = useFiber(current, element.props);
existing.ref = coerceRef(returnFiber, current, element);
existing.return = returnFiber;
return existing;
}
} // Insert
// 當舊fiber節點不存在時,既然對比不了,那就直接重新創建了
var created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, current, element);
created.return = returnFiber;
return created;
}
在updateElement中我們看到了針對是否能從map中獲取到舊節點的不同處理,比如key=2014在map很顯然就找不到,這就導致了current是null,於是就走了下面的createFiberFromElement方法完全重新創建。
而當key是2015或者2016時,因為current就是之前的舊fiber節點,於是走了var existing = useFiber(current, element.props)舊節點更新邏輯,而不是重新創建。
貳 ❀ 肆 既然null===null,為什麼還需要key?
其實說到這裡,我想大家對於這個問題應該也有了一定的理解。對於非list結構而言,確實是否提供key並無重要,反正大家都是逐層對比;而對於list而言,當存在數組頭部或中間插入元素時,假設大家提供index作為key或者不提供key,都會導致新舊節點的key全部相等。這就導致了已經錯位的節點強行逐層對比,本應該新建的節點因為key相同而走了更新,本應該更新的節點因為key相同結果走了新建。
貳 ❀ 伍 為什麼不推薦使用index做為key?
理由在第四個問題已經回答過了,而且核心問題是因為本應該新建的結果你只做了更新,這種情況甚至還能導致bug。官方在介紹key時也給了一個導致bug的例子,我們結合源碼來深究為什麼使用index導致了這個bug。
例子程式碼如下:
class Item extends React.Component {
render() {
return (
<div>
<div>
<input type="text" />
</div>
</div>
);
}
}
class Example extends React.Component {
constructor() {
super();
this.state = {
list: [
{ name: "聽風是風", id: 1 },
{ name: "行星飛行", id: 2 },
],
};
}
addItem = () => {
const id = +new Date();
this.setState({
list: [{ name: "時間跳躍" + id + id, id }, ...this.state.list],
});
};
render() {
return (
<div className="example">
<button onClick={this.addItem}>clie me</button>
<div className="form">
<form>
<h3>
不好的做法 <code>key=index</code>
</h3>
{this.state.list.map((todo, index) => (
<Item {...todo} key={index} />
))}
</form>
<form>
<h3>
更好的做法 <code>key=id</code>
</h3>
{this.state.list.map((todo) => (
<Item {...todo} key={todo.id} />
))}
</form>
</div>
</div>
);
}
}

簡單來說,我們分別使用index以及獨一無二的id作為key,然後我們分別在兩個form中的第一個input屬於一個值,之後點擊按鈕,分別在數組前插入了一個新數據,然後區別就出現了,index的例子並沒有按照預期完整重新創建一個input,這個1本應該屬於第二個input。
那麼為什麼造成了這個bug呢?原因其實很簡單,當使用了index作為key時,我們前文也說了,這個input就應該重新創建,結果你用index,0===0為true,type又相同,所以diff直接認為這是一次更新而不是重新創建。
在虛擬dom一文中,我們強調了虛擬dom為局部刷新提供了可能性,因為原生dom屬性非常多,如果遞歸去對比就格外複雜了,但虛擬dom設計直接將我們需要對比的屬性都聚焦在了props中,所以即便diff去更新props也只是更新虛擬dom的props,像上文中的input本身就是一個原生dom,它的vaule根本就不在diff比較的範疇內。
而前面也說了,因為index的緣故diff會認為你只是更新,在fiber節點中有一個stateNode欄位保存了對應真實dom的屬性,所以diff在clone節點時,直接將之前的stateNode賦值給了更新後的fiber節點,這就導致了這個1依舊停留在了第一個input上。

上圖就是當第一個fiber更新完成之後,通過stateNode訪問到input的value,這就是為啥導致這個bug的原因。
我們通過兩張圖來描述當數組前插入元素時,使用index或者不提供key默認null時,與使用獨一無二key的diff差異:


叄 ❀ 總
那麼到這裡,我們解釋了文章開頭的五個問題,也通過源碼解開了key的神秘面紗。簡單點來說,key並沒有大家所想的那麼聰明,但對於list的diff而言又極其重要,react的diff始終遵守逐層對比,也正因為key的存在,不管list如何改變順序,只有key獨一無二,react總是能正確的去更新或者新建它們,這才是key存在的核心意義。
那麼到這裡,關於key的介紹到此結束。

