從一個小Demo看React的diff演算法

流霜發表於2019-04-18

前言

React的虛擬Dom和其diff演算法,是React渲染效率遠遠高於傳統dom操作渲染效率的主要原因。一方面,虛擬Dom的存在,使得在操作Dom時,不再直接操作頁面Dom,而是對虛擬Dom進行相關操作運算。再通過運算結果,結合diff演算法,得出變更過的部分Dom,進行區域性更新。另一方面,當存在十分頻繁的操作時,會進行操作的合併。直接在運算出最終狀態之後才進行Dom的更新。從而大大提高Dom的渲染效率。
對於React如何通過diff演算法來對比出做出變動的Dom,React內部有著複雜的運算過程,此文不做具體程式碼層級的討論。僅僅通過一個小小Demo來巨集觀上的探討下diff的運算思路。

diff的對比思路

React的diff對比是採用深度遍歷的規則進行遍歷比對的。以下圖的Dom結構為例:
從一個小Demo看React的diff演算法
對比過程為:對比元件1(沒有變化)-> 對比元件2(沒有變化)-> 對比元件4(沒有變化)-> 對比元件5(元件5被移除,記錄一個移除操作)-> 對比元件3(沒有變化)->對比元件3子元件(新增了一個元件5,記錄一個新增操作)。對比結束,此時變動資料記錄了兩個節點的變動,在渲染時,便會執行一次元件5的移除,和一次元件5的新增。其它節點不做變更,從而實現頁面Dom的更新操作。

Demo初探

接下來,我們設計一個簡單的demo,來分析頁面變化時的整個過程。
首先我們建立幾個相同的Demo元件:

    import React, { Component } from 'react';
    export default class Demo1 extends Component {
        componentWillMount() {
            console.log('載入元件1');
        }
        componentWillUnmount() {
            console.log('銷燬元件1')
        }
        render () {
            return <div>{this.props.children}</div>
        }
    }
複製程式碼

元件除了將其內部的Dom直接渲染之外,還在元件載入前和解除安裝前分別在控制檯中列印出日誌。
接下來通過程式碼組合出上圖中的元件結構,並通過事件觸發元件結構的變化。

    // 變化前
    <Demo1>1
        <Demo2>2
            <Demo4>4</Demo4>
            <Demo5>5</Demo5>
        </Demo2>
        <Demo3>3</Demo3>
    </Demo1>
    
    // 變化後
    <Demo1>1
        <Demo2>2
            <Demo4>4</Demo4>
        </Demo2>
        <Demo3>3
            <Demo5>5</Demo5>
        </Demo3>
    </Demo1>
複製程式碼

執行變更操作之後,控制檯會列印出日誌

    載入元件5
    銷燬元件5
複製程式碼

結果通分析中一樣,分別執行了一次元件5的載入操作和一次元件5的解除安裝操作。
接下來來分析一些複雜的情況。
首先看下面這種Dom的刪除
從一個小Demo看React的diff演算法
按照前面的分析,比對過程為:對比元件1(沒有變化)-> 對比元件2(沒有變化)-> 對比元件4(元件4被移除,記錄一個移除操作)-> 對比元件5(沒有變化)-> 對比元件6(沒有變化)-> 對比元件3(沒有變化)。對比結束。按照這個分析,用程式碼進行測試後,控制檯日誌應該會輸出:

    銷燬元件4
複製程式碼

這一條日誌。然而,在實際測試後,會發現輸出日誌為:

    載入元件5
    載入元件6
    銷燬元件4
    銷燬元件5
    銷燬元件6
複製程式碼

可以發現,除了“銷燬元件4”這一個操作之外,還進行了元件5和元件6的銷燬和載入操作。難道是我們之前的分析是錯誤的?別急,我們再來進行另外一個實驗: 從一個小Demo看React的diff演算法
同樣只刪除了一個元件,只是刪除的元件位置不同,按照上次的實驗結果,控制檯輸出日誌應該為:

    載入元件4
    載入元件5
    銷燬元件4
    銷燬元件5
    銷燬元件6
複製程式碼

然而,實際的實驗結果又出乎我們的預料。實際輸出結果僅為:

    銷燬元件6
複製程式碼

這個現象十分有趣。僅僅是刪除了不同位置的元件,diff分析的過程卻完全不一樣。其實,如果你繼續實驗刪除元件5,你會發現,所得的結果跟前兩次也是完全不同。
其實diff演算法在進行虛擬Dom的變更比對時,並不能精確的進行一對一的比對(當然react提供瞭解決方案,後面討論)。當一個父節點發生變更時,會銷燬掉其下所有的子節點。而其兄弟節點,則會按照節點順序進行一對一的順序比對。那麼在上面第一個例子的比對順序其實是這樣的:對比元件1(沒有變化)-> 對比元件2(沒有變化)-> 對比元件4(元件4變更為元件5,記錄一次元件4的移除操作和一次元件5的新增操作)->對比元件5(元件5變更為元件6,記錄一次元件5的移除操作和一次元件6的新增操作)->對比元件6(元件6被移除,記錄一次元件6的移除操作)。對比結束。按照這個分析思路,控制檯的輸出結果就不難理解了。
同樣當我們在第二個例子中移除元件6時。元件4和元件5的順序並沒有變化,所以對比時,仍然是跟自身元件的虛擬Dom進行比對,沒有變化,所以也就只有一次元件6的移除操作。
我們可以進一步通過新增及修改操作來進一步驗證猜想。
通過在元件4前新增一個元件和在元件6後新增一個元件的對比。可以發現結果與我們的猜想結果完全一致。具體實驗推演過程,此處不在贅述。
對於修改,由於修改並未改變該元件及其兄弟元件的個數及順序,所以僅僅會執行替換元件及其子元件的新增操作和被替換元件的移除操作。
同級的元件分析完了,那麼如果是跨層級的元件操作呢?比如下面這種dom變更:
從一個小Demo看React的diff演算法
這種變更,由於元件2,元件4,元件5三個元件的結構均未有任何變化,那麼會不會複用其整個結構,只進行相對位置的變更呢?實驗發現,控制檯日誌輸出為:

    載入元件3
    載入元件2
    載入元件4
    載入元件5
    銷燬元件2
    銷燬元件4
    銷燬元件5
    銷燬元件3
複製程式碼

可見元件2及其子元件發生變化時,元件2以及其下的所有子元件均會被重新渲染。那麼為什麼元件3也會重新渲染呢?其實原因並不是其增加了子節點,而是因為其兄弟節點2被移除,影響了其相對位置而造成的。其完整的對比流程為:對比元件1(沒有變化)-> 對比元件2(元件二變更為元件3,記錄一次元件2的移除操作以及其子元件:元件4和元件5的移除操作,同時記錄元件3的新增操作,以及其子元件:元件2,元件4和元件5的移除操作)-> 對比元件3(元件3被移除,記錄一次元件3的移除操作
分析可見:當一個節點變化時,其下的所有子節點會全部被重新渲染。比如在上個例子中,不進行結構的變更,只是將元件2替換為元件6,元件4和元件5保持不變,但由於元件4和元件5是元件2的子元件,元件2的變更依然會導致元件4和元件4被重新渲染。
此外,分析輸出的結果,可以看到,react在進行區域性Dom的更新時,會先執行新元件的載入,再執行元件的移除操作。

被忽略的key

在我們以前的開發工作中,肯定遇到過列表的渲染。此時React會強制我們為列表的每一條資料設定一個唯一的key值(否則控制檯會報警告),並且官方禁止使用列表資料的下標來作為key值。在React 16及以後版本中,新增的以陣列的形式來渲染多個同級的兄弟節點的寫法中,同樣要求我們為每一項新增唯一key值。你可能很疑惑這個必須加的key,似乎並沒有什麼實質的作用,為何卻是一個必加項。

渲染效率的提升

其實,在React進行diff運算時,key值是十分關鍵的,因為每一個key就是該虛擬Dom節點的身份證,在我們之前的實驗中,由於沒有定義key值,diff運算在進行虛擬Dom的比對時,並不知道這個虛擬Dom跟之前的哪個虛擬Dom是一樣的,所以只能採用順序比對的方案,進行一對一比對。所以才有了之前分析中的由於位置的不同,導致了完全不同的輸出結果。而當我們為每一個元件新增key值之後,由於有了唯一標示,在進行diff運算時,便能進行精確的比對,不再受到位置變動的影響。
回到最初的刪除實驗,為每一個元件新增上唯一的key:
從一個小Demo看React的diff演算法

    // 變化前
    <Demo1 key={1}>1
        <Demo2 key={2}>2
            <Demo4 key={4}>4</Demo4>
            <Demo5 key={5}>5</Demo5>
            <Demo6 key={6}>6</Demo6>
        </Demo2>
        <Demo3 key={3}>3</Demo3>
    </Demo1>
    
    // 變化後
    <Demo1 key={1}>1
        <Demo2 key={2}>2
            <Demo4 key={4}>4</Demo4>
            <Demo5 key={5}>5</Demo5>
            <Demo6 key={6}>6</Demo6>
        </Demo2>
        <Demo3 key={3}>3</Demo3>
    </Demo1>
複製程式碼

執行發現,其輸出日誌正是我們最初設想的那樣:

    銷燬元件4
複製程式碼

相對於沒有key值的操作,避免了元件5和元件6的重新渲染。大大提高了渲染的效率。此時,為什麼列表類資料必須加一個唯一的key值,就顯而易見了。試想一下在一個無限滾動的移動端列表頁面,載入了1000條資料。此時將第一條刪除,那麼,在沒有key值的情況下,要重新渲染這個列表,需要將第一條之後的999條資料全部重新渲染。而有了key值,僅僅只需要對第一條資料進行一次移除操作就可以完成。可見,key值對渲染效率的提升,絕對是巨大的。\

key不可設定為資料下標

那麼,為什麼不能將key值設定為資料的下標呢?其實很簡單,因為下標都是從0開始的,還是這個移動端的列表,刪除了第一條資料,如果將key值設定為了資料下標。那麼原來的key值為1的資料,在重新渲染後,key值會重新被設定為0,那麼在進行比對時,會把這條資料跟變更前的key為0的資料進行比對,很明顯,這兩條資料並不是同一條,所以依然會因為資料不同,而導致整個列表的重新渲染。\

key值必須唯一?

除此之外,還有一個開發中的共識,就是key值必須唯一。但key值真的不能相同嗎?
按照之前的實驗以及分析,可以看出:當在進行兄弟節點的比對時,key值能夠作為唯一的標示進行精確的比對。但是對於非兄弟元件,由於diff運算採用的是深度遍歷,且父元件的變動會完全更新子元件,所以理論上key值對於非兄弟元件的作用,就顯得微乎其微。那麼對於非兄弟元件,key值相同應該是可行的。那麼用實驗驗證一下我們的猜想。

    // 變更前
    <Demo1 key={1}>1
        <Demo2 key={1}>2
            <Demo4 key={4}>4</Demo4>
            <Demo5 key={5}>5</Demo5>
            <Demo6 key={6}>6</Demo6>
        </Demo2>
        <Demo3 key={2}>3
            <Demo4 key={4}>4</Demo4>
            <Demo5 key={5}>5</Demo5>
            <Demo6 key={6}>6</Demo6>
        </Demo3>
    </Demo1>
    // 變更後
    <Demo1 key={1}>1
        <Demo2 key={1}>2
            <Demo5 key={5}>5</Demo5>
            <Demo6 key={6}>6</Demo6>
        </Demo2>
        <Demo3 key={2}>3
            <Demo4 key={4}>4</Demo4>
            <Demo6 key={6}>6</Demo6>
        </Demo3>
    </Demo1>
複製程式碼

在這個實驗中,元件1和元件2有著相同的key值,且元件2和元件3的子元件也有著相同的key值,然而執行該程式碼,卻並沒有關於key值相同的警告。執行Dom變更後,日誌輸出也同之前的猜想沒有出入。可見我們的猜想是正確的,key值並非需要絕對唯一,只是需要保證在同一個父節點下的兄弟節點中唯一便可以了。\

key的更多用法

除了上面提到的這些之外,在瞭解了key的作用機制之後,還可以利用key值來實現一些其它的效果。比如可以利用key值來更新一個擁有自狀態的元件,通過修改該元件的key值,便可以達到使該元件重新渲染到初始狀態的效果。此外,key值除了在列表中使用之外,在任何會操作dom,比如新增,刪除這種影響兄弟節點順序的情況,都可以通過新增key值的方法來提高渲染的效率。

相關文章