我在【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
的解釋是在介紹list
結構時所強調的概念,這也證明了key
對於非list
結構並不重要(一般我們直接不加key
),這也說明在原始碼層diff
一定會對於是否是list
做邏輯區分,簡單點來說,針對非list
的原始碼邏輯處理,你加不加key
一點也不重要。
老實說,上文我丟擲的五個問題的結論其實是彼此關聯和依賴的,所以在解釋這幾個問題之前,我先給出二個比較核心的結論(後面會從原始碼層解釋這個結論):
react
對於非list
結構的的新舊節點對比確實是逐層對比,但對於list
結構且假設新增了獨一無二key
時並不一定如此。diff
對比是先對比key
,若key
不同直接重新建立節點,若key
相同則再對比type
(標籤型別),如果type
不同同樣重新建立;因此只有key type
都相同時,react
才會基於舊節點結合新props
生成新節點。先記住這兩個結論,下文我會連著結論以及上文的問題依次給出解釋。
站在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
修正逐層對比。
針對官方所給的例子,假設陣列前新增了一個元素,通過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
邏輯,只是相對重新建立代價更小。
針對第三個問題,前文也已經說過了,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)
舊節點更新邏輯,而不是重新建立。
其實說到這裡,我想大家對於這個問題應該也有了一定的理解。對於非list
結構而言,確實是否提供key
並無重要,反正大家都是逐層對比;而對於list
而言,當存在陣列頭部或中間插入元素時,假設大家提供index
作為key
或者不提供key
,都會導致新舊節點的key
全部相等。這就導致了已經錯位的節點強行逐層對比,本應該新建的節點因為key
相同而走了更新,本應該更新的節點因為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
的介紹到此結束。