DOM diff 做爲工程問題,須要具備必定算法思惟,所以常常出如今面試場景中,畢竟這是可貴出如今工程領域的算法問題。前端
不管出於面試目的,仍是深刻學習目的,都有必要將這個問題搞懂,所以前端精讀咱們就專門用一個章節說清楚此問題。git
Dom diff 是全部如今框架必須作的事情,這背後的緣由是,由 Jquery 時代的面向操做過程轉變爲數據驅動視圖致使的。github
爲何 Jquery 時代不須要 Dom diff?由於 Dom diff 交給業務處理了,咱們調用 .append
或者 .move
之類 Dom 操做函數,就是顯式申明瞭如何作 Dom diff,這種方案是最高效的,由於怎麼移動 Dom 只有業務最清楚。面試
但這樣的問題也很明顯,就是業務心智負擔過重,對於複雜系統,須要作 Dom diff 的地方太多,不只寫起來繁瑣,當狀態存在交錯時,面向過程的手動 Dom diff 容易出現狀態遺漏,致使邊界錯誤,就算你沒有寫出 bug,代碼的可維護性也絕對算不上好。算法
解決方案就是數據驅動,咱們只須要關注數據如何映射到 UI,這樣不管業務邏輯再複雜,咱們永遠只須要解決局部狀態的映射,這極大下降了複雜系統的維護複雜度,之前須要一個老手寫的邏輯,如今新手就能作了,這是很是了不得的變化。typescript
但有利也有弊,這背後 Dom diff 就要交給框架來作了,因此是否能高效的作 Dom diff,是一個數據驅動框架可否應用於生產環境的重要指標,接下來,咱們來看看 Dom diff 是如何作的吧。數組
如圖所示,理想的 Dom diff 天然是滴水不漏的複用全部能複用的,實在遇到新增或刪除時,才執行插入或刪除。這樣的操做最貼近 Jquery 時代咱們手寫的 Dom diff 性能。微信
惋惜程序沒法猜到你的想法,想要精確複用就必須付出高昂的代價:時間複雜度 O(n³) 的 diff 算法,這顯然是沒法接受的,所以理想的 Dom diff 算法沒法被使用。app
關於 O(n³) 的由來。因爲左樹中任意節點均可能出如今右樹,因此必須在對左樹深度遍歷的同時,對右樹進行深度遍歷,找到每一個節點的對應關係,這裏的時間複雜度是 O(n²),以後須要對樹的各節點進行增刪移的操做,這個過程簡單能夠理解爲加了一層遍歷循環,所以再乘一個 n。
如圖所示,只按層比較,就能夠將時間複雜度下降爲 O(n)。按層比較也不是廣度遍歷,其實就是判斷某個節點的子元素間 diff,跨父節點的兄弟節點也沒必要比較。框架
這樣作確實很是高效,但代價就是,判斷的有點傻,好比 ac 明明是一個移動操做,卻被誤識別爲刪除 + 新增。
好在跨 DOM 複用在實際業務場景中不多出現,所以這種笨拙出現的頻率實際上很是低,這時候咱們就不要太追求學術思惟上的嚴謹了,畢竟框架是給實際項目用的,實際項目中不多出現的場景,算法是能夠不考慮的。
下面是同層 diff 可能出現的三種狀況,很是簡單,看圖便可:
那麼同層比較是怎麼達到 O(n) 時間複雜度的呢?咱們來看具體框架的思路。
Vue 的 Dom diff 一共 5 步,咱們結合下圖先看前三步:
如圖所示,第一和第二步分別從首尾兩頭向中間逼近,儘量跳過首位相同的元素,由於咱們的目的是 儘可能保證不要發生 dom 位移。
這種算法通常採用雙指針。若是前兩步作完後,發現舊樹指針重合了,新樹還未重合,說明什麼?說明新樹剩下來的都是要新增的節點,批量插入便可。很簡單吧?那若是反過來呢?以下圖所示:
第一和第二步完成後,發現新樹指針重合了,但舊樹還未重合,說明什麼?說明舊樹剩下來的在新樹都不存在了,批量刪除便可。
固然,若是 一、二、三、4 步走完以後,指針還未處理完,那麼就進入一個小小算法時間了,咱們須要在 O(n) 時間複雜度內把剩下節點處理完。熟悉算法的同窗應該很快能反映出,一個數組作一些檢測操做,還得把時間複雜度控制在 O(n),得用一個 Map 空間換一下時間,實際上也是如此,咱們看下圖具體作法:
如圖所示,一、二、三、4 步走完後,Old 和 New 都有剩餘,所以走到第五步,第五步分爲三小步:
e:4 d:3 c:2 h:0
這樣一個數組,下標 0 是新增,非 0 就是移過來的,批量轉化爲插入操做便可。最後一步的優化也很關鍵,咱們不要看見不一樣就隨便移動,爲了性能最優,要保證移動次數儘量的少,那麼怎麼才能儘量的少移動呢?假設咱們隨意移動,以下圖所示:
但其實最優的移動方式是下面這樣:
爲何呢?由於移動的時候,其餘元素的位置也在相對變化,可能作了 A 效果同時,也把 B 效果給知足了,也就是說,找到那些相對位置有序的元素保持不變,讓那些位置明顯錯誤的元素挪動便是最優的。
什麼是相對有序?a c e
這三個字母在 Old 原始順序 a b c d e
中是相對有序的,咱們只要把 b d
移走,這三個字母的位置天然就正確了。所以咱們只須要找到 New 數組中的 最長連續子串。具體的找法能夠看成一個小算法題了,因爲知道每一個元素的實際下標,好比這個例子中,下標是這樣的:
[b:1, d:3, a:0, c:2, e:4]
肉眼看上去,連續自增的子串有 b d
和 a c e
,因爲 a c e
更長,因此選擇後者。
換成程序去作,能夠採用動態規劃,設 dp(i) 爲以第 i 個字符串結尾的最長連續子串長度,一次 O(n) 循環便可。
// dp(i) = num[i] > num[i - 1] ? dp(i - 1) + 1 : 1
假設這麼一種狀況,咱們將 a 移到了 c 後,那麼框架從最終狀態倒推,如何最快的找到這個動機呢?React 採用了 僅右移策略,即對元素髮生的位置變化,只會將其移動到右邊,那麼右邊移完了,其餘位置也就有序了。
咱們看圖說明:
遍歷 Old 存儲 Map 和 Vue 是同樣的,而後就到了第二步遍歷 New,b
下標從原來的 1
變成了 0
,須要左移才行,但咱們不左移,咱們只右移,由於全部右移作完後,左移就等於自動作掉了(前面的元素右移後,本身天然被頂到前面去了,實現了左移的效果)。
同理,c 下標從 2
變成了 1
,須要左移才行,但咱們繼續不動。
a 的下標從 0
變成 2
,終於能夠右移了!
後面的 d、e 下標沒變,就不用動。咱們縱觀總體能夠發現,b 和 c 由於前面的 a 被抽走了,天然發生了左移。這就是用一個右移代替兩個左移的高效操做。
同時咱們發現,這也確實找到了咱們開始提到的最佳位移策略。
那這個算法真的有這麼聰明嗎?顯然不是,這個算法只是歪打誤撞碰對了而已,有用右移替代左移的算法,就有用左移替代右移的算法,既然選擇了右移替代左移,那麼必定丟失了左移代替右移的效率。
何時用左移代替右移效率最高?就是把數組最後一位移到第一位的場景:
顯然左移只要一步,那麼右移就是 n-1 步,在這個例子就是 4 步,咱們看右移算法圖解:
首先找到 e,位置從 4
變成了 0
,但咱們不能左移!因此只能保持不動,悲劇今後開始。
雖然算法已經不是最優了,但該作的仍是要作,其實以前有一個 lastIndex 概念沒有說,由於 e 已經在 4
的位置了,因此再把 a 從 0
挪到 1
已經不夠了,此時 a 應該從 0
挪到 5
。
方法就是記錄 lastIndex = max(oldIndex, newIndex)
=> lastIndex = max(4, 0)
,下一次移動到 lastIndex + 1
也就是 5
:
發現 a 從 0
變成了 5
(注意,此時考慮到 lastIndex 因素),因此右移。
同理,b、c、d 也同樣。咱們最後發現,發生了 4 次右移,e 也由於天然左移了 4 次到達了首位,符合預期。
因此這是一個有利有弊的算法。新增和刪除比較簡單,和 Vue 差很少。
PS:最新版 React Dom diff 算法若有更新,歡迎在評論區指出,由於這種算法看來不如 Vue 的高效。
Dom diff 總結有這麼幾點考慮:
討論地址是: 精讀《DOM diff 原理詳解》· Issue #308 · dt-fe/weekly
若是你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公衆號
版權聲明:自由轉載-非商用-非衍生-保持署名( 創意共享 3.0 許可證)