從一個小Demo看React的diff算法

前言

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

diff的對比思路

React的diff對比是採用深度遍歷的規則進行遍歷比對的。以下圖的Dom結構爲例:\
<img src="https://github.com/ISummerRai...; style="width: 620px" />\
對比過程爲:對比組件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的刪除\
<img src="https://github.com/ISummerRai...; style="width: 620px" />\
按照前面的分析,比對過程爲:對比組件1(沒有變化)-> 對比組件2(沒有變化)-> 對比組件4(組件4被移除,記錄一個移除操作)-> 對比組件5(沒有變化)-> 對比組件6(沒有變化)-> 對比組件3(沒有變化)。對比結束。按照這個分析,用代碼進行測試後,控制檯日誌應該會輸出:

    銷燬組件4

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

    加載組件5
    加載組件6
    銷燬組件4
    銷燬組件5
    銷燬組件6

可以發現,除了“銷燬組件4”這一個操作之外,還進行了組件5和組件6的銷燬和加載操作。難道是我們之前的分析是錯誤的?別急,我們再來進行另外一個實驗:
<img src="https://github.com/ISummerRai...; style="width: 620px" />\
同樣只刪除了一個組件,只是刪除的組件位置不同,按照上次的實驗結果,控制檯輸出日誌應該爲:

    加載組件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變更:\
<img src="https://github.com/ISummerRai...; style="width: 620px" />\
這種變更,由於組件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:\
<img src="https://github.com/ISummerRai...; style="width: 620px" />

    // 變化前
    <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值的方法來提高渲染的效率。

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