來源 | https://segmentfault.com/a/1190000039021724
fiber上的updateQueue經過React的一番計算之後,這個fiber已經有了新的狀態,也就是state,對於類組件來說,state是在render函數裡被使用的,既然已經得到了新的state,那麼當務之急是執行一次render,得到持有新state的ReactElement。假設render一次之後得到了大量的ReactElement,而這些ReactElement之中若只有少量需要更新的節點,那麼顯然不能全部去更新它們,此時就需要有一個diff過程來決定哪些節點是真正需要更新的。源碼結構我們以類組件為例,state的計算發生在類組件對應的fiber節點beginWork中的updateClassInstance函數中,在狀態計算完畢之後,緊跟著就是去調finishClassComponent執行diff、打上effectTag(即新版本的flag)。打上effectTag可以標識這個fiber發生了怎樣的變化,例如:新增(Placement)、更新(Update)、刪除(Deletion),這些被打上flag的fiber會在complete階段被收集起來,形成一個effectList鍊表,只包含這些需要操作的fiber,最後在commit階段被更新掉。function updateClassComponent( current: Fiber | null, workInProgress: Fiber, Component: any, nextProps: any, renderLanes: Lanes,) { ... shouldUpdate = updateClassInstance( current, workInProgress, Component, nextProps, renderLanes, ); ... const nextUnitOfWork = finishClassComponent( current, workInProgress, Component, shouldUpdate, hasContext, renderLanes, ); return nextUnitOfWork; }在finishClassComponent函數中,調用reconcileChildFibers去做diff,而reconcileChildFibers實際上就是ChildReconciler,這是diff的核心函數,
該函數針對組件render生成的新節點的類型,調用不同的函數進行處理。function ChildReconciler(shouldTrackSideEffects) { ... function reconcileSingleElement( returnFiber: Fiber, currentFirstChild: Fiber | null, element: ReactElement, lanes: Lanes,): Fiber { } function reconcileChildrenArray( returnFiber: Fiber, currentFirstChild: Fiber | null, newChildren: Array<*>, lanes: Lanes,): Fiber | null { } ... function reconcileChildFibers( returnFiber: Fiber, currentFirstChild: Fiber | null, newChild: any, lanes: Lanes,): Fiber | null { const isObject = typeof newChild === 'object' && newChild !== null; if (isObject) { switch (newChild.$$typeof) { case REACT_ELEMENT_TYPE: return placeSingleChild( reconcileSingleElement( returnFiber, currentFirstChild, newChild, lanes, ), ); case REACT_PORTAL_TYPE: ... case REACT_LAZY_TYPE: ... } } if (typeof newChild === 'string' || typeof newChild === 'number') { } if (isArray(newChild)) { return reconcileChildrenArray( returnFiber, currentFirstChild, newChild, lanes, ); } ... } return reconcileChildFibers; }Diff的主體關於Diff的參與者,在reconcileChildren函數的入參中可以看出
workInProgress.child = reconcileChildFibers( workInProgress, current.child, nextChildren, renderLanes, );
workInProgress:作為父節點傳入,新生成的第一個fiber的return會被指向它。
current.child:舊fiber節點,diff生成新fiber節點時會用新生成的ReactElement和它作比較。
nextChildren:新生成的ReactElement,會以它為標準生成新的fiber節點。
renderLanes:本次的渲染優先級,最終會被掛載到新fiber的lanes屬性上。
可以看出,diff的兩個主體是:oldFiber(current.child)和newChildren(nextChildren,新的ReactElement),它們是兩個不一樣的數據結構。
比如現在有組件<Example/>,它計算完新的狀態之後,要基於這兩個東西去做diff,分別是現有fiber樹中(current樹)<Example/>對應fiber的所有子fiber節點和<Example/>的render函數的執行結果,即那些ReactElements。
<Example/>對應fiber的所有子fiber節點:oldFiber
current樹中 <Example/> fiber | | A --sibling---> B --sibling---> C<Example/>的render函數的執行結果,newChildren
current fiber 對應的組件render的結果 [ {$$typeof: Symbol(react.element), type: "div", key: "A" }, {$$typeof: Symbol(react.element), type: "div", key: "B" }, {$$typeof: Symbol(react.element), type: "div", key: "B" }, ]Diff的基本原則對於新舊兩種結構來說,場景有節點自身更新、節點增刪、節點移動三種情況。面對複雜的情況,即使最前沿的算法,複雜度也極高。面對這種情況,React以如下策略應對:
舊<div> <span>a</span> <span>b</span></div>
新<p> <span>a</span> <span>b</span></p>舊<p key="a">aa</p><h1 key="b">bb</h1>
新<h1 key="b">bb</h1><p key="a">aa</p>因為tag 和 key的存在,所以React可以知道這兩個節點只是位置發生了變化。
場景上面說到diff算法應對三種場景:節點更新、節點增刪、節點移動,但一個fiber的子元素有可能是單節點,也有可能是多節點。所以依據這兩類節點可以再細分為:
單節點更新、單節點增刪。
多節點更新、多節點增刪、多節點移動。
什麼是節點的更新呢?對於DOM節點來說,在前後的節點類型(tag)和key都相同的情況下,節點的屬性發生了變化,是節點更新。若前後的節點tag或者key不相同,Diff算法會認為新節點和舊節點毫無關係。
以下例子中,key為b的新節點的className發生了變化,是節點更新。
舊<div className={'a'} key={'a'}>aa</div><div className={'b'} key={'b'}>bb</div>
新<div className={'a'} key={'a'}>aa</div><div className={'bcd'} key={'b'}>bb</div>以下例子中,新節點的className雖然有變化,但key也變化了,不屬於節點更新
舊<div className={'a'} key={'a'}>aa</div><div className={'b'} key={'b'}>bb</div>
新<div className={'a'} key={'a'}>aa</div><div className={'bcd'} key={'bbb'}>bb</div>以下例子中,新節點的className雖然有變化,但tag也變化了,不屬於節點更新
舊<div className={'a'} key={'a'}>aa</div><div className={'b'} key={'b'}>bb</div>
新<div className={'a'} key={'a'}>aa</div><p className={'bcd'} key={'b'}>bb</p>下面來分開敘述一下單節點和多節點它們各自的更新策略。
單節點若組件產出的元素是如下的類型:
那麼它最終產出的ReactElement為下面這樣(省略了一些與diff相關度不大的屬性)
{ $$typeof: Symbol(react.element), type: "div", key: "a" ...}單節點指newChildren為單一節點,但是oldFiber的數量不一定,所以實際有如下三種場景:
為了降低理解成本,我們用簡化的節點模型來說明問題,字母代表key。
對於單節點的diff,其實就只有更新操作,不會涉及位移和位置的變化,單節點的更新會調用reconcileSingleElement函數處理。該函數中對以上三種場景都做了覆蓋。但實際上面的情況對於React來說只是兩種,oldFiber鏈是否為空。因此,在實現上也只處理了這兩種情況。
oldFiber鏈不為空遍歷它們,找到key相同的節點,然後刪除剩下的oldFiber節點,再用匹配的oldFiber,newChildren中新節點的props來生成新的fiber節點。
function reconcileSingleElement( returnFiber: Fiber, currentFirstChild: Fiber | null, element: ReactElement, lanes: Lanes): Fiber { const key = element.key; let child = currentFirstChild; while (child !== null) { if (child.key === key) { switch (child.tag) { case Fragment: ... case Block: ... default: { if (child.elementType === element.type) { deleteRemainingChildren(returnFiber, child.sibling); const existing = useFiber(child, element.props); existing.ref = coerceRef(returnFiber, child, element); existing.return = returnFiber; return existing; } break; } } deleteRemainingChildren(returnFiber, child); break; } else { deleteChild(returnFiber, child); } child = child.sibling; } ... }oldFiber鏈為空對於沒有oldFiber節點的情況,只能新建newFiber節點。邏輯不複雜。
function reconcileSingleElement( returnFiber: Fiber, currentFirstChild: Fiber | null, element: ReactElement, lanes: Lanes): Fiber { const key = element.key; let child = currentFirstChild; while (child !== null) { ... } if (element.type === REACT_FRAGMENT_TYPE) { ... } else { const created = createFiberFromElement(element, returnFiber.mode, lanes); created.ref = coerceRef(returnFiber, currentFirstChild, element); created.return = returnFiber; return created; } }單節點的更新就是這樣的處理,真正比較複雜的情況是多節點的diff。因為它涉及到節點的增刪和位移。
多節點若組件最終產出的DOM元素是如下這樣:
<div key="a">aa</div><div key="b">bb</div><div key="c">cc</div><div key="d">dd</div>那麼最終的newChildren為下面這樣(省略了一些與diff相關度不大的屬性)
[ {$$typeof: Symbol(react.element), type: "div", key: "a" }, {$$typeof: Symbol(react.element), type: "div", key: "b" }, {$$typeof: Symbol(react.element), type: "div", key: "c" }, {$$typeof: Symbol(react.element), type: "div", key: "d" }]多節點的變化有以下四種可能性。
舊:A - B - C新:A - B - C - `D - E`舊:A - B - C - `D - E`新:A - B - C舊:A - B - C - D - E新:A - B - `D - C - E`多節點的情況一定是屬於這四種情況的任意組合,這種情況會調用reconcileChildrenArray進行diff。按照以上四種情況,它會以newChildren為主體進行最多三輪遍歷,但這三輪遍歷並不是相互獨立的,事實上只有第一輪是從頭開始的,之後的每一輪都是上輪結束的斷點繼續。
實際上在平時的實踐中,節點自身的更新是最多的,所以Diff算法會優先處理更新的節點。因此四輪遍歷又可以按照場景分為兩部分:
第一輪是針對節點自身屬性更新,剩下的兩輪依次處理節點的新增、移動,而重點又在移動節點的處理上,所以本文會著重講解節點更新和節點移動的處理,對刪除和新增簡單帶過。
節點更新第一輪從頭開始遍歷newChildren,會逐個與oldFiber鏈中的節點進行比較,判斷節點的key或者tag是否有變化。
let newIdx = 0;for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) { ... const newFiber = updateSlot( returnFiber, oldFiber, newChildren[newIdx], lanes, ); if (newFiber === null) { if (oldFiber === null) { oldFiber = nextOldFiber; } break; } ... }我們來看一個例子,假設新舊的節點如下:
舊:A - B - C - D - E
新:A - B - D - C在本輪遍歷中,會遍歷A - B - D - C。A和B都是key沒變的節點,可以直接復用,但當遍歷到D時,發現key變化了,跳出當前遍歷。例子中A 和 B是自身發生更新的節點,後面的D 和 C我們看到它的位置相對於oldFiber鏈發生了變化,會往下走到處理移動節點的循環中。
關於移動節點的參照物
為了方便說明,把保留在原位的節點稱為固定節點。經過這次循環的處理,可以看出固定節點是A 和 B。在newChildren中,最靠右的固定節點的位置至關重要,對於後續的移動節點的處理來說,它的意義是提供參考位置。所以,每當處理到最後一個固定節點時,要記住此時它的位置,這個位置就是lastPlacedIndex。關鍵代碼如下:
let newIdx = 0;for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) { ... ... lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); ...}placeChild方法實際上是移動節點的方法,但當節點無需移動的時候,會返回當前節點的位置,對於固定節點來說,因為無需移動,所以返回的就是固定節點的index。
節點刪除我們沒有提到對刪除節點的處理,實際上刪除節點比較簡單。
舊:A - B - C - D - E
新:A - B - C因為遍歷的是newChildren,當它遍歷結束,但oldFiber鏈還沒有遍歷完,那麼說明剩下的節點都要被刪除。直接在oldFiber節點上標記Deletion的effectTag來實現刪除。
if (newIdx === newChildren.length) { deleteRemainingChildren(returnFiber, oldFiber); return resultingFirstChild;}deleteRemainingChildren調用了deleteChild,值得注意的是,刪除不僅僅是標記了effectTag為Deletion,還會將這個被刪除的fiber節點添加到父級的effectList中。
function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void { ... const last = returnFiber.lastEffect; if (last !== null) { last.nextEffect = childToDelete; returnFiber.lastEffect = childToDelete; } else { returnFiber.firstEffect = returnFiber.lastEffect = childToDelete; } childToDelete.nextEffect = null; childToDelete.effectTag = Deletion;}節點新增新增節點的場景也很好理解,當oldFiber鏈遍歷完,但newChildren還沒遍歷完,那麼餘下的節點都屬於新插入的節點,會新建fiber節點並以sibling為指針連成fiber鏈。
舊:A - B - C
新:A - B - C - D - E插入的邏輯(省略了相關度不高的代碼)
if (oldFiber === null) { for (; newIdx < newChildren.length; newIdx++) { const newFiber = createChild(returnFiber, newChildren[newIdx], lanes); ... if (previousNewFiber === null) { resultingFirstChild = newFiber; } else { previousNewFiber.sibling = newFiber; } previousNewFiber = newFiber; } return resultingFirstChild;}節點移動節點的移動是如下場景:
舊 A - B - C - D - E - F
新 A - B - D - C - E經過第一輪遍歷的處理,固定節點為A B,最新的固定節點的位置(lastPlacedIndex)為1(B的位置)。此時oldFiber鏈中還剩C - D - E - F,newChildren中還剩D - C - E。
接下來的邏輯對於位置不一樣的節點,它自己會先更新再移動。因為此時剩餘的節點位置變了,更新又要復用oldFiber節點,所以為了在更新時方便查找,會將剩餘的oldFiber節點放入一個以key為鍵,值為oldFiber節點的map中。稱為existingChildren。
由於newChildren 和 oldFiber節點都沒遍歷完,說明需要移動位置。此刻需要明確一點,就是這些節點都在最新的固定節點的右邊。
移動的邏輯是:newChildren中剩餘的節點,都是不確定要不要移動的,遍歷它們,每一個都去看看這個節點在oldFiber鏈中的位置(舊位置),遍歷到的節點有它在newChildren中的位置(新位置):
如果舊位置在lastPlacedIndex的右邊,說明這個節點位置不變。
原因是舊位置在lastPlacedIndex的右邊,而新節點的位置也在它的右邊,所以它的位置沒變化。因為位置不變,所以它成了固定節點,把lastPlacedIndex更新成新位置。
如果舊位置在lastPlacedIndex的左邊,當前這個節點的位置要往右挪。
原因是舊位置在lastPlacedIndex的左邊,新位置卻在lastPlacedIndex的右邊,所以它要往右挪,但它不是固定節點。此時無需更新lastPlacedIndex。
我們來用上邊的例子過一下這部分邏輯。
舊 A - B - C - D - E - F
新 A - B - D - C - E位置固定部分 A - B,最右側的固定節點為B,lastPlacedIndex為1。這時剩餘oldFiber鏈為C - D - E - F,existingChildren為
{ C: '節點C', D: '節點D', E: '節點E', F: '節點F'}newChildren的剩餘部分D - C - E繼續遍歷。
首先遍歷到D,D在oldFiber鏈中(A - B - C - D - E)的位置為3
3 > 1,oldFiber中D的位置在B的右邊,newChildren中也是如此,所以D的位置不動,此時最新的固定節點變成了D,更新lastPlacedIndex為3。並從existingChildren中刪除D,
{ C: '節點C', E: '節點E', F: '節點F'}再遍歷到C,C在oldFiber鏈中(A - B - C - D - E)的索引為2
2 < 3,C原來在最新固定節點(D)的左邊,newChildren中C在D的右邊,所以要給它移動到右邊。並從existingChildren中刪除C。
再遍歷到E,E在oldFiber鏈中(A - B - C - D - E)的位置為4
4 > 3,oldFiber鏈中E位置在D的位置的右邊,新位置中也是如此,所以E的位置不動,此時最新的固定節點變成了E,更新lastPlacedIndex為4。並從existingChildren中刪除E,
這個時候newChildren都處理完了,針對移動節點的遍歷結束。此時還剩一個F節點,是在oldFiber鏈中的,因為newChildren都處理完了,所以將它刪除即可。
existingChildren.forEach(child => deleteChild(returnFiber, child));可以看到,節點的移動是以最右側的固定節點位置作為參照的。這些固定節點是指位置未發生變化的節點。每次對比節點是否需要移動之後,及時更新固定節點非常重要。
源碼了解了上邊的多節點diff原理後,將上邊的關鍵點匹配到源碼上更方便能進一步理解。下面放出帶有詳細注釋的源碼。
function reconcileChildrenArray( returnFiber: Fiber, currentFirstChild: Fiber | null, newChildren: Array<*>, lanes: Lanes,): Fiber | null { let resultingFirstChild: Fiber | null = null; let previousNewFiber: Fiber | null = null; let oldFiber = currentFirstChild; let lastPlacedIndex = 0; let newIdx = 0; let nextOldFiber = null; for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) { if (oldFiber.index > newIdx) { nextOldFiber = oldFiber; oldFiber = null; } else { nextOldFiber = oldFiber.sibling; } const newFiber = updateSlot( returnFiber, oldFiber, newChildren[newIdx], lanes, ); if (newFiber === null) { if (oldFiber === null) { } break; } if (shouldTrackSideEffects) { if (oldFiber && newFiber.alternate === null) { deleteChild(returnFiber, oldFiber); } } lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); if (previousNewFiber === null) { resultingFirstChild = newFiber; } else { previousNewFiber.sibling = newFiber; } previousNewFiber = newFiber; oldFiber = nextOldFiber; } if (newIdx === newChildren.length) { deleteRemainingChildren(returnFiber, oldFiber); return resultingFirstChild; } if (oldFiber === null) { for (; newIdx < newChildren.length; newIdx++) { const newFiber = createChild(returnFiber, newChildren[newIdx], lanes); if (newFiber === null) { continue; } lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); if (previousNewFiber === null) { resultingFirstChild = newFiber; } else { previousNewFiber.sibling = newFiber; } previousNewFiber = newFiber; } return resultingFirstChild; } const existingChildren = mapRemainingChildren(returnFiber, oldFiber); for (; newIdx < newChildren.length; newIdx++) { const newFiber = updateFromMap( existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes, ); if (newFiber !== null) { if (shouldTrackSideEffects) { if (newFiber.alternate !== null) { existingChildren.delete( newFiber.key === null ? newIdx : newFiber.key, ); } } lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); if (previousNewFiber === null) { resultingFirstChild = newFiber; } else { previousNewFiber.sibling = newFiber; } previousNewFiber = newFiber; } } if (shouldTrackSideEffects) { existingChildren.forEach(child => deleteChild(returnFiber, child)); } return resultingFirstChild; }總結Diff算法通過key和tag來對節點進行取捨,可直接將複雜的比對攔截掉,然後降級成節點的移動和增刪這樣比較簡單的操作。
對oldFiber和新的ReactElement節點的比對,將會生成新的fiber節點,同時標記上effectTag,這些fiber會被連到workInProgress樹中,作為新的WIP節點。
樹的結構因此被一點點地確定,而新的workInProgress節點也基本定型。這意味著,在diff過後,workInProgress節點的beginWork節點就完成了。接下來會進入completeWork階段。
點擊進入(地址:https://github.com/neroneroffy/react-source-code-debug)react源碼調試倉庫