文章首發於 我的博客
這是我 Deep In React 系列的第二篇文章,若是尚未讀過的強烈建議你先讀第一篇:詳談 React Fiber 架構(1)。javascript
我相信在看這篇文章的讀者通常都已經瞭解過 React 16 之前的 Diff 算法了,這個算法也算是 React 跨時代或者說最有影響力的一點了,使 React 在保持了可維護性的基礎上性能大大的提升,但 Diff 過程不只不是免費的,並且對性能影響很大,有時候更新頁面的時候每每 Diff 所花的時間 js 運行時間比 Rendering 和 Painting 花費更多的時間,因此我一直傳達的觀念是 React 或者說框架的意義是爲了提升代碼的可維護性,而不是爲了提升性能的,如今所作的提高性能的操做,只是在可維護性的基礎上對性能的優化。具體能夠參考我公衆號之前發的這兩篇文章:前端
若是你對標題不滿意,請把文章看完,至少也得把文章最後的結論好好看下
在上一篇將 React Fiber 架構中,已經說到過,React 如今將總體的數據結構從樹改成了鏈表結構。因此相應的 Diff 算法也得改變,覺得之前的 Diff 算法就是基於樹的。java
老的 Diff 算法提出了三個策略來保證總體界面構建的性能,具體是:node
基於以上三個前提策略,React 分別對 tree diff、component diff 以及 element diff 進行算法優化。react
具體老的算法能夠見這篇文章:React 源碼剖析系列 - 難以想象的 react diffgit
說實話,老的 Diff 算法仍是挺複雜的,你僅僅看上面這篇文章估計一時半會都不能理解,更別說看源碼了。對於 React 16 的 Diff 算法(我以爲都不能把它稱做算法,最多叫個 Diff 策略)其實仍是蠻簡單的,React 16 是整個調度流程感受比較難,我在前面將 Fiber 的文章已經簡單的梳理過了,後面也會慢慢的逐個攻破。github
接下來就開始正式的講解 React 16 的 Diff 策略吧!算法
作 Diff 的目的就是爲了複用節點。數組
鏈表的每個節點是 Fiber,而不是在 16 以前的虛擬DOM 節點。性能優化
我這裏說的虛擬 DOM 節點是指 React.createElement 方法所產生的節點。虛擬 DOM tree 只維護了組件狀態以及組件與 DOM 樹的關係,Fiber Node 承載的東西比 虛擬 DOM 節點多不少。
Diff 就是新舊節點的對比,在上一篇中也說道了,這裏面的 Diff 主要是構建 currentInWorkProgress 的過程,同時獲得 Effect List,給下一個階段 commit 作準備。
React16 的 diff 策略採用從鏈表頭部開始比較的算法,是層次遍歷,算法是創建在一個節點的插入、刪除、移動等操做都是在節點樹的同一層級中進行的。
對於 Diff, 新老節點的對比,咱們以新節點爲標準,而後來構建整個 currentInWorkProgress,對於新的 children 會有四種狀況。
那麼咱們就來一步一步的看這四種類型是如何進行 diff 的。
這篇文章主要是從 React 的源碼的邏輯出發介紹的,因此介紹以前瞭解下只怎麼進入到這個 diff 函數的,react 的 diff 算法是從 reconcileChildren
開始的
export function reconcileChildren( current: Fiber | null, workInProgress: Fiber, nextChildren: any, renderExpirationTime: ExpirationTime, ) { if (current === null) { workInProgress.child = mountChildFibers( workInProgress, null, nextChildren, renderExpirationTime, ); } else { workInProgress.child = reconcileChildFibers( workInProgress, current.child, nextChildren, renderExpirationTime, ); } }
reconcileChildren
只是一個入口函數,若是首次渲染,current 空 null,就經過 mountChildFibers
建立子節點的 Fiber 實例。若是不是首次渲染,就調用 reconcileChildFibers
去作 diff,而後得出 effect list。
接下來再看看 mountChildFibers 和 reconcileChildFibers 有什麼區別:
export const reconcileChildFibers = ChildReconciler(true); export const mountChildFibers = ChildReconciler(false);
他們都是經過 ChildReconciler
函數來的,只是傳遞的參數不一樣而已。這個參數叫shouldTrackSideEffects
,他的做用是判斷是否要增長一些effectTag
,主要是用來優化初次渲染的,由於初次渲染沒有更新操做。
function reconcileChildFibers( returnFiber: Fiber, currentFirstChild: Fiber | null, newChild: any, expirationTime: ExpirationTime, ): Fiber | null { // 主要的 Diff 邏輯 }
reconcileChildFibers
就是 Diff 部分的主體代碼,這個函數超級長,是一個包裝函數,下面全部的 diff 代碼都在這裏面,詳細的源碼註釋能夠見這裏。
參數介紹
returnFiber
是即將 Diff 的這層的父節點。currentFirstChild
是當前層的第一個 Fiber 節點。newChild
是即將更新的 vdom 節點(多是 TextNode、多是 ReactElement,多是數組),不是 Fiber 節點expirationTime
是過時時間,這個參數是跟調度有關係的,本系列還沒講解,固然跟 Diff 也沒有關係。再次提醒,reconcileChildFibers 是 reconcile(diff) 的一層。
前置知識介紹完畢,就開始詳細介紹每一種節點是如何進行 Diff 的。
首先看 TextNode,由於它是最簡單的,擔憂直接看到難的,而後就打擊你的信心。
看下面兩個小 demo:
// demo1:當前 ui 對應的節點的 jsx return ( <div> // ... <div> <xxx></xxx> <xxx></xxx> </div> //... </div> ) // demo2:更新成功後的節點對應的 jsx return ( <div> // ... <div> 前端桃園 </div> //... </div> )
對應的單鏈表結構圖:
對於 diff TextNode 會有兩種狀況。
currentFirstNode 是當前該層的第一個節點,reconcileChildFibers 傳進來的參數。
爲何要分兩種狀況呢?緣由就是爲了複用節點
第一種狀況。xxx 是一個 TextNode,那麼就表明這這個節點能夠複用,有複用的節點,對性能優化頗有幫助。既然新的 child 只有一個 TextNode,那麼複用節點以後,就把剩下的 aaa 節點就能夠刪掉了,那麼 div 的 child 就能夠添加到 workInProgress 中去了。
源碼以下:
if (currentFirstChild !== null && currentFirstChild.tag === HostText) { // We already have an existing node so let's just update it and delete // the rest. deleteRemainingChildren(returnFiber, currentFirstChild.sibling); const existing = useFiber(currentFirstChild, textContent, expirationTime); existing.return = returnFiber; return existing; }
在源碼裏 useFiber
就是複用節點的方法,deleteRemainingChildren
就是刪除剩餘節點的方法,這裏是從 currentFirstChild.sibling
開始刪除的。
第二種狀況。xxx 不是一個 TextNode,那麼就表明這個節點不能複用,因此就從 currentFirstChild
開始刪掉剩餘的節點,對應到上面的圖中就是刪除掉 xxx 節點和 aaa 節點。
對於源碼以下:
deleteRemainingChildren(returnFiber, currentFirstChild); const created = createFiberFromText( textContent, returnFiber.mode, expirationTime, ); created.return = returnFiber;
其中 createFiberFromText
就是根據 textContent
來建立節點的方法。
注意:刪除節點不會真的從鏈表裏面把節點刪除,只是打一個 delete 的 tag,當 commit 的時候纔會真正的去刪除。
有了上面 TextNode 的 Diff 經驗,那麼來理解 React Element 的 Diff 就比較簡單了,由於他們的思路是一致的:先找有沒有能夠複用的節點,若是沒有就另外建立一個。
那麼就有一個問題,如何判斷這個節點是否能夠複用呢?
有兩個點:1. key 相同。 2. 節點的類型相同。
若是以上兩點相同,就表明這個節點只是變化了內容,不須要建立新的節點,能夠複用的。
對應的源碼以下:
if (child.key === key) { if ( child.tag === Fragment ? element.type === REACT_FRAGMENT_TYPE : child.elementType === element.type ) { // 爲何要刪除老的節點的兄弟節點? // 由於當前節點是隻有一個節點,而老的若是是有兄弟節點是要刪除的,是多於的。刪掉了以後就能夠複用老的節點了 deleteRemainingChildren(returnFiber, child.sibling); // 複用當前節點 const existing = useFiber( child, element.type === REACT_FRAGMENT_TYPE ? element.props.children : element.props, expirationTime, ); existing.ref = coerceRef(returnFiber, child, element); existing.return = returnFiber; return existing; }
相信這些代碼都很好理解了,除了判斷條件跟前面 TextNode 的判斷條件不同,其他的基本都同樣,只是 React Element 多了一個跟新 ref 的過程。
一樣,若是節點的類型不相同,就將節點從當前節點開始把剩餘的都刪除。
deleteRemainingChildren(returnFiber, child);
到這裏,可能大家就會以爲接下來應該就是講解當沒有能夠複用的節點的時候是若是建立節點的。
不過惋惜大家猜錯了。由於 Facebook 的工程師很厲害,另外還作了一個工做來優化,來找到複用的節點。
咱們如今來看這種狀況:
這種狀況就是有可能更新的時候刪除了一個節點,可是另外的節點還留着。
那麼在對比 xxx 節點和 AAA 節點的時候,它們的節點類型是不同,按照咱們上面的邏輯,仍是應該把 xxx 和 AAA 節點刪除,而後建立一個 AAA 節點。
可是你看,明明 xxx 的 slibling 有一個 AAA 節點能夠複用,可是被刪了,多浪費呀。因此還有另外有一個策略來找 xxx 的全部兄弟節點中有沒有能夠複用的節點。
這種策略就是從 div 下面的全部子節點去找有沒有能夠複用的節點,而不是像 TextNode 同樣,只是找第一個 child 是否能夠複用,若是當前節點的 key 不一樣,就表明確定不是同一個節點,因此把當前節點刪除,而後再去找當前節點的兄弟節點,直到找到 key 相同,而且節點的類型相同,不然就刪除全部的子節點。
你有木有這樣的問題:爲何 TextNode 不採用這樣的循環策略來找能夠複用的節點呢?這個問題留給你思考,歡迎在評論區留下你的答案。
對應的源碼邏輯以下:
// 找到 key 相同的節點,就會複用當前節點 while (child !== null) { if (child.key === key) { if ( child.tag === Fragment ? element.type === REACT_FRAGMENT_TYPE : child.elementType === element.type ) { // 複用節點邏輯,省略該部分代碼,和上面複用節點的代碼相同 // code ... return existing; } else { deleteRemainingChildren(returnFiber, child); break; } } else { // 若是沒有能夠複用的節點,就把這個節點刪除 deleteChild(returnFiber, child); } child = child.sibling; }
在上面這段代碼咱們須要注意的是,當 key 相同,React 會認爲是同一個節點,因此當 key 相同,節點類型不一樣的時候,React 會認爲你已經把這個節點從新覆蓋了,因此就不會再去找剩餘的節點是否能夠複用。只有在 key 不一樣的時候,纔會去找兄弟節點是否能夠複用。
接下來纔是咱們前面說的,若是沒有找到能夠複用的節點,而後就從新建立節點,源碼以下:
// 前面的循環已經把該刪除的已經刪除了,接下來就開始建立新的節點了 if (element.type === REACT_FRAGMENT_TYPE) { const created = createFiberFromFragment( element.props.children, returnFiber.mode, expirationTime, element.key, ); created.return = returnFiber; return created; } else { const created = createFiberFromElement( element, returnFiber.mode, expirationTime, ); created.ref = coerceRef(returnFiber, currentFirstChild, element); created.return = returnFiber; return created; }
對於 Fragment 節點和通常的 Element 節點建立的方式不一樣,由於 Fragment 原本就是一個無心義的節點,他真正須要建立 Fiber 的是它的 children,而不是它本身,因此 createFiberFromFragment
傳遞的不是 element
,而是 element.props.children
。
Diff Array 算是 Diff 中最難的一部分了,比較的複雜,由於作了不少的優化,不過請你放心,認真看完個人講解,最難的也會很容易理解,廢話很少說,開始吧!
由於 Fiber 樹是單鏈表結構,沒有子節點數組這樣的數據結構,也就沒有能夠供兩端同時比較的尾部遊標。因此React的這個算法是一個簡化的兩端比較法,只從頭部開始比較。
前面已經說了,Diff 的目的就是爲了複用,對於 Array 就不能像以前的節點那樣,僅僅對比一下元素的 key 或者 元素類型就行,由於數組裏面是好多個元素。你能夠在頭腦裏思考兩分鐘如何進行復用節點,再看 React 是怎麼作的,而後對比一下孰優孰劣。
相同位置進行對比,這個是比較容易想到的一種方式,仍是舉個例子加深一下印象。
這已是一個很是簡單的例子了,div 的 child 是一個數組,有 AAA、BBB 而後還有其餘的兄弟節點,在作 diff 的時候就能夠重新舊的數組中按照索引一一對比,若是能夠複用,就把這個節點從老的鏈表裏面刪除,不能複用的話再進行其餘的複用策略。
那若是判斷節點是否能夠複用呢?有了前面的 ReactElement 和 TextNode 複用的經驗,這個也相似,由於是一一對比嘛,至關因而一個節點一個節點的對比。
不過對於 newChild 可能會有不少種類型,簡單的看下源碼是如何進行判斷的。
const key = oldFiber !== null ? oldFiber.key : null;
前面的經驗可得,判斷是否能夠複用,經常會根據 key 是否相同來決定,因此首先獲取了老節點的 key 是否存在。若是不存在老節點極可能是 TextNode 或者是 Fragment。
接下來再看 newChild 爲不一樣類型的時候是如何進行處理的。
當 newChild 是 TextNode 的時候
if (typeof newChild === 'string' || typeof newChild === 'number') { // 對於新的節點若是是 string 或者 number,那麼都是沒有 key 的, // 全部若是老的節點有 key 的話,就不能複用,直接返回 null。 // 老的節點 key 爲 null 的話,表明老的節點是文本節點,就能夠複用 if (key !== null) { return null; } return updateTextNode( returnFiber, oldFiber, '' + newChild, expirationTime, ); }
若是 key 不爲 null,那麼就表明老節點不是 TextNode,而新節點又是 TextNode,因此返回 null,不能複用,反之則能夠複用,調用 updateTextNode
方法。
注意,updateTextNode 裏面包含了首次渲染的時候的邏輯,首次渲染的時候回插入一個 TextNode,而不是複用。
當 newChild 是 Object 的時候
newChild 是 Object 的時候基本上走的就是 ReactElement 的邏輯了,判斷 key 和 元素的類型是否相等來判斷是否能夠複用。
if (typeof newChild === 'object' && newChild !== null) { // 有 $$typeof 表明就是 ReactElement switch (newChild.$$typeof) { case REACT_ELEMENT_TYPE: { // ReactElement 的邏輯 } case REACT_PORTAL_TYPE: { // 調用 updatePortal } } if (isArray(newChild) || getIteratorFn(newChild)) { if (key !== null) { return null; } return updateFragment( returnFiber, oldFiber, newChild, expirationTime, null, ); } }
首先判斷是不是對象,用的是 typeof newChild === 'object' && newChild !== null
,注意要加 !== null
,由於 typeof null
也是 object。
而後經過 $$typeof 判斷是 REACT_ELEMENT_TYPE 仍是 REACT_PORTAL_TYPE,分別調用不一樣的複用邏輯,而後因爲數組也是 Object ,因此這個 if 裏面也有數組的複用邏輯。
我相信到這裏應該對於應該對於如何相同位置的節點如何對比有清晰的認識了。另外還有問題,那就是如何循環一個一個對比呢?
這裏要注意,新的節點的 children 是虛擬 DOM,因此這個 children 是一個數組,而對於以前提到的老的節點樹是鏈表。
那麼循環一個一個對比,就是遍歷數組的過程。
let newIdx = 0 // 新數組的索引 for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) { // 遍歷老的節點 nextOldFiber = oldFiber.sibling; // 返回複用節點的函數,newFiber 就是複用的節點。 // 若是爲空,就表明同位置對比已經不能複用了,循環結束。 const newFiber = updateSlot( returnFiber, oldFiber, newChildren[newIdx], expirationTime, ); if (newFiber === null) { break; } // 其餘 code,好比刪除複用的節點 }
這並非源碼的所有源碼,我只是把思路給貼出來了。
這是第一次遍歷新數組,經過調用 updateSlot
來對比新老元素,前面介紹的如何對比新老節點的代碼都是在這個函數裏。這個循環會把因此的從前面開始能複用的節點,都複用到。好比上面咱們畫的圖,若是兩個鏈表裏面的 ???節點,不相同,那麼 newFiber 爲 null,這個循環就會跳出。
跳出來了,就會有兩種狀況。
若是新節點已經遍歷完畢的話,也就是沒有要更新的了,這種狀況通常就是從原來的數組裏面刪除了元素,那麼直接把剩下的老節點刪除了就好了。仍是拿上面的圖的例子舉例,老的鏈表裏???還有不少節點,而新的鏈表???已經沒有節點了,因此老的鏈表???無論是有多少節點,都不能複用了,因此沒用了,直接刪除。
if (newIdx === newChildren.length) { // 新的 children 長度已經夠了,因此把剩下的刪除掉 deleteRemainingChildren(returnFiber, oldFiber); return resultingFirstChild; }
注意這裏是直接 return
了哦,沒有繼續往下執行了。
若是老的節點在第一次循環的時候就被複用完了,新的節點還有,頗有可能就是新增了節點的狀況。那麼這個時候只須要根據把剩餘新的節點直接建立 Fiber 就好了。
if (oldFiber === null) { // 若是老的節點已經被複用完了,對剩下的新節點進行操做 for (; newIdx < newChildren.length; newIdx++) { const newFiber = createChild( returnFiber, newChildren[newIdx], expirationTime, ); } return resultingFirstChild; }
oldFiber === null
就是用來判斷老的 Fiber 節點變量完了的代碼,Fiber 鏈表是一個單向鏈表,因此爲 null 的時候表明已經結束了。因此就直接把剩餘的 newChild 經過循環建立 Fiber。
到這裏,目前簡單的對數組進行增、刪節點的對比仍是比較簡單,接下來就是移動的狀況是如何進行復用的呢?
對於移動的狀況,首先要思考,怎麼能判斷數組是否發生過移動操做呢?
若是給你兩個數組,你是否能判斷出來數組是否發生過移動。
答案是:老的數組和新的數組裏面都有這個元素,並且位置不相同。
從兩個數組中找到相同元素(是指可複用的節點),方法有不少種,來看看 React 是如何高效的找出來的。
把全部老數組元素按 key 或者是 index 放 Map 裏,而後遍歷新數組,根據新數組的 key 或者 index 快速找到老數組裏面是否有可複用的。
function mapRemainingChildren( returnFiber: Fiber, currentFirstChild: Fiber, ): Map<string | number, Fiber> { const existingChildren: Map<string | number, Fiber> = new Map(); let existingChild = currentFirstChild; // currentFirstChild 是老數組鏈表的第一個元素 while (existingChild !== null) { // 看到這裏可能會疑惑怎麼在 Map 裏面的key 是 fiber 的key 仍是 fiber 的 index 呢? // 我以爲是根據數據類型,fiber 的key 是字符串,而 index 是數字,這樣就能區分了 // 因此這裏是用的 map,而不是對象,若是是對象的key 就不能區分 字符串類型和數字類型了。 if (existingChild.key !== null) { existingChildren.set(existingChild.key, existingChild); } else { existingChildren.set(existingChild.index, existingChild); } existingChild = existingChild.sibling; } return existingChildren; }
這個 mapRemainingChildren
就是將老數組存放到 Map 裏面。元素有 key 就 Map 的鍵就存 key,沒有 key 就存 index,key 必定是字符串,index 必定是 number,因此取的時候是能區分的,因此這裏用的是 Map,而不是對象,若是是對象,屬性是字符串,就沒辦法區別是 key 仍是 index 了。
如今有了這個 Map,剩下的就是循環新數組,找到 Map 裏面能夠複用的節點,若是找不到就建立,這個邏輯基本上跟 updateSlot
的複用邏輯很像,一個是從老數組鏈表中獲取節點對比,一個是從 Map 裏獲取節點對比。
// 若是前面的算法有複用,那麼 newIdx 就不從 0 開始 for (; newIdx < newChildren.length; newIdx++) { const newFiber = updateFromMap( existingChildren, returnFiber, newIdx, newChildren[newIdx], expirationTime, ); // 省略刪除 existingChildren 中的元素和添加 Placement 反作用的狀況 }
到這裏新數組遍歷完畢,也就是同一層的 Diff 過程完畢,接下來進行總結一下。
如下效果動態演示來自於文章:React Diff 源碼分析,我以爲這個演示很是的形象,有助於理解。
這裏渲染一個可輸入的數組。
當第一種狀況,新數組遍歷完了,老數組剩餘直接刪除(12345→1234 刪除 5):
新數組沒完,老數組完了(1234→1234567 插入 567):
移動的狀況,即以前就存在這個元素,後續只是順序改變(123 → 4321 插入4,移動2 1):
最後刪除沒有涉及的元素。
對於數組的 diff 策略,相對比較複雜,最後來梳理一下這個策略,其實仍是很簡單,只是看源碼的時候比較難懂。
咱們能夠把整個過程分爲三個階段:
updateSlot
方法找到能夠複用的節點,直到找到不能夠複用的節點就退出循環。剛開始閱讀源碼的過程是很是的痛苦的,可是當你一遍一遍的把做者想要表達的理解了,爲何要這麼寫 理解了,會感到做者的設計是如此的精妙絕倫,每個變量,每一行代碼感受都是精心設計過的,而後感覺到本身與大牛的差距,激發本身的動力。
更多的對於 React 原理相關,源碼相關的內容,請關注個人 github:Deep In React 或者 我的博客:桃園
我是桃翁,一個愛思考的前端er,想了解關於更多的前端相關的,請關注個人公號:「前端桃園」