詳解 Diff 演算法以及迴圈要加 key 值問題
上一篇文章我簡述了什麼是 Virtual DOM,這一章我會詳細講 Diff
演算法以及為什麼在 React
和 Vue
中迴圈都需要 key 值。
什麼是 DOM Diff 演算法
Web 介面其實就是一個 DOM 樹的結構,當其中某個部分發生變化的時候,實質上就是對應的某個 DOM 節點發生了變化。而在 React/Vue 中,都採用了 Virtual DOM 來模擬真實的樹結構,他們都擁有兩個 Virtual DOM,一顆是真實 DOM 結構的對映,另一顆則是改動後生成的 Virtual DOM,然後利用高效的 Diff 演算法來遍歷分析新舊 Virtual DOM 結構的差異,最後 Patch 不同的節點。
但是給定兩個 Virtual DOM,利用標準的 Diff 演算法肯定是不行的,使用傳統的 Diff 演算法通過迴圈遞迴遍歷節點進行對比,其複雜度要達到O(n^3),其中 n 是節點總數,效率十分低下,假設我們要展示 1000 個節點,那麼我們就要依次執行上十億次的比較,這肯定無法滿足效能要求。
這裡附上一則傳統的 Diff 演算法:
// 儲存比較結果 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 樹結構特點做了以下優化。
- Tree Diff(層級的比較)
- Component Diff(元件的比較)
- Element Diff(元素/節點的比較)
簡單來說就是兩個概念:
- 相同元件會產生類似的 DOM 結構,不同的元件產生的 DOM 結構也不同
- 同一層級的子節點,可以通過唯一的 id 來進行區分
Tree Diff 層級的比較(樹結構的比較)
利用一張常見的圖可以完全看出 Tree Diff 的比較規則:
左右兩棵樹,分別為舊樹和新樹,先進行樹結構的層級比較,並且只會對相同顏色方框內的 DOM 節點進行比較,即同一個父節點下的所有子節點。
如果有任一一個節點不匹配,則該節點和其子節點就會被完全刪除,不會繼續遍歷。
基於這個策略,演算法複雜度降低為O(n)。
這時有同學要問了,那如果我想移動一個節點到另一個節點下,即跨層級操作,DIff 會怎樣表現呢?
如下圖所示:
以 C 為根節點,整棵樹會被新建立,而不是簡單的移動,建立的流程為 create C
->`create F
->create G
->delete C
。
這是一種很影響效能的操作,官方建議不要進行DOM節點跨層級操作,可以通過CSS隱藏、顯示節點,而不是真正地移除、新增DOM節點。
注意:在開發元件時,保持穩定的 DOM 結構會有助於效能的提升。例如,可以通過 CSS 隱藏或顯示節點,而不是真的移除或新增 DOM 節點。
Component Diff 元件比較
React
/Vue
是基於元件構建應用的,對於元件間的比較所採用的策略也是非常簡潔和高效的。
對此,有以下三種策略:
- 同類型元件(即:兩節點是同一個元件類的兩個不同例項)
- 若元件相同,則繼續按照層級比較其 Virtual DOM 的結構。
- 若元件 A 轉變為元件 B,但是元件 A 和元件 B 渲染出來的 Virtual DOM 沒有任何變化(即,子節點的順序、狀態state等,都未發生變化),如果開發者能夠提前知道這一點,那麼可以省下大量 Diff 的時間。React 中,允許使用者通過
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 改變,繼而進行元件的更新。
Element Diff 元素的比較(同一層級同一父元素下的節點集合,進行比較)
當 DOM 處於同一層級時,Diff 提供三個節點操作,即 刪除(REMOVE_NODE)、插入(INSERT_MARKUP)、移動(MOVE_EXISTING)。
刪除(REMOVE_NODE)
舊元件型別,在新集合裡也有,但對應的element
不同則不能直接複用和更新,需要執行刪除操作,或者舊元件不在新集合裡的,也需要執行刪除操作。
如圖所示:
插入(INSERT_MARKUP)
新的元件型別不在舊集合中,即全新的節點,需要對新節點進行插入操作。
如圖所示:
移動(MOVE_EXISTING)
舊集合中有新元件型別,且element
是可更新的型別,這時候就需要做移動操作,可以複用以前的DOM節點。
沒有 Key 值的問題
如下圖,老集合中包含節點:A、B、C、D,更新後的新集合中包含節點:B、A、D、C,此時新老集合進行 diff 差異化對比,發現 B != A,則建立並插入 B 至新集合,刪除老集合 A;以此類推,建立並插入 A、D 和 C,刪除 B、C 和 D。
React 發現這類操作繁瑣冗餘,因為這些都是相同的節點,但由於位置發生變化,導致需要進行繁雜低效的刪除、建立操作,其實只要對這些節點進行位置移動即可。
針對這一現象,React 提出優化策略:允許開發者對同一層級的同組子節點,新增唯一 key 進行區分,雖然只是小小的改動,效能上卻發生了翻天覆地的變化!
為什麼迴圈需要新增唯一 Key值
給元素加了 Key 值之後,React/Vue 在做 Diff 的時候會進行差異化對比,即通過 key 發現新老集合中的節點都是相同的節點,因此無需進行節點刪除和建立,只需要將老集合中節點的位置進行移動,更新為新集合中節點的位置,此時 React 給出的 diff 結果為:B、D 不做任何操作,A、C 進行移動
操作,即可。
那麼,如此高效的 diff 到底是如何運作的呢?
簡單來說有以下幾步:
對新集合的節點進行遍歷,通過唯一 key 可以判斷新老集合中是否存在相同節點。
如果存在相同節點,則進行移動操作,但在移動前,需要將當前節點在老集合中的位置與 lastIndex 進行比較,如果不同,則進行節點移動,否則不執行該操作。
這是一種順序優化手段,lastIndex 一直在更新,表示訪問過的節點在老集合中最右的位置(即最大的位置),如果新集合中當前訪問的節點比 lastIndex 大,說明當前訪問節點在老集合中就比上一個節點位置靠後,則該節點不會影響其他節點的位置,因此不用新增到差異佇列中,即不執行移動操作,只有當訪問的節點比 lastIndex 小時,才需要進行移動操作。
這裡給出一整圖作為示例。
如上圖所示,以新樹為迴圈基準:
- B 在老集合的下標為
BIndex=1
,此時lastIndex=0
,這時,lastIndex < BIndex
,不進行任何處理,並且取值lastIndex=Math.max(BIndex, lastIndex)
- A 在老集合的下標為
AIndex=0
,此時lastIndex=1
,這時,lastIndex > AIndex
,這時,需要把老樹中的 A 移動到下標為lastIndex
的位置,並且取值lastIndex=Math.max(AIndex, lastIndex)
- D 在老集合的下標為
DIndex=3
,此時lastIndex=1
,這時,lastIndex < DIndex
,不進行任何處理,並且取值lastIndex=Math.max(DIndex, lastIndex)
- C 在老集合的下標為
CIndex=2
,此時lastIndex=3
,這時,lastIndex > CIndex
,需要把老樹中的 C 移動到下標為lastIndex
的位置,並且取值lastIndex=Math.max(CIndex, lastIndex)
- 由於 C 已經是最後一個節點,因此 Diff 至此結束。
以上主要分析新老集合中存在相同節點但位置不同時,對節點進行位置移動的情況,如果新集合中有新加入的節點且老集合存在需要刪除的節點,那麼 React diff 又是如何對比運作的呢?
以此圖為例:
同上的流程:
- B 同上流程
- 老集合中沒有 E 集合,則判斷老集合中不存在相同節點 E,則建立新節點 E,更新 lastIndex = 1,並將 E 的位置更新為新集合中的位置。
- C 同上
- A 同上
- 當完成集合 Diff 時,最後還需要對老集合進行迴圈遍歷,判斷是否存在新集合中沒有但老集合中仍然存在的節點,如果有,則刪除,迴圈發現,D 就是這樣的節點,因此刪除 D,完成 Diff。
這種迴圈方式,眼尖的讀者會發現一個問題,如果是集合的首尾位置互換,那開銷就大了。
如上圖所示,此時的 DIff 演算法,會將 A,B,C 全部移動到 D 的後面,造成大量DOM 的移動,而實際上我們只需要將 D 移動到集合的頭部僅一次即可。
由此可看出,在開發過程中,儘量減少類似將最後一個節點移動到列表首部的操作,當節點數量過大或更新操作過於頻繁時,在一定程度上會影響 React 的渲染效能。
沒有 key 值的更新問題
除了上述不新增 Key 值會造成整個集合刪除再新增,不會進行移動 DOM 操作,導致大量無謂的開銷外,但是結合上述 Component Diff 聯想,如果 A、B、C、D都是同類型元件且不加 Key 值會發生什麼情況呢?
我們看圖說話:
這是 Vue 的:
這是 React 的:
我們發現,無論是 React 還是 Vue 刪除了第二項之後,第三項列表內部的 state 仍然沿用的第二個列表的內容。
這是因為,React/Vue 判斷是變化前後是同類型元件,並且 props 的內容並沒有改變,不會觸發改變。
其流程如下:
- 既然 1 沒有變,那麼就就地複用之前的 1
- 既然 2 變成了 3,裡面的子孫元素就地複用。有人不理解為什麼子孫元素就地複用,那麼是因為子孫元素的 data/state 屬性不受 2 變成 3 的影響
- 既然 3 沒了,那麼連其子孫元素全部刪除
破解方法就是加上唯一的 key,讓 Diff 知道就算是同類型的元件,也是有名字區分的。
在做動態改變的時候,儘量不要使用 index 作為迴圈的 key,如果你用 index 作為 key,那麼在刪除第二項的時候,index 就會從 1,2,3 變為 1,2(而不是 1,3),那麼仍有可能引起更新錯誤。
總結
- React/Vue 的 DIff 策略使遍歷複雜度降低為 O(n),是一個重大的優化
- React/Vue 在做迴圈時,一定要加上唯一的 key 值,這樣不僅能有效提高 Diff 效率,減少 DOM 的重繪,還能避免一些稀奇古怪的錯誤
- 儘量減少跨層級的元件改動,儘量使用 v-show/display:none 來保持 DOM 結構的穩定性,防止新增、刪除等消耗大量效能的操作
- 儘量減少將節點尾部移動到節點頭部等操作,當節點數量過大或更新操作過於頻繁時,在一定程度上會影響 React 的渲染效能。
- 另外,React 從 16 版本開始使用了 Fiber 架構,這個架構解決了大型 React 專案的效能問題及一些之前框架的痛點,我會在下一章詳細介紹 Fiber 架構的奧祕和其與之前架構的區別
文章最後,如大家有興趣入小程式的坑,不妨試試用 React 方式書寫小程式的框架 Taro,我以此為基礎做出一套多端 UI 框架MP-ColorUI,大家感興趣可以去 Github star 一下,下面是小程式演示版本。
相關推薦
詳解 Diff 演算法以及迴圈要加 key 值問題
上一篇文章我簡述了什麼是 Virtual DOM,這一章我會詳細講 Diff 演算法以及為什麼在 React 和 Vue 中迴圈都需要 key 值。 什麼是 DOM Diff 演算法 Web 介面其實就是一個 DOM 樹的結構,當其中某個部分發生變化的時候,實質上就是對應的某個 DOM 節點發生了變化。而在
JVM類加載機制詳解(一)JVM類加載過程
進行 虛擬機啟動 類加載的時機 bsp 參與 tro ext 環境 java代碼 首先Throws(拋出)幾個自己學習過程中一直疑惑的問題: 1、什麽是類加載?什麽時候進行類加載? 2、什麽是類初始化?什麽時候進行類初始化? 3、什麽時候會為變量分配內存? 4、什麽時候會為
JDBC詳解系列(二)之加載驅動
red mar mys ons try path 替換 host man ---[來自我的CSDN博客](http://blog.csdn.net/weixin_37139197/article/details/78838091)--- ??在JDBC詳解系列(一)之流程中
詳解Mysql5.5以及5.7版本忘記管理員密碼處理機制
rest entos 之前 密碼重置 emc type hello mys 相關 簡介 使用Mysql時,如果忘記了其他用戶的密碼,可以使用root用戶重新設置,但是如果忘記了root的密碼,就需要采用下面的操作進行處理 實驗環境 系統環境:centos7.4 服務器IP
(轉載)KMP演算法詳解 (原創)詳解KMP演算法
轉自https://www.cnblogs.com/yjiyjige/p/3263858.html (原創)詳解KMP演算法 作者:孤~影 KMP演算法應該是每一本《資料結構》書都會講的,算是知名度最高的演算法之一了,但很可惜,我大二那年壓根就沒看懂過~~~ 之後也在很多地方也都經常看
一文詳解“工廠模式”以及python語言的實現
一、什麼是“工廠模式”——factory pattern 工廠模式,也稱之為“簡單工廠模式”或者是“靜態工廠模式” 工廠模式(Factory Pattern)是 程式設計中 中最常用的設計模式之一。這種型別的設計模式屬於建立型模式,它提供了一種建立物件的最佳方式。在工廠模式
詳解B+tree以及mysql的索引原理 一
最近在學mysq的索引,網上查了很多資料但都沒有很好理解的,現在先講講b+tree 動態查詢樹主要有:二叉查詢樹(Binary Search Tree),平衡二叉查詢樹(Balanced Binary Search Tree),紅黑樹 (Red-Black Tree )
詳解softmax函式以及相關求導過
注意:圖片看不到,直接進知乎吧https://zhuanlan.zhihu.com/p/25723112 這幾天學習了一下softmax啟用函式,以及它的梯度求導過程,整理一下便於分享和交流! softmax函式 softmax用於多分類過程中,它將多個神經元的輸出,
Java中Class類詳解、用法以及泛化
在前面我們將類的載入的時候,類載入一共有5步,載入,驗證,準備,解析和初始化。其中載入階段,除了將位元組碼載入到方法區,還生成了這個了的Java.lang.Class物件。那麼這個Class物件到底有什麼用呢? 前面的關於反射的文章,我們多次都用到了Class類,可以用這個
詳解spl_autoload_register()函式以及自動載入不同目錄的類
在瞭解spl_autoload_register()函式之前,先來看另一個函式:__autoload。 一、__autoload 這是一個自動載入函式,在PHP5中,當我們例項化一個未定義的
詳解Dijkstra演算法(含數學證明和優化)
Dijkstra演算法簡介: Dijkstra演算法是由荷蘭電腦科學家Edsger Wybe Dijkstra於1959年提出的一種解決有向加權圖中單源最短路問題的演算法,其中要求加權圖中不可有負權邊。 Dijkstra演算法步驟演示: 以如下的一張
聚類之詳解FCM演算法原理及應用
【之前】 該文的pdf清晰版已被整理上傳,方便儲存學習,下載地址: (一)原理部分 模糊C均值(Fuzzy C-means)演算法簡稱FCM演算法,是一種基於目標函式的模糊聚類演算法,主要用於資料的聚類分析。理論成熟,應用廣泛,是一種優秀的聚類演算法。本
DFT和FFT詳解(演算法導論學習筆記)
程式碼均為做嚴格測試,僅供參考 分治法基本原理 將原問題分解為幾個規模較小但類似於原問題的子問題,遞迴的求解這些子問題。然後再合併這些子問題的解來建立原問題的解。遞迴求解這些子問題,然後再合併這些子問題的解來建立原問題的解。 分治法在分層遞迴時都有三個步驟
詳解PAXOS演算法1
Paxos演算法的難理解與演算法的知名度一樣令人敬仰,從我個人的經歷而言,難理解的原因並不是該演算法高深到大家智商不夠,而在於Lamport在表達該演算法時過於晦澀且缺乏一個完整的應用場景。如果大師能換種思路表達該演算法,大家可能會更容易接受: 首先提出演算法適用的場景,
mybatis如何實現批量更新和插入新增例項詳解(附SQL以及mapper配置)
Mybatis批量插入、批量修改 批量插入 step1:建立DB表 CREATE TABLE `student_info` ( `STUDENT_ID` BIGINT(20) NOT NULL A
[機器學習]詳解分類演算法--決策樹演算法
前言 演算法的有趣之處在於解決問題,否則僅僅立足於理論,便毫無樂趣可言; 不過演算法的另一特點就是容易嚇唬人,又是公式又是圖示啥的,如果一個人數學理論知識過硬,靜下心來看,都是可以容易理解的,紙老虎一個,不過這裡的演算法主要指的應用型演算法
Java虛擬機器詳解----GC演算法和種類【重要】
轉載自:http://www.cnblogs.com/smyhvae/p/4744233.html 本文主要內容: GC的概念GC演算法 引用計數法(無法解決迴圈引用的問題,不被java採納) 根搜尋演算法 現代虛擬機器中的垃圾蒐集演算法:
CentOS 7中firewall防火牆詳解和配置以及切換為iptables防火牆
原文連結:http://blog.csdn.net/xlgen157387/article/details/52672988 一、firewall介紹 CentOS 7中防火牆是一個非常的強大的功能,在CentOS 6.5中在iptables防火牆中進行了升級了。
詳解LOG4J2配置以及與slf4j的整合
注:轉載自(原文檢視),相關部分略有改動,更加詳細(其它) 一、背景 最近由於專案的需要,我們把log4j 1.x的版本全部遷移成log4j 2.x 的版本,那隨之而來的slf4j整合log4j的配置(使用Slf4j整合Log4j2構建專案日誌系統的完美解決方案)以及
詳解BitMap演算法
所謂的BitMap就是用一個bit位來標記某個元素所對應的value,而key即是該元素,由於BitMap使用了bit位來儲存資料,因此可以大大節省儲存空間。 1. 基本思想 首先用一個簡單的例子來詳細介紹BitMap演算法的原理。假設我們要對0-7內的5個元素(4,7,2,5,3)進行排序(這裡假設元素沒有