深度對(duì)比 React 與 Vue 的 Diff 算法
共 11938字,需瀏覽 24分鐘
·
2024-08-05 22:39
昨天接了個(gè)廣子,恰了點(diǎn)飯。你們的吐槽我的認(rèn)了,哎呀,你們都說(shuō)得對(duì)。所以我今晚連夜爆肝寫了一篇專門用來(lái)面試的干貨,總計(jì) 7000 字左右,分享給大家,必須要搶救一下想要取關(guān)的大佬們!
許多朋友會(huì)在簡(jiǎn)歷上同時(shí)寫自己會(huì) React、Vue,但是倒霉的面試官一看到這種簡(jiǎn)歷,就喜歡問(wèn)它們有什么區(qū)別。其中頻率比較高的一個(gè)問(wèn)題就是 React 與 Vue 的 diff 算法有啥相同之處和不同之處...
很顯然,這種問(wèn)題對(duì)于面試考驗(yàn)開(kāi)發(fā)者能力而言,沒(méi)啥營(yíng)養(yǎng),就算知道了,對(duì)開(kāi)發(fā)能力也不會(huì)有什么明顯的提高,還不如更具體的問(wèn) key 值有什么用呢,但是沒(méi)辦法,有的面試官就是愛(ài)問(wèn),既然這樣,那我們就答給他們看。
假說(shuō)論
我們?cè)谒伎妓惴▎?wèn)題的時(shí)候,一定要謹(jǐn)記一個(gè)前提,那就是沒(méi)有完美的算法可以解決所有問(wèn)題。 因此,在設(shè)計(jì)一個(gè)算法時(shí),我們需要充分考慮應(yīng)用場(chǎng)景,然后提出一個(gè)假說(shuō),從而極大的減少問(wèn)題的復(fù)雜性,讓解決方案變得更加簡(jiǎn)單。
在 React/Vue 的 diff 算法中,當(dāng)我們要對(duì)比前后兩棵樹(shù)的差異時(shí),我們的目標(biāo)是盡可能少的創(chuàng)建節(jié)點(diǎn)。但是由于 DOM 操作的可能性太復(fù)雜了,因此如果要全部對(duì)比出來(lái),復(fù)雜度就非常高。達(dá)到了 O(n^ 3) 這個(gè)級(jí)別。
之所以這么復(fù)雜的原因,就是因?yàn)楣?jié)點(diǎn)不僅可以增加刪除,還可以移動(dòng)。我們要分辨節(jié)點(diǎn)是否從子元素移動(dòng)到了父元素,或者增加了一個(gè)父元素,判斷過(guò)程非常復(fù)雜。因此,在設(shè)計(jì) dfiff 算法的時(shí)候,React/Vue 都放棄了這種情況的識(shí)別。
他們根據(jù)實(shí)際情況提出的假說(shuō)是:在實(shí)際情況中,整棵 DOM 樹(shù)里,關(guān)于父子節(jié)點(diǎn)移動(dòng)的情況是比較少的,因此,沒(méi)有必要為了這種少部分情況加劇算法的壓力。只要放棄識(shí)別這種情況,算法就能夠得到極大的簡(jiǎn)化。
幾乎相同的比較策略
在上面我們提到的假說(shuō)論之下,React/Vue 在 diff 算法上,都使用了非常類似的策略。
一、同層比較
如下圖所示,diff 算法只需要比較相同層級(jí)的節(jié)點(diǎn)是否相同
比較之后又兩種情況
-
如果相同:則直接復(fù)用,而不需要重新創(chuàng)建 -
如果不同:則直接默認(rèn)為從該節(jié)點(diǎn)開(kāi)始,以下的全部節(jié)點(diǎn)都發(fā)生了變化,需要重新創(chuàng)建。
如下圖所示,雖然節(jié)點(diǎn)只是發(fā)生了移動(dòng),但是在 diff 過(guò)程中,會(huì)被認(rèn)為 A 節(jié)點(diǎn)已經(jīng)被刪除,然后重新創(chuàng)建它。
因此,在開(kāi)發(fā)過(guò)程中,我們可以通過(guò)避免跨層級(jí)操作節(jié)點(diǎn)的方式,來(lái)提高你應(yīng)用程序的性能。
二、節(jié)點(diǎn)比較
在對(duì)比節(jié)點(diǎn)是否相同時(shí),React 與 Vue 的處理策略也比較類似。了解節(jié)點(diǎn)的比較方式,是我們?cè)谧鲂阅軆?yōu)化時(shí)的重要依據(jù)。
節(jié)點(diǎn)比較有兩種情況,一種節(jié)點(diǎn)是默認(rèn)支持的 DOM 節(jié)點(diǎn),一種是通過(guò)自定義創(chuàng)造出來(lái)的自定義節(jié)點(diǎn),在 React 中會(huì)區(qū)分這兩種情況,但是在 Vue 中會(huì)統(tǒng)一只識(shí)別標(biāo)簽名 tag。
除了這個(gè)情況之外,在結(jié)構(gòu)類型上,還分為兩種情況,一種是正常的樹(shù)狀結(jié)構(gòu),另外一種是列表結(jié)構(gòu)。列表結(jié)構(gòu)通常情況下我們就不能直接忽視移動(dòng)這種情況。因此,針對(duì)于這種列表結(jié)構(gòu),React 和 Vue 都優(yōu)先提倡使用傳入 key 值的方式進(jìn)行標(biāo)記,從而極大的減小比較壓力。
因此,在列表節(jié)點(diǎn)的比較中,React、Vue 都優(yōu)先比較 key 值。
!不過(guò)針對(duì)列表的比較方式處理,是 React 和 Vue 在 diff 算法上最大的差別。我們后面單獨(dú)介紹
然后,在 React 中,diff 算法會(huì)優(yōu)先比較節(jié)點(diǎn)類型是否相同。例如下面的代碼,div 與 span 屬于不同的節(jié)點(diǎn)類型,那么就表示不是同一個(gè)節(jié)點(diǎn)。
然后會(huì)比較 props、state、context 等外部傳入的參數(shù)是否相同,從而判斷是否是同一個(gè)節(jié)點(diǎn)。當(dāng)然,這里還有一個(gè)重要的細(xì)節(jié),對(duì)于性能優(yōu)化至關(guān)重要。
由于在 React 中,props 的傳入都是通過(guò)類似于函數(shù)傳參的方式傳入,例如
前后兩次執(zhí)行
這里的細(xì)節(jié)就是,雖然兩次都入?yún)⒍际?{a: 1},但是他們是不同的內(nèi)存地址,因此前后兩次 props 的比較結(jié)果始終為 false
如果不解決這個(gè)問(wèn)題,任何比較方式都是沒(méi)有意義的,結(jié)果都是為 false。
所里這里 React 設(shè)計(jì)了一個(gè)巧妙的規(guī)則,那就是當(dāng)我們判定元素節(jié)點(diǎn)的父節(jié)點(diǎn)未發(fā)生變化時(shí),就不比較 props,從而跳過(guò) props 的比較,來(lái)提高性能。我們可以利用這樣的特性在 React 中寫出性能非常高的代碼。
除此之外,React 還設(shè)計(jì)了一個(gè) api,React.memo,該 api 可以改變 props 的比較規(guī)則。使用方式如下所示,memo 接收組件聲明作為參數(shù)
使用 memo 包裹之后,props 的比較規(guī)則變成了依次比較第一層 key 值所對(duì)應(yīng)的值。例如,上面的案例中,包裹之前的比較方式為
包裹之后的比較方式為
因此,在特定的場(chǎng)景中,使用 memo 可以有效命中可復(fù)用節(jié)點(diǎn)。
在 Vue 中,由于并不是那么強(qiáng)依賴 diff 算法,因此它的節(jié)點(diǎn)比較邏輯相對(duì)而言會(huì)簡(jiǎn)單直接一些,通過(guò)一個(gè) sameVnode 方法來(lái)比較節(jié)點(diǎn)是否相同。
這里需要注意的是,如果我們要詳細(xì)的聊清楚 React 和 Vue 在節(jié)點(diǎn)是否相同的比較方式上時(shí),就要明白 React 是強(qiáng)依賴于這個(gè),但是 Vue 依賴較弱,因?yàn)?Vue 的實(shí)現(xiàn)原理中更多的是通過(guò)依賴收集的方式來(lái)找到需要更新的節(jié)點(diǎn),這也導(dǎo)致了 React 在這個(gè)上面的邏輯要更加復(fù)雜,Vue 則更加簡(jiǎn)單。因此,我們需要對(duì)他們各自的原理背景有一定的了解。
最大的區(qū)別,在列表上的處理
列表是性能問(wèn)題頻發(fā)的重要場(chǎng)景,因此,React 和 Vue 都針對(duì)長(zhǎng)列表設(shè)計(jì)了特殊的處理方式。在聊之前,我們要明確處理場(chǎng)景,那就是,在列表中,我們就不能再忽視節(jié)點(diǎn)位置的移動(dòng)了。
因?yàn)?,一個(gè)簡(jiǎn)單的移動(dòng),就很容易會(huì)造成整個(gè)組件被判定為需要全部重新創(chuàng)建。所以,我們需要判斷出來(lái)節(jié)點(diǎn)是只發(fā)生了移動(dòng),而不是需要重新創(chuàng)建。
給列表中的每一個(gè)節(jié)點(diǎn),引入唯一 key 值,是他們都共同采用的技術(shù)手段。通過(guò)唯一key 值,我們可以在舊列表中找到新列表的節(jié)點(diǎn)是否已經(jīng)存在,從而決定是移動(dòng)節(jié)點(diǎn)的位置還是創(chuàng)建新的節(jié)點(diǎn)。我們通常會(huì)在數(shù)組中設(shè)定一個(gè) id 用于表示唯一 key 值。
需要注意的是,這里的唯一 key 值,盡量不要使用遞增的數(shù)字序列來(lái)表示。
這個(gè)問(wèn)題也是面試中經(jīng)常會(huì)聊到的話題:為什么盡量不要使用 index 序列作為 key。這是因?yàn)楫?dāng)我們?cè)诹斜碇行略鲆粋€(gè)內(nèi)容時(shí),每一項(xiàng)的 index 每次都會(huì)發(fā)生變化,從而讓渲染結(jié)果出現(xiàn)混亂。
例如有一個(gè)數(shù)組為 [a, b, c],此時(shí)我們將 index 作為key值,那么數(shù)組項(xiàng)與 key 值的對(duì)應(yīng)關(guān)系就是
此時(shí),我們往數(shù)組頭部添加一個(gè)數(shù)據(jù),這個(gè)時(shí)候數(shù)組變成了 [p, a, b, c],然后再執(zhí)行,數(shù)組項(xiàng)與 key 的對(duì)應(yīng)關(guān)系就變成了
新增的項(xiàng)是 p,但是最終導(dǎo)致了數(shù)組項(xiàng)與key 之間的對(duì)應(yīng)關(guān)系錯(cuò)亂,結(jié)果導(dǎo)致緩存的節(jié)點(diǎn)掛到了錯(cuò)誤的數(shù)組項(xiàng)上去了。我們可以通過(guò)如下案例演示觀察在 UI 上的表現(xiàn)差別
首先,我們的默認(rèn)情況如下,上面的列表使用 index 作為 key 值,下面的列表使用 唯一 id 作為key 值。
當(dāng)我新增一個(gè)項(xiàng)時(shí),仔細(xì)觀察兩個(gè)列表的差異。
在引入了 key 值之后,為了追求在某些情況下更少的移動(dòng)次數(shù),Vue 中使用了更復(fù)雜的算法設(shè)計(jì):雙端比較以及最長(zhǎng)子序列遞增,這是他們最大的區(qū)別。
React 的比較方式
首先,我們假定有一個(gè)已經(jīng)渲染好的列表節(jié)點(diǎn)如下
然后,我們?cè)?A 的前面新增的了一個(gè)節(jié)點(diǎn),P,理想的結(jié)果如下所示
在比較的過(guò)程中,我們會(huì)首先創(chuàng)建一個(gè)變量 lastIndex,默認(rèn)值為 0。然后使用一個(gè)指針 index 用來(lái)記錄新數(shù)組中的當(dāng)前項(xiàng)在舊列表中的索引值。
在比較的過(guò)程中,lastIndex 的變化規(guī)則如下
例如,我們開(kāi)始遍歷新數(shù)據(jù) [P, A, B, C D]
1、第一個(gè)目標(biāo)項(xiàng)為 P,我們會(huì)去舊列表中查詢是否存在相同的 key=P 的項(xiàng)。發(fā)現(xiàn)沒(méi)有,此時(shí)創(chuàng)建新節(jié)點(diǎn)
2、第二個(gè)目標(biāo)項(xiàng)為 A,我們?nèi)ヅf列表中查詢是否存在相同的,發(fā)現(xiàn)有,此時(shí) index = 0,可復(fù)用節(jié)點(diǎn)
當(dāng)滿足條件 index < lastIndex 時(shí),移動(dòng)節(jié)點(diǎn)。此時(shí) index = 0,lastIndex = 0,不滿足條件,不移動(dòng),此時(shí)結(jié)果為
i注意:這里說(shuō)的移動(dòng)節(jié)點(diǎn),指的是對(duì)真實(shí) DOM 的操作。
?這里需要多花點(diǎn)時(shí)間感受一下這個(gè)判斷條件的合理性,
index < lastIndex,他是為了確保索引的正確順序而設(shè)計(jì)的規(guī)則
3、第三個(gè)目標(biāo)項(xiàng)為 B,我們?nèi)ヅf列表中查詢是否存在相同的key,發(fā)現(xiàn)有,此時(shí) index = 1,可復(fù)用節(jié)點(diǎn)
不滿足 index(1) < lastIndex(0),不移動(dòng),結(jié)果為
依次類推,最終我們發(fā)現(xiàn),這種情況下,只需要?jiǎng)?chuàng)建一個(gè)新節(jié)點(diǎn) P,不需要移動(dòng)任何節(jié)點(diǎn)。
第二個(gè)案例
新舊列表節(jié)點(diǎn)如下
新的數(shù)據(jù)為 [B, A, D, C]
1、第一個(gè)目標(biāo)節(jié)點(diǎn)為 B,發(fā)現(xiàn)在舊列表中存在相同 key,那么復(fù)用節(jié)點(diǎn),此時(shí),index = 1,當(dāng)前結(jié)果為
2、第二個(gè)目標(biāo)節(jié)點(diǎn)為 A,發(fā)現(xiàn)在舊列表中存在相同 key,那么復(fù)用節(jié)點(diǎn),此時(shí),index = 0,當(dāng)前結(jié)果為
3、第三個(gè)目標(biāo)節(jié)點(diǎn)為 D,發(fā)現(xiàn)在舊列表中存在相同 key,那么復(fù)用節(jié)點(diǎn),此時(shí),index = 3,當(dāng)前結(jié)果為
4、第四個(gè)目標(biāo)節(jié)點(diǎn)為 C,發(fā)現(xiàn)在舊列表中存在相同 key,那么復(fù)用節(jié)點(diǎn),此時(shí),index = 2,當(dāng)前結(jié)果為
這個(gè)案例在 diff 之后,只需要真實(shí) DOM 移動(dòng)兩次節(jié)點(diǎn)。就可以完成更新了。
相信通過(guò)這兩個(gè)案例,大家應(yīng)該能掌握 React 在列表中的對(duì)比規(guī)則,接下來(lái),我們來(lái)了解一下 Vue 的雙端對(duì)比。
Vue 雙端比較
Vue 的雙端對(duì)比的設(shè)計(jì)有一個(gè)目標(biāo),那就是在特定的場(chǎng)景之下,減少真實(shí) DOM 的移動(dòng)次數(shù)。我們來(lái)看一下這樣一種場(chǎng)景。
如果按照 React 的比較規(guī)則,此時(shí)由于第一個(gè)目標(biāo) D 的 index 為 3,從一開(kāi)始就變成了最大,因此,后續(xù)的 lastIndex 都為 3,所有的目標(biāo)項(xiàng)都會(huì)滿足 index < lastIndex,因此,真實(shí) DOM 的移動(dòng)就會(huì)執(zhí)行 3 次。
而 Vue 提出的雙端比較,目標(biāo)就是希望可以識(shí)別出來(lái)這種情況,只需要讓移動(dòng)只發(fā)生一次即可。就是 D 從最末尾移動(dòng)到最前面。
雙端比較會(huì)使用 4 個(gè)指針,分別記錄舊列表和新列表的首尾兩個(gè)節(jié)點(diǎn)位置。
以及對(duì)應(yīng)的虛擬節(jié)點(diǎn)對(duì)象
比較的規(guī)則遵循:首-首比較,尾-尾比較,尾-首比較,首-尾比較的順序。通過(guò)這樣方式找出首尾是否有節(jié)點(diǎn)可以被復(fù)用。
例如下面案例
在首次經(jīng)過(guò)四次比較之后,發(fā)現(xiàn) 新首-舊尾 key 值相同,可以復(fù)用,此時(shí)需要通過(guò)移動(dòng)首尾索引指針構(gòu)建新的新舊節(jié)點(diǎn)數(shù)組
然后新的新舊數(shù)組為
得到新的新舊數(shù)組,繼續(xù)重復(fù)首-首比較,尾-尾比較,尾-首比較,首-尾比較。直到比較結(jié)束。
指針的移動(dòng)方向,總體趨勢(shì)是從兩端往中間移動(dòng)。
這里需要特別注意的是,這樣的比較方式雖然快,但這是不充分比較,因此,在許多情況下,會(huì)導(dǎo)致節(jié)點(diǎn)存在復(fù)用,但是沒(méi)有找出來(lái)。因此,如果沒(méi)找到可復(fù)用的節(jié)點(diǎn),比較還沒(méi)有結(jié)束,還有后續(xù)的邏輯。
我們構(gòu)建一個(gè)新的案例,如下所示
此時(shí)我們發(fā)現(xiàn),通過(guò)首位的 4 次比較,結(jié)果一個(gè)可復(fù)用的節(jié)點(diǎn)都沒(méi)找到,因此,此時(shí)需要回過(guò)頭來(lái)重新完整的遍歷,以新首 key 為當(dāng)前目標(biāo),去舊列表中依次查找是否存在可復(fù)用節(jié)點(diǎn)
在這個(gè)案例中,可以找到,那么,我們就把 B 移動(dòng)到首位
移動(dòng)之后,通過(guò)改變指針位置,再將新的雙端列表演變?yōu)?/p>
然后再以新尾 key 作為當(dāng)前目標(biāo),去舊列表中依次遍歷找尋??梢哉业?C,將 C 移動(dòng)到尾部。然后依次類推。
最后,如果雙端對(duì)比結(jié)束之后,舊列表中還存在節(jié)點(diǎn),則表示這些節(jié)點(diǎn)需要被批量刪除
如果對(duì)比結(jié)束之后,新列表中還存在節(jié)點(diǎn),則表示這些節(jié)點(diǎn)需要批量新增,簡(jiǎn)單處理即可。
Vue3 中的處理方式又發(fā)生了變化,但是時(shí)間來(lái)不及太詳細(xì)的分析了,我這里就簡(jiǎn)單說(shuō)一下,可以用于面試的術(shù)語(yǔ):
先處理左側(cè)相同部分,再處理右側(cè)相同部分,鎖定可復(fù)用的節(jié)點(diǎn)之后,再針對(duì)中間亂序部分,采用最長(zhǎng)遞增子序列的算法,計(jì)算出亂序部分可以復(fù)用的最長(zhǎng)連續(xù)節(jié)點(diǎn)。通過(guò)這個(gè)遞增數(shù)組,我們就可以知道哪些節(jié)點(diǎn)在變化前后的相對(duì)位置沒(méi)有發(fā)生變化,哪些需要移動(dòng)。
?關(guān)于最長(zhǎng)遞增子序列大家可以通過(guò)力扣的算法題了解。涉及到的動(dòng)態(tài)規(guī)劃、二分、回朔等方式在評(píng)論都有大量的提到。他的目標(biāo)依然是為了減少真實(shí) DOM 的移動(dòng)次數(shù)
https://leetcode.cn/problems/longest-increasing-subsequence/description/
總結(jié)
本文比較了 React 和 Vue 在 diff 算法上的相同的考慮與差異的處理,主要的作用是為了應(yīng)對(duì)面試中的場(chǎng)景,在實(shí)踐應(yīng)用場(chǎng)景中用處不是很大。大家可以收藏起來(lái)慢慢看。
我在最后用了非常大量的篇幅介紹 React 與 Vue 在列表中對(duì)比的差異,Vue 由于在努力追求真實(shí) DOM 能夠以最小的移動(dòng)來(lái)更新 DOM,因此在算法上更加特殊。
不過(guò)需要特別注意的是,在同一次事件循環(huán)中,由于瀏覽器在渲染時(shí)本身就會(huì)對(duì) DOM 樹(shù)進(jìn)行統(tǒng)一的處理和繪制,因此,節(jié)點(diǎn)移動(dòng) 1 次,還是移動(dòng) 100 次,只要在這一幀中的最終結(jié)果是一樣的,那么在渲染階段所消耗的計(jì)算成本實(shí)際上是一樣的,沒(méi)有太大的區(qū)別。因此,我個(gè)人的觀點(diǎn)是 Vue 這種算法對(duì)于渲染性能的提升效果應(yīng)該是非常微弱的。
