上一篇文章我簡述了什麼是 Virtual DOM,這一章我會詳細講 Diff
算法以及爲何在 React
和 Vue
中循環都須要 key 值。html
Web 界面其實就是一個 DOM 樹的結構,當其中某個部分發生變化的時候,實質上就是對應的某個 DOM 節點發生了變化。而在 React/Vue 中,都採用了 Virtual DOM 來模擬真實的樹結構,他們都擁有兩個 Virtual DOM,一顆是真實 DOM 結構的映射,另外一顆則是改動後生成的 Virtual DOM,而後利用高效的 Diff 算法來遍歷分析新舊 Virtual DOM 結構的差別,最後 Patch 不一樣的節點。node
可是給定兩個 Virtual DOM,利用標準的 Diff 算法確定是不行的,使用傳統的 Diff 算法經過循環遞歸遍歷節點進行對比,其複雜度要達到O(n^3),其中 n 是節點總數,效率十分低下,假設咱們要展現 1000 個節點,那麼咱們就要依次執行上十億次的比較,這確定沒法知足性能要求。react
這裏附上一則傳統的 Diff 算法:git
// 存儲比較結果
let result = []
// 比較兩棵樹
const diffTree = function (beforeTree, afterTree) {
// 獲取較大樹的 長度
let count = Math.max(beforeTree.children.length, afterTree.children.length)
// 進行循環遍歷
for (let i = 0; i < count; i++) {
const beforeChildren = beforeTree.children[i]
const afterChildren = afterTree.children[i]
// 若是原樹沒有,新樹有,則添加
if (beforeChildren === undefined) {
result.push({
type: 'add',
element: afterChildren
})
// 若是原樹有,新樹沒有,則刪除
} else if (afterChildren === undefined) {
result.push({
type: 'remove',
element: beforeChildren
})
// 若是節點名稱對應不上,則刪除原樹節點並添加新樹節點
} else if (beforeChildren.tagName !== afterChildren.tagName) {
result.push({
type: 'remove',
elevation: beforeChildren
})
result.push({
type: 'add',
element: beforeChildren
})
// 若是節點名稱同樣,但內容改變,則修改原樹節點的內容
} else if (beforeChildren.innerTHML !== afterChildren.innerTHML) {
// 若是沒有其餘子節點,則直接改變
if (beforeChildren.children.length === 0) {
result.push({
type: 'changed',
beforeElement: beforeChildren,
afterElement: afterChildren,
html: afterChildren.innerTHML
});
} else {
// 不然進行遞歸比較
diffTree(beforeChildren, afterChildren)
}
}
}
// 最後返回結果
return result
}
複製代碼
然而優化事後的 Diff 算法的複雜度只有O(n),這歸結於 DIff 算法的優化,工程師們將 Diff 算法根據實際 DOM 樹結構特色作了如下優化。github
簡單來講就是兩個概念:算法
利用一張常見的圖能夠徹底看出 Tree Diff 的比較規則:小程序
左右兩棵樹,分別爲舊樹和新樹,先進行樹結構的層級比較,而且只會對相同顏色方框內的 DOM 節點進行比較,即同一個父節點下的全部子節點。數組
若是有任一一個節點不匹配,則該節點和其子節點就會被徹底刪除,不會繼續遍歷。架構
基於這個策略,算法複雜度下降爲O(n)。框架
這時有同窗要問了,那若是我想移動一個節點到另外一個節點下,即跨層級操做,DIff 會怎樣表現呢?
以下圖所示:
以 C 爲根節點,整棵樹會被新建立,而不是簡單的移動,建立的流程爲 create C
->``create F->
create G->
delete C`。
這是一種很影響性能的操做,官方建議不要進行DOM節點跨層級操做,能夠經過CSS隱藏、顯示節點,而不是真正地移除、添加DOM節點。
注意:在開發組件時,保持穩定的 DOM 結構會有助於性能的提高。例如,能夠經過 CSS 隱藏或顯示節點,而不是真的移除或添加 DOM 節點。
React
/Vue
是基於組件構建應用的,對於組件間的比較所採用的策略也是很是簡潔和高效的。
對此,有如下三種策略:
shouldComponentUpdate()
來判斷該組件是否須要進行diff
算法分析。dirty component
,繼而替換整個組件的全部內容。注意:
若是組件 A 和組件 B 的結構類似,可是 React 判斷是 不一樣類型的組件,則不會比較其結構,而是刪除組件 A 及其子節點,建立組件 B 及其子節點。
舉個栗子:就算組件 D 和組件 G 的結構如出一轍,可是改變時仍然會刪除而且從新建立。
![]()
Component Diff 只會比較同組節點集合的內容是否改變。即,若舊樹裏,A 有 B 和 C 兩個節點,新樹裏 A 有 C 和 B 兩個節點,不管 B 和 C 的位置是否改變,都會認爲 component 層未改變。可是若 A 裏的 state 發生了改變,則會認爲 component 改變,繼而進行組件的更新。
當 DOM 處於同一層級時,Diff 提供三個節點操做,即 刪除(REMOVE_NODE)、插入(INSERT_MARKUP)、移動(MOVE_EXISTING)。
舊組件類型,在新集合裏也有,但對應的element
不一樣則不能直接複用和更新,須要執行刪除操做,或者舊組件不在新集合裏的,也須要執行刪除操做。
如圖所示:
新的組件類型不在舊集合中,即全新的節點,須要對新節點進行插入操做。
如圖所示:
舊集合中有新組件類型,且element
是可更新的類型,這時候就須要作移動操做,能夠複用之前的DOM節點。
以下圖,老集合中包含節點:A、B、C、D,更新後的新集合中包含節點:B、A、D、C,此時新老集合進行 diff 差別化對比,發現 B != A,則建立並插入 B 至新集合,刪除老集合 A;以此類推,建立並插入 A、D 和 C,刪除 B、C 和 D。
React 發現這類操做繁瑣冗餘,由於這些都是相同的節點,但因爲位置發生變化,致使須要進行繁雜低效的刪除、建立操做,其實只要對這些節點進行位置移動便可。
針對這一現象,React 提出優化策略:容許開發者對同一層級的同組子節點,添加惟一 key 進行區分,雖然只是小小的改動,性能上卻發生了翻天覆地的變化!
給元素加了 Key 值以後,React/Vue 在作 Diff 的時候會進行差別化對比,即經過 key 發現新老集合中的節點都是相同的節點,所以無需進行節點刪除和建立,只須要將老集合中節點的位置進行移動,更新爲新集合中節點的位置,此時 React 給出的 diff 結果爲:B、D 不作任何操做,A、C 進行移動
操做,便可。
那麼,如此高效的 diff 究竟是如何運做的呢?
簡單來講有如下幾步:
對新集合的節點進行遍歷,經過惟一 key 能夠判斷新老集合中是否存在相同節點。
若是存在相同節點,則進行移動操做,但在移動前,須要將當前節點在老集合中的位置與 lastIndex 進行比較,若是不一樣,則進行節點移動,不然不執行該操做。
這是一種順序優化手段,lastIndex 一直在更新,表示訪問過的節點在老集合中最右的位置(即最大的位置),若是新集合中當前訪問的節點比 lastIndex 大,說明當前訪問節點在老集合中就比上一個節點位置靠後,則該節點不會影響其餘節點的位置,所以不用添加到差別隊列中,即不執行移動操做,只有當訪問的節點比 lastIndex 小時,才須要進行移動操做。
這裏給出一整圖做爲示例。
如上圖所示,以新樹爲循環基準:
BIndex=1
,此時 lastIndex=0
,這時,lastIndex < BIndex
,不進行任何處理,而且取值 lastIndex=Math.max(BIndex, lastIndex)
AIndex=0
,此時lastIndex=1
,這時,lastIndex > AIndex
,這時,須要把老樹中的 A 移動到下標爲lastIndex
的位置,而且取值 lastIndex=Math.max(AIndex, lastIndex)
DIndex=3
,此時lastIndex=1
,這時,lastIndex < DIndex
,不進行任何處理,而且取值 lastIndex=Math.max(DIndex, lastIndex)
CIndex=2
,此時lastIndex=3
,這時,lastIndex > CIndex
,須要把老樹中的 C 移動到下標爲lastIndex
的位置,而且取值 lastIndex=Math.max(CIndex, lastIndex)
以上主要分析新老集合中存在相同節點但位置不一樣時,對節點進行位置移動的狀況,若是新集合中有新加入的節點且老集合存在須要刪除的節點,那麼 React diff 又是如何對比運做的呢?
以此圖爲例:
同上的流程:
這種循環方式,眼尖的讀者會發現一個問題,若是是集合的首尾位置互換,那開銷就大了。
如上圖所示,此時的 DIff 算法,會將 A,B,C 所有移動到 D 的後面,形成大量DOM 的移動,而實際上咱們只須要將 D 移動到集合的頭部僅一次便可。
由此可看出,在開發過程當中,儘可能減小相似將最後一個節點移動到列表首部的操做,當節點數量過大或更新操做過於頻繁時,在必定程度上會影響 React 的渲染性能。
除了上述不添加 Key 值會形成整個集合刪除再新增,不會進行移動 DOM 操做,致使大量無謂的開銷外,可是結合上述 Component Diff 聯想,若是 A、B、C、D都是同類型組件且不加 Key 值會發生什麼狀況呢?
咱們看圖說話:
這是 Vue 的:
這是 React 的:
咱們發現,不管是 React 仍是 Vue 刪除了第二項以後,第三項列表內部的 state 仍然沿用的第二個列表的內容。
這是由於,React/Vue 判斷是變化先後是同類型組件,而且 props 的內容並無改變,不會觸發改變。
其流程以下:
破解方法就是加上惟一的 key,讓 Diff 知道就算是同類型的組件,也是有名字區分的。
在作動態改變的時候,儘可能不要使用 index 做爲循環的 key,若是你用 index 做爲 key,那麼在刪除第二項的時候,index 就會從 1,2,3 變爲 1,2(而不是 1,3),那麼仍有可能引發更新錯誤。
2019-08-05 更新:
經評論區的小夥伴提醒,我發現我寫的加 key 能提升 DIff 效率這句話明顯出現問題。
Vue 官方文檔:
key 的特殊屬性主要用在 Vue 的虛擬 DOM 算法,在新舊 nodes 對比時辨識 VNodes。若是不使用 key,Vue 會使用一種最大限度減小動態元素而且儘量的嘗試修復/再利用相同類型元素的算法。使用 key,它會基於 key 的變化從新排列元素順序,而且會移除 key 不存在的元素。
有相同父元素的子元素必須有獨特的 key。重複的 key 會形成渲染錯誤。
React 官方文檔:
key 幫助 React 識別哪些項目已更改,已添加或已刪除。應該爲數組內部的元素賦予鍵,以使元素具備穩定的標識:key 必須在惟一的。
對於這個問題,我總結出下面幾點:
三胞胎站成一排,你怎麼知道誰是老大?
若是老大皮了一會兒,和老三換了一下位置,你又如何區分出來?
給他們掛個牌牌,寫上老大、老2、老三。
這樣就不會認錯了。key就是這個做用。
說到底,key 的做用就是更新組件時判斷兩個節點是否相同。相同就複用,不相同就刪除舊的建立新的。
不加 key 的利弊:
對於 Vue 來講:
對於 React 來講:
文章最後,如你們有興趣入小程序的坑,不妨試試用 React 方式書寫小程序的框架 Taro,我以此爲基礎作出一套多端 UI 框架MP-ColorUI,你們感興趣能夠去 Github star 一下,下面是小程序演示版本。