本文首發於個人 Blogreact
當你看到這個標題的時候,必定很好奇,React 不是很快麼?爲啥會變慢呢?在寫這篇文章以前,我也是這麼認爲的,可是當我去看了一下 React 有關 Array 的 Diff 以後,我才認識到其實 React 若是你用的不正確,那麼是會變慢的。算法
React Diff 算法相信你們不是很陌生吧,這裏就不具體展開講了。不過有一點要補充下,Diff 算法針對的是整個 React 組件樹,而不只僅是 DOM 樹,雖然這樣性能會比較低一些,可是實現起來卻很方便。typescript
而在 Diff 算法中,針對數組的 diff 實際上是比較有意思的一個地方。在開始講解方面,我但願你能對 React 有必定的瞭解和使用。數組
首先咱們建立 3 個組件,分別渲染 10000 個 DOM 元素,從 [1...10000]
,渲染成以下。app
const e10000 = new Array(10000).fill(0).map((_, i) => i + 1)
element10000.map(i => <div key={`${i}`}>{i}</div>)
複製代碼
每一個組件有兩個狀態,會切換數據的順序編輯器
[1...10000]
和 [2,1,3...10000]
之間切換。[1...10000]
和 [10000,1...9999]
之間切換[1...10000]
和 [10000...1]
之間切換,也就是正序和倒序之間切換。咱們簡單命名下,默認的初始狀態爲 S1
而切換以後的狀態爲 S2
。你們能夠思考一下,同一個組件狀態切換的時候,所耗費的時間是否是都是同樣的?能夠直接使用這個 DEMO。函數
能夠直接點擊上方的 toggle 來切換二者之間的狀態,並在控制檯中查看渲染的時間。由於每次時間都不是絕對準確的,因此取了屢次平均值,直接揭曉答案:性能
組件 | S2 => S1 | S1 => S2 |
---|---|---|
A | 102ms | 103ms |
B | 129ms | 546ms |
C | 556ms | 585ms |
有麼有以爲很奇怪,爲何一樣是 S1 ⇒ S2 ,一樣是隻改變了一個元素的位置,爲何 A 和 B 的時間差距有這麼多的差距。這個具體原理就要從 Diff 算法開始講起了。優化
在講 React 的實現以前,咱們先來拋開 React 的實現獨立思考一下。可是若是直接從 React 的組件角度下手會比較麻煩,首先簡化一下問題。spa
存在兩個數組 A 和 B,數組中每個值必需要保證在對應數組內是惟一的,類型能夠是字符串或者數字。那麼這個問題就轉變成了如何從數組 A 經過最少的變換步驟到數組 B。
其實每一個元素的值對應的就是 React 當中的 key。若是一個元素沒有 key 的話,index 就是那個元素默認的 key。爲何要強調最少?由於咱們但願的是可以用最少的步數完成,可是實際上這會形成計算量的加大,而 React 的實現並無計算出最優解,而是一個較快解。
順便定義一下操做的類型有:刪除元素
,插入元素
,移動元素
。
這裏又要引伸一個特殊點,React 充分利用了 DOM 的特性,在 DOM 操做中,你是能夠不使用 index 來索引數據的。簡單來說,若是用數組表示,刪除須要指定刪除元素的索引,插入須要指定插入的位置,而移動元素須要指定從哪一個索引移動到另外一個索引。而利用 DOM,咱們就能夠簡化這些操做,能夠直接刪除某個元素的實例,在某個元素前插入或者移動到這裏(利用 insertBefore
API,若是是要在添加或者移動到最後,能夠利用 append
)。這樣最大的好處是咱們不須要記錄下移動到的位置,只須要記錄下那些元素移動了便可,並且這部分操做正好能夠由 Fiber 來承擔。
舉個例子說,從 A=[1,2,3]
變化到 B=[2,3,4,1]
,那麼只須要記錄以下操做便可:
有人好奇,不須要記錄移動插入到那個元素前面麼?其實不須要的,這是由於你有了操做列表和 B 數組以後,就能夠知道目標元素在哪裏了。並且採用這種方式就根本不須要關心每次操做以後索引的變化。
回到上面的簡化後的問題,首先經過對比 A、B 數組,能夠獲得哪些元素是刪除的,哪些元素是添加的,而無論採用什麼樣子的策略,添加刪除元素的操做次數是沒法減小的。由於你不能憑空產生或者消失一個元素。那麼咱們問題就能夠再簡化一下,把全部的添加刪除的元素剔除後分別獲得數組 A' 和 B',也就是 A' 中不包含被刪除的元素,B' 中不包含被添加的元素,此時 A' 和 B' 的長度必定是同樣長的。也就是求解出最少移動次數使得數組 A' 可以轉化成數組 B'。
若是隻是簡單的求解一下最少移動步數的話,答案很簡單,就是最長上升子序列(LIS,Longest Increasing Subsequence)。關於如何證實爲何是最長不降低子序列這個算法,能夠經過簡單的反證法獲得。關於這個算法的內容我就不具體講解了,有興趣的能夠自行 Google。在這裏咱們只須要知道這個算法的時間複雜度是 O(n^2)
。
可是如今咱們還沒法直接應用這個算法,由於每一個元素的類型多是字符串或者數字,沒法比較大小。定義數組 T 爲 B' 內元素在 A' 的位置。舉個例子,若是 A' = ['a', 'b', 'c']
B' = ['b', 'c', 'a']
,那麼 T = [2, 3, 1]
。本文約定位置是從 1 開始,索引從 0 開始。
此時即可以對 T 求解 LIS,能夠獲得 [2, 3]
,咱們將剩下不在 LIS 中的元素標記爲移動元素,在這裏就是 1
,最後補上被剔除的刪除和插入的元素的操做動做。這樣 Diff 算法就能夠結束了。
上面講解的是一個我的認爲完整的 Array Diff 算法,可是仍是能夠在保證正確性上繼續優化。可是無論優化,這個複雜度對於 React 來說仍是偏高的,而如何平衡效率和最優解成爲了最頭疼的問題,好在 React 採用了一個混合算法,在犧牲掉必定正確性的前提下,將複雜度下降爲 O(n)
。下面咱們來說解下。
你們有過 React 開發經驗的人很清楚,大部分狀況下,咱們一般是這樣使用的:
情形1:一個標籤的的直接子子標籤數量類型順序不變,一般用於靜態內容或者對子組件的更新
// 好比每次渲染都是這樣的,裏面的直接子元素的類型和數量是不變的,在這種狀況下,實際上是能夠省略 key
<div>
<div key="header">header</div>
<div key="content">content</div>
<div key="footer">footer</div>
<SubCmp time={Date.now()}/>
</div>
複製代碼
情形2:一個標籤有多個子標籤,可是通常只改變其中的少數幾個子標籤。最多見的場景就是規則編輯器,每次只在最後添加新規則,或者刪除其中某個規則。固然了,滾動加載也算是這種。
情形3:交換某幾個子標籤之間的順序
情形4:翻頁操做,幾乎重置了整個子元素
上面只是簡單舉了幾個常見的例子,你們能夠發現,大部分狀況下子標籤變更的其實並很少,React 利用了這個,因此將 LIS 簡化成以第一個元素開始,找到最近上升子序列。簡單來來說就是從頭開始遍歷,只要這個元素不小於前的元素,那麼就加入隊列。
Q = [4, 1, 5, 2, 3]
// 標準算法
LIS = [1, 2, 3]
// 簡化後的算法,從第一個開始,找到最近的不降低子序列便可。
LIS_React = [4, 5]
複製代碼
咱們乍一看,這個算法不對呀,隨便就能舉出一個例子讓這個算法錯成狗,可是咱們要結合實際狀況來看。若是咱們套回前面說的幾種狀況,能夠看到對於狀況 1,2,3 來說,幾乎和簡化前效果是同樣。而這樣作以後,時間複雜度下降爲 O(n)
,空間複雜度下降爲 O(1)
。咱們給簡化後的算法叫作 LIS'
方便後面區分。
咱們將 LIS 算法簡化後,配合上以前同樣的流程就能夠得出 React 的 Array Diff 算法的核心流程了。(爲何叫核心流程,由於還有不少優化的地方沒有講)
當咱們在瞭解了 React 的實現以後,咱們再回過來頭來看看前面給出的三個例子爲啥會有這麼大的時間差距?
[1...10000]
變化到 [2,1,3...10000]
。此時咱們先求解一下 LIS'
能夠獲得 [2,3,4...10000]
,那麼咱們只須要移動 1
這個元素就能夠了,將移動到元素 3
前面。同理反過來也是如此,也就是說 S1 ⇒ S2 和 S2 ⇒ S1 的所須要移動的次數是一致的,理論上時間上也就是相同的。[1...10000]
變化到 [10000,1,2...9999]
。同理,先計算 LIS'
能夠獲得 [10000]
,沒錯,你沒看錯,就是隻有一次元素,那麼我須要將剩下的全部元素全都移動到 10000
的後面去,換句話要進行 9999 次移動。這也就是爲啥 S1 => S2
的時間會這麼慢。可是反過來卻不須要這個樣子,將狀態反過來,並從新計算索引,那麼也就是從 [1...10000]
到 [2,3....10000,1]
,在計算一次 LIS'
獲得 [2,3...10000]
,此時只須要移動一次便可,S2 ⇒ S1
的時間也就天然恢復的和組件 A 一致。LIS'
能夠獲得,[10000]
,也就是說要移動 9999 次,反過來也是要 9999 次,因此時間狀態是一致的。通過這樣的分析你們是否是就明白爲啥會變慢了吧?
上面有一點沒有講到,不知道你們有沒有思考到,我怎麼知道某個元素是該添加函數刪除呢?你們第一反應就是構建一個 Set,將數組元素全放進去,而後進行判斷就能夠了。可是在 React 中,其實用的是 Map,由於要存儲對應的 Fiber,具體細節你們能夠不用關注,只須要知道這裏用 Map 實現了這個功能。
無論怎麼樣,根據算法,一開始確定要構建一遍 Map,可是咱們來看下上面的 情形1
。發現內容是根本不會發生變化的,並且對於 情形2
來說,有很大的機率前面的大部分是相同的。
因而 React 一開始不構建 Map,而是假設前面的內容都是一致的,對這些元素直接執行普通的更新 Fiber 操做,直到碰到第一個 key 不相同的元素纔開始構建 Map 走正常的 Diff 流程。按照這個方式,情形1根本不會建立 Map,並且對於情形二、3來說也會減小不少 Map 元素的操做(set、get、has)。
按照上面的算法,咱們須要至少 3 遍循環:第一遍構建 Map,第二遍剔除添加刪除的元素生成 A' 和 B',第三遍計算 LIS 並獲得哪些元素須要移動或者刪除。而咱們發現第二遍和第三遍是能夠合併在一塊兒的。也便是說咱們在有了 Map 的狀況下,不須要剔除元素,當遍歷發現這個元素是新增的時候,直接記錄下來。
關於 Diff 算法其實還有不少的細節,我這邊沒有過多講解,由於比較簡單,比較符合直覺。你們有興趣的能夠本身去看下。另外有人應該會注意到,上面的例子中,爲何切換一樣的次數,有的時間長,有的時間短了。往後有時間再分析下補充了。