大家好,我是前端西瓜哥。今天带带大家来分析 React 源码,理解它的单节点 diff 和多节点 diff 的具体实现。
React 的版本为 18.2.0
reconcileChildFibers React 的节点对比逻辑是在 reconcileChildFibers 方法中实现的。
reconcileChildFibers 是 ChildReconciler 方法内部定义的方法,通过调用 ChildReconciler 方法,并传入一个 shouldTrackSideEffects 参数返回。这样做是为了根据不同使用场景 ,产生不同的效果。
因为一个组件的更新和挂载的流程不同的。比如挂载会执行挂载的生命周期函数,更新则不会。
1 2 3 4 5 export const reconcileChildFibers = ChildReconciler (true );export const mountChildFibers = ChildReconciler (false );
reconcileChildFibers 的核心实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 function reconcileChildFibers ( returnFiber, currentFirstChild, newChild, lanes, ) { switch (newChild.$$typeof ) { case REACT_ELEMENT_TYPE : return placeSingleChild ( reconcileSingleElement ( returnFiber, currentFirstChild, newChild, lanes, ), ); } if (isArray (newChild)) { return reconcileChildrenArray ( returnFiber, currentFirstChild, newChild, lanes, ); } }
newChild 是在组件 render 时得到 ReactElement,通过访问组件的 props.children 得到。
如果 newChild 是对象(非数组),会 调用 reconcileSingleElement (普通元素的情况),做单个节点的对比。
如果是数组时,就会 调用 reconcileChildrenArray ,进行多节点的 diff。
更新和挂载的逻辑有点不同,后面都会用 “更新” 的场景进行讲解。
单节点 diff 先看看 单节点 diff 。
需要注意的是,这里的 “单节点” 指的是新生成的 ReactElement 是单个的 。只要新节点是数组就不算单节点,即使数组长度只为 1。 此外旧节点可能是有兄弟节点的(sibling 不为 null)。
fiber 对象是通过链表来表示节点之间的关系的,它的 sibling 指向它的下一个兄弟节点,index 表示在兄弟节点中的位置。
ReactElement 则是对象或数组的形式,通过 React.createElement() 生成。
单节点 diff 对应 reconcileSingleElement 方法,其核心实现为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 function reconcileSingleElement ( returnFiber, currentFirstChild, element, ) { const key = element.key ; let child = currentFirstChild; while (child !== null ) { if (child.key === key) { const elementType = element.type ; if (child.elementType === elementType) { deleteRemainingChildren (returnFiber, child.sibling ); const existing = useFiber (child, element.props .children ); existing.return = returnFiber; return existing; } else { deleteRemainingChildren (returnFiber, child); break ; } } else { deleteChild (returnFiber, child); } child = child.sibling ; } const created = createFiberFromElement (element, returnFiber.mode , lanes); created.return = returnFiber; return created; }
currentFirstChild 是更新前的节点,它是以链表的保存的,它的 sibling 指向它的下一个兄弟节点。
分支很多,下面我们进行详细地分析。
分支 1:key 相同且 type 相同 当发现 key 相同时,React 会尝试复用组件。新旧节点的 key 都没有设置的话,会设置为 null,如果新旧节点的 key 都为 null,会认为相等。
此外还要判断新旧类型是否相同(比如都是 div),因为类型都不同了,是无法复用的。
如果都满足,就会将旧 fiber 的后面的兄弟节点都标记为待删除 ,具体是调用 deleteRemainingChildren()
方法,它会在父 fiber 的 deletions 数组上,添加指定的子 fiber 和它之后的所有兄弟节点,作为删除标记。
之后的 commit 阶段会再进行正式的删除,再执行一些调用生命周期函数等逻辑。
useFiber()
会创建旧的 fiber 的替身,更新到 fiber 的 alternate 属性上,最后这个 useFiber 返回这个 alternate。然后直接 return,结束这个方法。
分支 2:key 相同但 type 不同 type 不同是无法复用的,如果 type 不同但 key 却相同,React 会认为没有匹配的可复用节点了。直接就将剩下的兄弟节点标记为删除,然后结束循环。
分支 3:key 不匹配 key 不同,用 deleteChild()
方法将当前的 fiber 节点标记为待删除 ,取出下一个兄弟节点再和新节点再比较,不断循环,直到匹配到其中一种分支为止。
以上就是三个分支。
如果能走到循环结束,说明没能找到能复用的 fiber,就会根据 ReactElement 调用 createFiberFromElement()
方法创建一个新的 fiber,然后返回它。
外部会拿到这个 fiber,调用 placeSingleChild()
将其 打上待更新 tag 。
reconcileChildrenArray 然后是 多节点 diff 。
对应 ReactElement 为数组的场景,这种场景的算法实现要复杂的多。
多节点 diff 对应 reconcileChildrenArray 方法,因为算法比较复杂,先不直接贴比较完整的代码,而是分成几个阶段去一点点讲解。
多节点的 diff 分 4 个阶段,下面细说。
阶段1:同时从左往右遍历
旧 fiber 和 element 各自的指针一起从左往右走。指针分别为 nextFiber 和 newIdx,从左往右不断遍历。
遍历中发生的逻辑有:
有一个指针走完,即 nextFiber 变成 null 或 newIdx 大于 newChildren.length,循环结束;
如果 key 不同,就会结束遍历(在源码中的体现是 updateSlot()
返回 null 赋值给 newFiber,然后就 break 跳出循环);
如果 key 相同,但 type 不同,说明这个旧节点是不能用的了,给它 打上 “删除” 标记 ,然后继续遍历;
key 相同,type 也相同,复用节点。对于普通元素类型,最终会调用 updateElement 方法。
updateElement 方法会判断 fiber 和 element 的类型是否相同,如果相同,会给 fiber 的 alternate 生成一个 workInProcess (替身) fiber 返回,否则 创建一个新的 fiber 返回。它们会带上新的 pendingProps 属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 function reconcileChildrenArray ( returnFiber, currentFirstChild, newChildren, lanes, ) { 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 ) { oldFiber = nextOldFiber; } break ; } if (shouldTrackSideEffects) { if (oldFiber && newFiber.alternate === null ) { deleteChild (returnFiber, oldFiber); } } lastPlacedIndex = placeChild (newFiber, lastPlacedIndex, newIdx); } }
阶段 2:新节点遍历完的情况 跳出循环后,我们先看 新节点数组是否遍历完 (newIdx 是否等于 newChildren.length)。
是的话,就将旧节点中剩余的所有节点编辑为 “删除”,然后直接结束整个函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function reconcileChildrenArray ( returnFiber, currentFirstChild, newChildren, lanes, ) { if (newIdx === newChildren.length ) { deleteRemainingChildren (returnFiber, oldFiber); return resultingFirstChild; } }
阶段 3:旧节点遍历完,新节点没遍历完的情况 如果是旧节点遍历完了,但新节点没有遍历完,就将新节点中的剩余节点,根据 element 构建为 fiber。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 function reconcileChildrenArray ( returnFiber, currentFirstChild, newChildren, lanes, ) { 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; } }
阶段 4:使用 map 高效匹配新旧节点进行更新 【4】如果新旧节点都没遍历完,那我们会调用 mapRemainingChildren 方法,先将剩余的旧节点,放到 Map 映射中,以便快速访问。
map 中会优先使用 fiber.key (保证会转换为字符串)作为键;如果 fiber.key 是 null,则使用 fiber.index (数值类型),key 和 index 的值是不会冲突的。值自然就是 fiber 对象本身。
然后就是遍历剩余的新节点,调用 updateFromMap 方法,从映射表中找到对应的旧节点,和新节点进行对比更新。
遍历完后就是收尾工作了,map 中剩下的就是没能匹配的旧节点,给它们打上 “删除” 标记。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 function reconcileChildrenArray ( returnFiber, currentFirstChild, newChildren, lanes, ) { 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; }
结尾 有点复杂的。
我是前端西瓜哥,欢迎关注我,学习更多前端知识。
本文首发于我的公众号:前端西瓜哥