「前端」淺談 React/Vue/Inferno 在 DOM Diff 算法上的異同

一、引言

img

在現代的前端渲染框架中,Virtual DOM 幾乎已經成了標配,通過這樣一個緩衝層,我們已經能夠實現對 Real DOM 的最少操作,在大家的廣泛認知中,操作 DOM 是比較慢的,因此 Virtual DOM 可以實現應用程序的性能提升。

毫無疑問,Virtual DOM 不可能全量同步到 Real DOM,因爲那樣就違背了設計 Virtual DOM 的初衷,那麼Virtual DOM 同步到 Real DOM 的操作就稱之爲 DOM Diff,顧名思義,計算兩個 DOM Tree 之間的差異性,增量更新 Real DOM。更新動作的原則是,能複用的節點絕不刪除重新創建。

不同框架對於 DOM Diff 的理解並不完全一致,但有一點可以達成共識:由於 DOM 本身是樹形(Tree)結構,不同層級之間的節點(Node)沒有必要對比,因爲這可能會帶來 O(N³) 的計算複雜度,很可能還不如直接操作 Real DOM 來的快。因此,狹義的 DOM Diff 算法,一般指的是同一層級兄弟節點的範圍之內。

本文,我就對典型的幾種 DOM Diff 實現進行簡單的介紹,並分析潛在的陷阱,以便從原理上理解並更好地使用相應的框架。

二、實現簡介

React

30 React Components For Web Developers 2019

大多數開發者都是從 React 上才第一次接觸到 Virtual DOM 這個概念的,雖然並非是它發明的。在幾年前 Angular 1.x 大熱的時候,“髒檢查”的原理幾乎成爲了每一次面試的必考題目。很快,“髒檢查”帶來的性能上的瓶頸並很快顯現出來,除了“餓了麼”等少數產品之外,鮮有團隊敢於在C端系統上部署 Angular,它更多成爲了後臺系統的效率利器,甚至有很多後端開發者也擁有了編寫後臺前端的能力,因此 Angular 帶來的開發效率上的提升還是相當值得肯定的。

在大多數人的印象裏,帶來性能革命的就是 React,連帶的 JSX、Virtual DOM 都成爲了日後開發者耳熟能詳的概念。現在我們就來了解一下 React 是如何實現 DOM Diff 的。

dom-diff

假設在 Real DOM 中存在下面這幾個兄弟節點:【A、B、C、D、E、F、G】,而現在 Virtual DOM 中是 【D、A、G、F、K、E】。顯然,除了順序打亂了之外,移除了B節點和C節點,新增了K節點。我們來一步一步演示 React 的 DOM Diff 算法。

  1. 遍歷 Virtual DOM。
    1. 首先,第1個節點除非要被移除,否則不會被移動,於是首節點D不動。
    2. 遍歷到節點A,在 Real DOM 中節點A在節點D之前,與 Virtual DOM 中的先後順序不同,因此我們把節點A移動到節點D之後(這裏使用了 DOM 元素的 insertBefore 方法)。這是第1次操作DOM,此時 Real DOM 爲 【B、C、D、A、E、F、G】。
    3. 遍歷到節點G,由於在Real DOM 中節點G在節點A(上一個遍歷到的節點)之後,與 Virtual DOM 順序相同,因此不動。
    4. 遍歷到節點F,由於在 Real DOM 中節點F在節點G(上一個遍歷到的節點)之前,與 Virtual DOM 順序不同,因此我們把節點F移動到節點G之後。這是第2次操作DOM,此時 Real DOM 爲 【B、C、D、A、E、G、F】。
    5. 遍歷到節點K,在 Real DOM 中不存在K節點,我們創建它,並放在節點F(上一個遍歷到的節點)之後。這是第3次操作DOM,此時 Real DOM 爲 【B、C、D、A、E、G、F、K】。
    6. 遍歷到節點E,由於節點K(上一個遍歷到的節點)是新創建的節點,因此我們直接把節點E移動到節點K之後。這是第4次操作DOM,此時 Real DOM 爲 【B、C、D、A、G、F、K、E】。
  2. 遍歷 Real DOM
    1. 移除節點B和節點C。第5、6次操作DOM,Real DOM 爲【D、A、G、F、K、E】。
dom-diff

一共操作了6次DOM,完成了這次 DOM Diff。

我們假設如果不使用 Virtual DOM,那麼所有 DOM 節點都需要移除和重新創建,一共13次 DOM 操作,顯然我們有了50%以上的效率提升。

我們言簡意賅地總結一下 React 的 DOM Diff 算法的關鍵邏輯。

  1. Virtual DOM 中的首個節點不執行移動操作(除非它要被移除),以該節點爲原點,其它節點都去尋找自己的新位置;
  2. 在 Virtual DOM 的順序中,每一個節點與前一個節點的先後順序與在 Real DOM 中的順序進行比較,如果順序相同,則不必移動,否則就移動到前一個節點的前面或後面

於是,如果不考慮節點的移除和創建,我們可以推導出什麼樣的重新排序對這套 DOM Diff 算法最不利。最不利的結果無非就是除了首個節點外,其它所有節點都需要移動,對於有 N 個節點的數組,總共移動了 N-1 次。

考慮這個序列【A、B、C、D】,如果想變成【D、C、B、A】,應該是什麼樣的過程:

  1. 節點D是首個節點,不執行移動。
  2. 節點C移動到節點D後面:【A、B、D、C】;
  3. 節點B移動到節點C後面:【A、D、C、B】;
  4. 節點A移動到節點B後面:【D、C、B、A】。
dom-diff

一共3步,正是 N-1。所以,可以確定的是,如果末尾的節點移動到了首位,就會引起最不利的 DOM Diff 結果。

我們用另一個例子驗證一下,這個序列【A、B、C、D】,變成【D、A、B、C】。我們一眼看上去就知道,只要把節點D移動到首位就可以了,但是我們看 React 它會怎麼做:

  1. 節點D是首個節點,不執行移動。
  2. 節點A移動到節點D後面:【B、C、D、A】;
  3. 節點B移動到節點A後面:【C、D、A、B】;
  4. 節點C移動到節點B後面:【D、A、B、C】。
dom-diff

還是 N-1,可見首個節點不執行移動這個特性,導致了只要把末尾節點移動到首位,就會引起 N-1 這種最壞的 DOM Diff 過程,所以大家要儘可能避免這種重排序。

Vue

vue

Vue 的起步晚於 React,但被廣大開發者接受的更迅速。事實上,Vue 並不能與 React 完全等價。React 只是專注於數據到視圖的轉換,而 Vue 則是典型的 MVVM,帶有雙向綁定。當然 Vue 還具備更人性化、更方便的的工程化開發框架,這也是它爲什麼更容易被接受的原因,不過本文不做討論。

Vue 並未完全自主開發一套 Virtual DOM,而是借鑑了另一個開源庫snabbdom,其核心算法邏輯代碼請參考https://github.com/snabbdom/snabbdom/blob/v0.7.3/src/snabbdom.ts#L179

下面我們還是用之前的例子來演示這套 DOM Diff 是如何運作的,由【A、B、C、D、E、F、G】轉換成【D、A、G、F、K、E】。

設定4個指針OS(OldStart)、OE(OldEnd)、NS(NewStart)、NE(NewEnd),分別指向這兩個序列的頭尾。

A B C D E F G
?					 ?
OS          OE

D A G F K E
? ?
NS NE
預覽

現在我們來交叉比較,看有沒有相同的。如果OS或OE與NS相同,則移動到NS的位置,如果OS或OE與NE相同,則移動到NE的位置。如果都沒有相同的,則在 Real DOM 中找到NS的元素,移動到NS位置。

可見,【A,G】與【D,E】沒有相同的,那麼就找到D元素,移動到NS的位置。這是第1次操作DOM,此時 Real DOM 爲 【D、A、B、C、E、F、G】,NS++,OS++,現在4個指針的指向爲:

D A B C E F G
  ?				 ?
  OS        OE

D A G F K E
? ?
NS NE
預覽

然後開始第二輪比較,顯然OS與NS都是A,相同,不用執行任何移動操作,OS++,NS++,現在4個指針的指向爲:

D A B C E F G
    ?			 ?
    OS      OE

D A G F K E
? ?
NS NE
預覽

現在開始第三輪比較,顯然OE與NS都是G,相同,現在需要把OE移動到NS的位置。這是第2次操作DOM,此時 Real DOM 爲 【D、A、G、B、C、E、F】,OE–,NS++,現在4個指針的指向爲:

D A G B C E F
      ?		 ?
      OS    OE

D A G F K E
? ?
NS NE
預覽

現在開始第四輪比較,顯然OE與NS都是F,相同,現在需要把OE移動到NS的位置。這是第3次操作DOM,此時 Real DOM 爲 【D、A、G、F、B、C、E】,OE–,NS++,現在4個指針的指向爲:

D A G F B C E
        ?	 ?
        OS  OE

D A G F K E
? ?
NSNE
預覽

現在開始第五輪比較,顯然OE與NE都是E,相同,不用執行任何移動操作,OE–,NE–,現在4個指針的指向爲:

D A G F B C E
        ? ?
        OSOE

D A G F K E
?
NS=NE
預覽

現在開始第六輪比較,顯然NS(或NE)指向的 K 在 Real DOM 中並不存在,因此我們創建節點K,這是第4次操作DOM,此時 Real DOM 爲 【D、A、G、F、K、B、C、E】,NS++,現在4個指針的指向爲:

D A G F K B C E
          ? ?
          OSOE

D A G F K E
? ?
NENS
預覽

由於NE<NS,意味着 新序列中已經沒有可遍歷的元素,因此OS與OE閉區間內的節點都需要被刪除,這是第5、6次操作DOM,此時 Real DOM 爲 【D、A、G、F、K、E】。

dom-diff

到此爲止,我們用了六次DOM操作,與 React 的性能相當。

我們還是總結一下 Vue 的 DOM Diff 算法的關鍵邏輯:

  1. 建立新序列(Virtual DOM)頭(NS)尾(NE)、老序列(Real DOM)頭(OS)尾(OE)一共4個指針,然後讓NS/NE與OS/OE比較;
  2. 如果發現有OS或OE的值與NS或NE相同,則把相應節點移動到NS或NE的位置。

說的簡單一點,其實 Vue 的這個 DOM Diff 過程就是一個查找排序的過程,遍歷 Virtual DOM 的節點,在 Real DOM 中找到對應的節點,並移動到新的位置上。不過這套算法使用了雙向遍歷的方式,加速了遍歷的速度。

從以上原理中我們可以輕易地推導出對該算法最不利的莫過於序列倒序。比如從【A、B、C、D】轉換爲【D、C、B、A】,算法將執行N-1次移動,與 React 相同,並沒有更壞。

那麼我們再看一眼對於 React 無法高效處理的例子,【A、B、C、D】轉換爲【D、A、B、C】,看一下 Vue 的算法表現如何。

dom-diff

在第一輪比較中,Real DOM 的末尾節點D與 Virtual DOM 的首節點D相同,那麼就把節點D移動到首位,變成【D、A、B、C】,直接一步到位,高效完成了轉換,從這一點上,並沒有犯 React 的錯。

不過值得說明的是,在匹配不成功的情況下,如何找到NS節點在 Real DOM 的位置,並非是順序遍歷的(否則就會導致 O(N²) 的複雜度),而是預先存儲了各個節點的位置,查找映射表即可,所以可以說是用一定的空間複雜度換了時間複雜度。

Inferno

Inferno

Inferno 雖然在一定程度上兼容 React 語法,但它最大的賣點卻是其卓越的算法。如果說 React、Vue 的算法能在一定程度上能節約DOM操作的次數的話, 那麼毫無誇張地說,Inferno 的算法就是能把DOM操作的次數降到最低。我們來看一下它是怎麼辦到的。

下面我們還是用之前的例子來演示這套 DOM Diff 是如何運作的,由【A、B、C、D、E、F、G】轉換成【D、A、G、F、K、E】。

首先,我們記錄 Virtual DOM 的各個元素在 Real DOM 中的序號:【3、0、6、5、-1、4】,記錄爲數組maxIncrementSubSeq,其中-1表示在 Real DOM 中並不存在,需要創建。

這個數組能說明什麼呢?彆着急,現在我們來獲取該數組的“最大遞歸子序列”,當然,僅限非負數。

這個例子有4個子序列都滿足需求:【3、5】、【3、4】、【0、5】、【0、4】,具體算法已經很成熟,這裏不關心,我們隨便取一個【3、5】。

Real DOM 在【3、5】位置上是節點D和節點G。這說明這兩個節點是不需要移動位置的,其它都要移動或刪除。

從數組 maxIncrementSubSeq 中我們已經能夠推斷出應該刪除的節點是位於位置【1、2】的兩個節點B和C,因爲【1、2】並未出現在數組 maxIncrementSubSeq 中。這是第1、2次DOM操作,此時 Real DOM 爲 【A、D、E、F、G】。

接下來我們從後往前遍歷 Virtual DOM:

  1. 最後一個是節點E,那我們就把節點E移動到最後,這是第3次DOM操作,此時 Real DOM 爲 【A、D、F、G、E】;
  2. 遍歷到節點K,這是一個新節點,我們創建並插入到節點E(上一個遍歷到的節點)之前,這是第4次DOM操作,此時 Real DOM 爲 【A、D、F、G、K、E】;
  3. 遍歷到節點F,那我們就把節點F移動到節點K之前,這是第5次DOM操作,此時 Real DOM 爲 【A、D、G、F、K、E】;
  4. 遍歷到節點G,由於節點G位於最大遞增子序列中,因此不需要移動;
  5. 遍歷到節點A,由於節點A也位於最大遞增子序列中,因此也不需要移動;
  6. 遍歷到節點D,那我們就把節點D移動到節點A之前,這是第6次DOM操作,此時 Real DOM 爲 【D、A、G、F、K、E】。
dom-diff

同樣操作了六次DOM,相比於 Vue、React 好像並沒有什麼優勢,不過這是因爲這個例子中的最大遞增子序列太短導致的,也就是說,能保持位置不動的元素不夠多。

同樣再看一眼對於 React 無法高效處理的例子,【A、B、C、D】轉換爲【D、A、B、C】,看一下 Inferno 的算法表現如何。

dom-diff

顯然,最大遞增子序列所代表的不需要移動的元素是【A、B、C】,那麼從後往前遍歷 Virtual DOM,先後經歷節點C、B、A都不需要移動,到節點D才需要移動一次,因此對於這種特殊場景,Inferno 也只需要一次 DOM 操作,與 Vue 效率相同。

那麼對於序列倒序這種特殊場景,由於最大遞增子序列的長度爲1,所以也需要N-1次DOM移動操作,與 Vue 相同。

那麼有沒有能優於 Vue 算法的場景呢?試想將【A、B、C、D、E】轉換爲【C、D、E、A、B】。

對於 Inferno 而言,由於最大遞增子序列【C、D、E】的長度爲3,所以只需要5-3=2次DOM操作即可完成重排序。

ABCDE
  ↆ
ACDEB
  ↆ
CDEAB
預覽

對於 Vue 而言,則依賴於算法在遇到無匹配的邏輯分支下,是決定補NS指針的節點位置還是補NE指針,如果是後者,則也只需要2次DOM移動操作,如果是前者,則需要3次。從實現代碼上來看,是前者。

ABCDE
  ↆ
CABDE
  ↆ
CDABE
  ↆ
CDEAB
預覽

因此在一些比較特殊的情況下,Inferno 在節省DOM操作次數的指標上,是可能優於 Vue 的,不過也不多。

現在我們再來回想一下 React 的算法,在上面這個例子中,React 也只需要移動2次就夠了。

ABCDE
  ↆ
BCDEA
  ↆ
CDEAB
預覽

如果你仔細回味,就會發現,React 的 DOM Diff 算法其實也體現了最大遞增子序列的概念,但是它假定這個子序列一定是從第一個位置開始的,一旦不是這樣子,算法效率就會惡化,這也是爲什麼它不能很好地處理末尾節點移動到首位這種場景的,因爲子序列長度僅爲1。

三、總結

簡單闡述了 React、Vue、Inferno 的算法梗概之後,我們統一總結一下:

  1. Inferno 利用最大遞增子序列的算法達到了移動DOM次數最少的目標;
  2. React 假定最大遞增子序列從0開始,在末尾節點移動到首位的場景中會惡化;
  3. Vue 利用雙向遍歷排序的方法,有可能不是最優解,但與最優解十分逼近;
  4. 三種算法對於倒序這種場景都降級爲理論上的最少N-1次。

因此,在實際的業務開發中,序列倒序是最應該被避免的,對於 React 還應注意末尾節點的問題,除此之外,沒有什麼特別需要擔心的,框架都會在足夠程度上(雖然可能不是最優的)利用現有DOM而不是重新創建,從而實現性能優化的發揮。

需要特別指出的是,包括但不限於 React、Vue、Inferno 在內的衆多框架,在同一層級節點上,都希望業務指定一個key值來判定重渲染前後是否是同一個節點,如果連key值都不同,那麼DOM節點是不會被重用的。

在很多“最佳實踐”文章中,都認爲用數組遍歷的序號來做key值是不可取的,不過這也取決於具體場景,典型的是,如果遍歷的數據是靜態不可變的,那麼使用序號來做key並不會有什麼問題。

退一步說,如果數組順序變化,依然用序號做key會有什麼問題呢?這個問題需要從兩方面來回答。

首先,對於性能來講,渲染前後對於同一個序號的數據發生了變化,框架依然可能會重用節點,這可能會導致後代節點的大量刪除與重建。

其次,對於渲染結果正確性來講,一般也不會有問題,但有一種場景,就是DOM上的數據並沒有同步到框架中,比如 React 中的一個概念,叫做“失控組件(https://reactjs.org/docs/uncontrolled-components.html)”,那麼重渲染之後,未同步的數據很可能出現在錯誤的節點中。

這就是使用序號來做key需要注意的知識點。

對於 Inferno 而言,key值的邏輯並不絕對,對於靜態數組或者只是在尾部添加元素的數組,不使用key反倒在性能上更有優勢。不過對於使用頻率高得多的 React 和 Vue,還是老老實實都添加 key 爲好。

最後,還有一個問題需要回答,Inferno 的算法是否解決了將一個數組打亂成另一個數組,中間最少需要幾步的問題呢?我看不見得,大家要注意DOM有這樣一個操作:insertBefore,它的功能是在一個節點前面插入另一個節點,如果要插入的節點就在這個兄弟節點集合中,那麼它還會被自動從原來的位置移除。

數組有這樣的特性嗎?恐怕沒有。數組是順序存儲的,在一個位置插入數據會導致後面的數據全部後移,這是可觀的性能開銷。

如果換成雙向鏈表,那麼我認爲應用這幾種算法都問題不大。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章