React源码系列(八):搞定React DIFF设计思想和源码

您所在的位置:网站首页 react网站源码 React源码系列(八):搞定React DIFF设计思想和源码

React源码系列(八):搞定React DIFF设计思想和源码

2023-03-21 19:32| 来源: 网络整理| 查看: 265

前言

本文正在参加「金石计划」

这是React源码系列专栏的第八篇文章,预计写10篇左右,之前的文章请查看文末,通过本专栏的学习,相信大家可以快速掌握React源码的相关概念以及核心思想,向成为大佬的道路上更近一步; 本章我们学习render阶段diff算法的设计思想和源码,本系列源码基于v18.2.0版本;

什么是协调

协调就是通过如 ReactDOM 等类库使虚拟DOM与真实的DOM同步。通俗的讲协调是将虚拟DOM映射到真实DOM的过程。而diff是协调的一个环节,协调是使一致的过程,而Diff是找不同的过程。

diff设计思想

在传统方式中,要找出两棵树不同,需要一一对节点对比,这个过程的算法复杂度是O(n³)。也就是说假如一棵树有100个节点,那么比较一次就需要操作10w次,代价是非常昂贵的。那么是如何简化这个过程的呢?就是将 O (n³) 复杂度转换成 O (n) 复杂度:

若两个组件属于同一个类型,那么它们将拥有相同的 DOM 树形结构; 处于同一层级的一组子节点,可用通过设置 key 作为唯一标识,从而维持各个节点在不同渲染过程中的稳定性。

diff策略总结为以下三点:

diff 算法性能突破的关键点在于“分层对比”; 类型相同的节点才有继续 diff 的必要性; 设置key 属性,重用同一层级内的节点; 分层对比

它只针对相同层级的节点作对比,如下图所示。

分层比较.jpg

销毁 + 重建的代价是昂贵的,尽量保持 DOM 结构的稳定性。所以React发生了跨层级的节点操作,它只能认为移出子树那一层的组件消失了,对应子树需要被销毁;而移入子树的那一层新增了一个组件,需要重新为其创建一棵子树。

1231.jpg

节点类型判断

在React 中,只有同类型的组件,才有进一步对比的必要性;若参与 Diff 的两个组件类型不同,那么直接放弃比较,原地替换掉旧的节点。只有确认组件类型相同后,React 才会在保留组件对应 DOM 树(或子树)的基础上,尝试向更深层次去 Diff。这样一来,便能够从很大程度上减少 Diff 过程中冗余的递归操作。如下图所示,直接移除span以及后代所有节点,新增p节点及后代节点;

重构节点类型.jpg

使用key来保持节点的稳定性

key属性的设置,可以帮我们尽可能重用同一层级内的节点。它就像一个记号一样,帮助记住某一个节点,从而在后续的更新中实现对节点的追踪;

key 是用来帮助 React 识别哪些内容被更改、添加或者删除。key 需要写在用数组渲染出来的元素内部,并且需要赋予其一个稳定的值。稳定在这里很重要,因为如果 key 值发生了变更,React 则会触发 UI 的重渲染。这是一个非常有用的特性。

key.jpg

这个 key 就是每个节点的唯一标识,有了这个标识之后,当 C 被插入到 B 和 D 之间时,React 并不会再认为 C、D、E 这三个坑位都需要被重建——它会通过识别唯一标识,意识到 D 和 E 并没有发生变化(D 的 ID 仍然是 1,E 的 ID 仍然是 2),而只是被调整了顺序而已。接着,React 便能够轻松地重用它追踪到旧的节点,将 D 和 E 转移到新的位置,并完成对 C 的插入。这样一来,同层级下元素的操作成本便大大降低。

ChildReconciler

在之前章节 React源码系列(六):render阶段beginWork流程解析 讲解了ChildReconciler方法会返回reconcileChildFibers方法,即为diff算法的处理过程。

// react/packages/react-reconciler/src/ReactChildFiber.old.js function ChildReconciler(shouldTrackSideEffects) { function reconcileChildFibers( returnFiber: Fiber, currentFirstChild: Fiber | null, newChild: any, lanes: Lanes, ): Fiber | null { /** ...省略一大段逻辑...**/ } // 将总的 reconcileChildFibers 函数返回 return reconcileChildFibers; } 复制代码 reconcileChildFibers

在render阶段更新Fiber节点时,会调用reconcileChildFibers对比current Fiber和workInProgress Fiber。 reconcileChildFibers中会根据newChild的类型来进入单节点的diff或者多节点diff。

function reconcileChildFibers( returnFiber: Fiber, currentFirstChild: Fiber | null, newChild: any, lanes: Lanes, ): Fiber | null { /** ...省略...**/ if (typeof newChild === 'object' && newChild !== null) { switch (newChild.$$typeof) { case REACT_ELEMENT_TYPE: return placeSingleChild( // 单节点diff reconcileSingleElement( returnFiber, currentFirstChild, newChild, lanes, ), ); /** ...省略...**/ } if (isArray(newChild)) { // 多节点diff return reconcileChildrenArray( returnFiber, currentFirstChild, newChild, lanes, ); } } // 删除节点 return deleteRemainingChildren(returnFiber, currentFirstChild); } 复制代码 单节点diff

单点diff有以下几种情况:

key和type相同表示可以复用节点 key不同直接标记删除节点,新建节点 key相同type不同,标记删除该节点和兄弟节点,新创建节点 function reconcileSingleElement( returnFiber: Fiber, currentFirstChild: Fiber | null, element: ReactElement, lanes: Lanes, ): Fiber { const key = element.key; let child = currentFirstChild; while (child !== null) { // 比较key if (child.key === key) { const elementType = element.type; // Fragment节点类型处理 if (elementType === REACT_FRAGMENT_TYPE) { if (child.tag === Fragment) { deleteRemainingChildren(returnFiber, child.sibling); const existing = useFiber(child, element.props.children); existing.return = returnFiber; if (__DEV__) { existing._debugSource = element._source; existing._debugOwner = element._owner; } return existing; } } else { if ( // 比较type // 如果新的 ReactElement 和旧 Fiber 的 key 和 type 都相等 child.elementType === elementType || (__DEV__ ? isCompatibleFamilyForHotReloading(child, element) : false) || (typeof elementType === 'object' && elementType !== null && elementType.$$typeof === REACT_LAZY_TYPE && resolveLazy(elementType) === child.type) ) { deleteRemainingChildren(returnFiber, child.sibling); // 复用之前current树上相对应的fiber,并使用最新的props更新fiber上的pendingProps属性 const existing = useFiber(child, element.props); existing.ref = coerceRef(returnFiber, child, element); existing.return = returnFiber; if (__DEV__) { existing._debugSource = element._source; existing._debugOwner = element._owner; } // type相同则可以复用 返回复用的节点 return existing; } } // key相同,type不同则把fiber及和兄弟fiber标记删除 deleteRemainingChildren(returnFiber, child); // type不同跳出 break; } else { // key不同直接标记删除该节点 deleteChild(returnFiber, child); } child = child.sibling; } // 当key或者类型不相等时,会根据新创建的React element元素创建新的Fiber节点 if (element.type === REACT_FRAGMENT_TYPE) { const created = createFiberFromFragment( element.props.children, returnFiber.mode, lanes, element.key, ); created.return = returnFiber; return created; } else { const created = createFiberFromElement(element, returnFiber.mode, lanes); created.ref = coerceRef(returnFiber, currentFirstChild, element); created.return = returnFiber; return created; } } 复制代码 多节点diff

多节点diff有三次for循环遍历(循环可以跳出)

第一次遍历处理节点的更新(包括props更新和type更新和删除); 第二次遍历处理其他的情况(节点新增),例如老节点没了,新节点还有,逐个新增即可; 第三次处理位节点置改变; 第一次遍历

第一次遍历处理节点的更新(包括props更新和type更新和删除),会经历以下4种情况:

key不同,第一次循环结束; newChildren或者oldFiber遍历完,第一次循环结束; key同type不同,标记oldFiber为DELETION; key相同type相同则可以复用;

newChildren遍历完,oldFiber没遍历完,在第一次遍历完成之后将oldFiber中没遍历完的节点标记为DELETION;

function reconcileChildrenArray( returnFiber: Fiber, currentFirstChild: Fiber | null, newChildren: Array, lanes: Lanes ): Fiber | null { /** ...省略... **/ let oldFiber = currentFirstChild; // 记录上次插入节点的位置,判断节点是否需要移动 let lastPlacedIndex = 0; // newChildren是数组,newIdx就是遍历数组用的下标 let newIdx = 0; let nextOldFiber = null; // 第一次遍历新老VDOM都是从左边开始遍历,按位比较,如果节点可以复用,那么都往后移一位,否则中止本轮循环 for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) { // oldFiber的下标大于新的,本轮循环中止 if (oldFiber.index > newIdx) { nextOldFiber = oldFiber; oldFiber = null; } else { nextOldFiber = oldFiber.sibling; } // 通过 updateSlot 来 diff oldFiber 和新的 child,生成新的 Fiber const newFiber = updateSlot( returnFiber, oldFiber, newChildren[newIdx], lanes ); // newFiber 为 null 说明不可复用,退出第一轮的循环 if (newFiber === null) { if (oldFiber === null) { oldFiber = nextOldFiber; } break; } if (shouldTrackSideEffects) { if (oldFiber && newFiber.alternate === null) { deleteChild(returnFiber, oldFiber); } } // 记录复用的 oldFiber 的 index lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); if (previousNewFiber === null) { resultingFirstChild = newFiber; } else { previousNewFiber.sibling = newFiber; } previousNewFiber = newFiber; oldFiber = nextOldFiber; } // 如果newIdx === newChildren.length,证明经过上轮循环,新节点已经遍历完了,那么如果还有剩下的老节点,删除即可 if (newIdx === newChildren.length) { deleteRemainingChildren(returnFiber, oldFiber); if (getIsHydrating()) { const numberOfForks = newIdx; pushTreeFork(returnFiber, numberOfForks); } return resultingFirstChild; } /** ...省略... **/ } 复制代码 第二次遍历

第二次遍历处理其他的情况(节点新增),例如老节点没了,新节点还有,逐个新增即可,会经历以下3种情况:

newChildren和oldFiber都遍历完:多节点diff过程结束; newChildren没遍历完,oldFiber遍历完,将剩下的newChildren的节点标记为Placement; newChildren和oldFiber没遍历完,则进入节点移动的逻辑; function reconcileChildrenArray( returnFiber: Fiber, currentFirstChild: Fiber | null, newChildren: Array, lanes: Lanes ): Fiber | null { /** ...省略第一遍for相关逻辑... **/ // 如果老节点没了,新节点还有,那么新节点逐个新增即可 if (oldFiber === null) { for (; newIdx < newChildren.length; newIdx++) { // 遍历剩下的 child,通过 createChild 创建新的 fiber const newFiber = createChild(returnFiber, newChildren[newIdx], lanes); if (newFiber === null) { continue; } // 处理dom移动,记录 index lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); if (previousNewFiber === null) { resultingFirstChild = newFiber; } else { previousNewFiber.sibling = newFiber; } previousNewFiber = newFiber; } if (getIsHydrating()) { const numberOfForks = newIdx; pushTreeFork(returnFiber, numberOfForks); } return resultingFirstChild; } /** ...省略... **/ } 复制代码 第三次遍历

第三次遍历处理节点位置改变;

function reconcileChildrenArray( returnFiber: Fiber, currentFirstChild: Fiber | null, newChildren: Array, lanes: Lanes ): Fiber | null { /** ...省略第一个和第二个for相关逻辑... **/ // oldFiber 和 newChildren 都未遍历完 // mapRemainingChildren 生成一个以 oldFiber 的 key 为 key, oldFiber 为 value 的 map const existingChildren = mapRemainingChildren(returnFiber, oldFiber); // 对剩下的 newChildren 进行遍历 for (; newIdx < newChildren.length; newIdx++) { // 找到 mapRemainingChildren 中 key 相等的 fiber, 创建新 fiber 复用 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 ); } } // 处理dom移动,记录 index lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); if (previousNewFiber === null) { resultingFirstChild = newFiber; } else { previousNewFiber.sibling = newFiber; } previousNewFiber = newFiber; } } // 发现老节点中还有没被复用的,打上 Deletion 副作用标签 if (shouldTrackSideEffects) { existingChildren.forEach((child) => deleteChild(returnFiber, child)); } if (getIsHydrating()) { const numberOfForks = newIdx; pushTreeFork(returnFiber, numberOfForks); } return resultingFirstChild; } 复制代码 实用场景举例1 // old DOM A B C D // ------------------------------- // new DOM A C D B 复制代码

如上面的例子,会经历以下5种情况:

newChild中第一个位置的A和oldFiber第一个位置的A,key相同可复用,初始化lastPlacedIndex=0 newChild中第二个位置的C和oldFiber第二个位置的B,key不同跳出第一次循环,将oldFiber中的BCD保存在map中 newChild中第二个位置的C在oldFiber中的index=2 > lastPlacedIndex=0不需要移动,lastPlacedIndex=2 newChild中第三个位置的D在oldFiber中的index=3 > lastPlacedIndex=2不需要移动,lastPlacedIndex=3 newChild中第四个位置的B在oldFiber中的index=1 < lastPlacedIndex=3,移动到最后

image.png

实用场景举例2 // old DOM A B C D // ------------------------------- // new DOM D A B C 复制代码

如上面的例子,会经历以下5种情况:

newChild中第一个位置的D和oldFiber第一个位置的A,key不相同不可复用,将oldFiber中的ABCD保存在map中,lastPlacedIndex=0 newChild中第一个位置的D在oldFiber中的index=3 > lastPlacedIndex=0,不需要移动,lastPlacedIndex=3 newChild中第二个位置的A在oldFiber中的index=0 < lastPlacedIndex=3,移动到最后 newChild中第三个位置的B在oldFiber中的index=1 < lastPlacedIndex=3,移动到最后 newChild中第四个位置的C在oldFiber中的index=2 < lastPlacedIndex=3,移动到最后

image.png

多节点diff完整代码 function reconcileChildrenArray( returnFiber: Fiber, currentFirstChild: Fiber | null, newChildren: Array, lanes: Lanes ): Fiber | null { let oldFiber = currentFirstChild; // 记录上次插入节点的位置,判断节点是否需要移动 let lastPlacedIndex = 0; // newChildren是数组,newIdx就是遍历数组用的下标 let newIdx = 0; let nextOldFiber = null; // 第一次遍历新老VDOM都是从左边开始遍历,按位比较,如果节点可以复用,那么都往后移一位,否则中止本轮循环 for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) { // oldFiber的下标大于新的,本轮循环中止 if (oldFiber.index > newIdx) { nextOldFiber = oldFiber; oldFiber = null; } else { nextOldFiber = oldFiber.sibling; } // 通过 updateSlot 来 diff oldFiber 和新的 child,生成新的 Fiber const newFiber = updateSlot( returnFiber, oldFiber, newChildren[newIdx], lanes ); // newFiber 为 null 说明不可复用,退出第一轮的循环 if (newFiber === null) { if (oldFiber === null) { oldFiber = nextOldFiber; } break; } if (shouldTrackSideEffects) { if (oldFiber && newFiber.alternate === null) { deleteChild(returnFiber, oldFiber); } } // 记录复用的 oldFiber 的 index lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); if (previousNewFiber === null) { resultingFirstChild = newFiber; } else { previousNewFiber.sibling = newFiber; } previousNewFiber = newFiber; oldFiber = nextOldFiber; } // 如果newIdx === newChildren.length,证明经过上轮循环,新节点已经遍历完了,那么如果还有剩下的老节点,删除即可 if (newIdx === newChildren.length) { deleteRemainingChildren(returnFiber, oldFiber); if (getIsHydrating()) { const numberOfForks = newIdx; pushTreeFork(returnFiber, numberOfForks); } return resultingFirstChild; } // 如果老节点没了,新节点还有,那么新节点逐个新增即可 if (oldFiber === null) { for (; newIdx < newChildren.length; newIdx++) { // 遍历剩下的 child,通过 createChild 创建新的 fiber const newFiber = createChild(returnFiber, newChildren[newIdx], lanes); if (newFiber === null) { continue; } // 处理dom移动,记录 index lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); if (previousNewFiber === null) { resultingFirstChild = newFiber; } else { previousNewFiber.sibling = newFiber; } previousNewFiber = newFiber; } if (getIsHydrating()) { const numberOfForks = newIdx; pushTreeFork(returnFiber, numberOfForks); } return resultingFirstChild; } // oldFiber 和 newChildren 都未遍历完 // mapRemainingChildren 生成一个以 oldFiber 的 key 为 key, oldFiber 为 value 的 map const existingChildren = mapRemainingChildren(returnFiber, oldFiber); // 对剩下的 newChildren 进行遍历 for (; newIdx < newChildren.length; newIdx++) { // 找到 mapRemainingChildren 中 key 相等的 fiber, 创建新 fiber 复用 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 ); } } // 处理dom移动,记录 index lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx); if (previousNewFiber === null) { resultingFirstChild = newFiber; } else { previousNewFiber.sibling = newFiber; } previousNewFiber = newFiber; } } // 发现老节点中还有没被复用的,打上 Deletion 副作用标签 if (shouldTrackSideEffects) { existingChildren.forEach((child) => deleteChild(returnFiber, child)); } if (getIsHydrating()) { const numberOfForks = newIdx; pushTreeFork(returnFiber, numberOfForks); } return resultingFirstChild; } 复制代码 小结

本章我们学习了diff的设计思想和源码,接下来的文章将进入commit阶段流程分析,欢迎继续跟随本专栏一起学习;

参考链接 深入浅出搞定React React源码系列 React源码系列(一):React设计理念&架构 React源码系列(二):虚拟DOM与其价值 React源码系列(三):源码调试 React源码系列(四):JSX解析 React源码系列(五):Fiber架构&双缓存 React源码系列(六):render阶段beginWork流程解析 React源码系列(七):render阶段completeWork流程解析


【本文地址】


今日新闻


推荐新闻


    CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3