详解 react diff

4,927 阅读13分钟

前言

react diff 是从 fiber 树的 Root 节点开始,从上往下一层一层的对新老节点进行 比较。期间组件的 key 以及 type 决定是否需要复用老的节点。节点的 index 最终决定了 dom 是否需要被移动。没有被复用的节点会被删除,也就不需要对其子树进行 diff,从而不需要跨层级的 diff。

附图:

链接:pan.baidu.com/s/1EfTWzqC4… 密码:8nqr

仓库:github.com/BUPTlhuanyu…

实际的栗子

item 1
this Header will be deleted
item 2
this is Footer

上面为测试的 HTML 结构,其中 this Header will be deleted 元素是在点击 document 的时候会被删除或者添加,代码如下,除了以这种形式的增删,还可以以 map 或者其他方式的增删节点,通过原生 dom 的增删,不会通过 react 处理自然也不会被 diff:

    class APP extends React.Component{
        constructor(props){
            super(props)
            this.state = {
                showHeader: false
            }
        }
        clickHandler(){
            this.setState({
                showHeader: !this.state.showHeader
            })
        }
        render(){
            return (
                <div onClick={this.clickHandler.bind(this)}>                   
                    <div>item 1</div>
                    {
                        this.state.showHeader && <Header/>
                    } 
                    <div>item 2</div>
                    <Footer/>
                </div>
            )
        }
    }

react diff 的四个阶段

为了后续能够比较容易理解,这里简述 diff 非常重要的四个阶段以及一个原则:

一个原则

diff 的是内存中的数组与链表(可以说是通过 sibling 属性链接的 fiber 链表( vnode )与 react 元素数组之间的 diff,这里不理解没关系,记住这两个概念),不会操作 dom,但是会给 fiber 贴上标签,需要换位置,需要删除的标签。

四个阶段

  1. 从链表与数组的开头往后同向遍历直到 oldfiber 没有被复用: key 不相同或者 index 不匹配。
  2. 数组遍历完了,将链表所有的节点标记需要删除
  3. 链表遍历完了,为剩余的每个数组元素创建新的 fiber
  4. 数组和链表都没遍历完,将链表的节点组成 map,遍历剩余的数组,去 map 中找是相同键的 fiber,找到就从 map 中剔除,然后利用这个 react 元素生成或者复用 fiber 得到一个新的 fiber 并标记为 Placement。最后将 map 中剩余的都标记为删除。

对谁进行 diff

diff 算法所在的函数是 reconcileChildrenArray,而 diff 作用于 oldFibers 以及 newChildren:

  • newChildren: 在执行 APP 组件的 render 中的 return 的时候,会通过插件 @babel/preset-react 调用 React.createElement 将 jsx 编译成 React.createElement 的形式。React.createElement 从第三个参数开始就是其 children,他们组成的数组就是 newChildren
  • oldFibers: fiber 是从 React.createElement 返回的 react 元素生成的,fiber 的 return 属性是指向父节点 fiber,其 sibling 属性指向右边的第一个兄弟节点。 react 元素是 JSX 编译后的产物,fiber 是 存在于组件生命周期中的对象,保存了组件状态以及实例或者 DOM 的引用。

jsx 的编译

React.createElement(
    "div", 
    {
        onClick: (void 0).clickHandler.bind(void 0)
    }, 
    /*#__PURE__*/ 
    React.createElement(
        "div", 
        null, 
        "item 1"
    ), 
    (void 0).state.showHeader && /*#__PURE__*/ React.createElement(Header, null), 
    /*#__PURE__*/ 
    React.createElement(
        "div", 
        null, 
        "item 2"
    ), 
    /*#__PURE__*/ 
    React.createElement(Footer, null)
);

newChildren

上述 jsx 的编译结果执行后得到的 newChildren 如下:

注意上述的 react 元素是个 falsy 元素,创建的 fiber 为null,null 不会被添加到 fiber 链上,因此上面生成的 fiber 链节点数量为 3, 链表上节点属性 index 分别是 0,2,3。原理如下:

子节点的增加与删除可以通过逻辑符或者三元表达式来实现,这使得 babel 生成的 react 元素是 false 或者 null,由于初次挂载父节点的时候也会走 diff 流程,挂载阶段没有 oldFiber,根据 reconcileChildren 可以知道,由于没有 oldfiber,所以直接执行上述四个阶段的第三个阶段,该阶段是对 newChildren 的遍历,其中就包含了这些 falsy 元素,通过这些 falsy 元素创建的 fiber 都是 null,当 fiber 为 null 的时候会 continue 直接进入下一轮,下一轮 newIdx++,导致了上一轮的 index 丢失了,也就是最终生成的 fiber 上的 index 分别是 0, 2, 3。 需要补充的是,这些 falsy 元素对应的 fiber 都是 null,而这些 null 都会被舍弃,不会添加到 fiber 链上,fiber 链也就只有三个节点了,并且 index 分别是 0,2,3。

后续所有更新过程中,新生成的 fiber 的 index 都是递增的。

怎么 diff

reconcileChildrenArray 中包含了 diff 的完整逻辑,上面介绍了 diff 的对象以及初次 diff 生成的 fiber 的 index。下面分四个阶段分析如何 diff。

diff 阶段一

从链表与数组的开头往后同向遍历直到 oldfiber 没有被复用: key 不相同或者 index 不匹配。

遍历数组,newIdx 是数组的下标,oldFiber 是当前 newChildren[newIdx] 需要比较的 fiber。

for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) 

在这个 for 循环中,分为一下几个步骤:

  • 首先确定当前需要处理的 oldFiber 以及 下一个需要处理的 nextOldFiber,分为两种情况
    • 新的节点个数比原来的多,说明需要增加一些节点,那么当前的这个 oldFiber 不是当前 newChildren[newIdx] 对应的 oldfiber,因此将其引用地址赋值给 nexOldFiber 作为下一个需要处理的 oldFiber。然后为 newChildren[newIdx] 分配一个 null 作为其对应的 fiber。
    • 新的节点个数与原来一样或者比原来少,比原来少说明需要删除一些节点,这种情况直接将 nextOldFiber 指向 oldFiber.sibling。
  if (oldFiber.index > newIdx) {
    nextOldFiber = oldFiber;
    oldFiber = null;
  } else {
    nextOldFiber = oldFiber.sibling;
  }
  • 接着根据当前的 oldFiber 与 newChildren[newIdx] 即 react 元素创建 newFiber,逻辑如下
    • oldFiber 与 newChildren[newIdx] 的 key 不相同,newFiber 为 null,包括了 oldfiber 为 null 的情况。
    • 如果 key 相同,那么比较前者的 elementType 与后者的 type ,如果一样就复用 oldFiber 上的状态以及 statenode 等数据,如果不相同创建新的 fiber。
  const newFiber = updateSlot(
    returnFiber,
    oldFiber,
    newChildren[newIdx],
    expirationTime,
  );

这里需要说明: 如果 fiber 是通过 react 元素生成 的,那么前者的 elementType 与 后者的 type 属性是全等的。比如 'div', 'span' 这些 HostComponent,或者 function compoenent 的构造函数,class component 的构造函数。'div' 自然不等于 'span', 两个不同的组件自然也不是相等的。下面一些栗子

return (
    <div>
        <span>下面的 type 都不一样</span>
        {
            this.state.anything? <div>1</div> : <span>1</span>
        }
        {
            this.state.something? <HeaderClass>1</HeaderClass> : <HeaderFn>1</HeaderFn>            
        }
    </div>
)
  • 然后判断是否结束 diff 阶段一。上面涉及的两种情况会结束 diff 阶段一。
    • 情况一:步骤一中由于增加子节点导致 index 不匹配,导致为 newChildren[newIdx] 设置 null 为其 oldfiber。这时跳出阶段一之前需要将 oldfiber 设置为之前的 oldfiber。
    • 情况二:步骤二中 key 不一样的情况,直接跳出,oldfiber 与 newIdx 都不变,后续阶段接着从 newIdx 以及 oldfiber 开始处理。
      if (newFiber === null) {
        // 对应步骤二中: key 不一样
        if (oldFiber === null) {
          // 对应步骤一中:nextOldFiber = oldFiber; oldFiber = null;
          oldFiber = nextOldFiber;
        }
        // 跳出循环
        break;
      }
  • 如果不跳出循环,判断是否需要处理 oldfiber:如果本次 diff 发生在更新阶段,存在 oldfiber 并且没有被复用,那么需要标记 oldfiber 的 effecttag 为 删除,并且将这个 oldfiber 添加到其父节点 fiber 上的 effect 链表上,后续 commit 阶段(可以参考我仓库中提供的文章和原理图了解这个阶段做的事)刷新链表的时候需要删除这个 fiber 上 statenode 存储的 dom 或者 从内存中删除其实例。
      if (shouldTrackSideEffects) {
        // 更新阶段的 diff
        if (oldFiber && newFiber.alternate === null) {
          // 存在 oldfiber 但是没有被复用
          deleteChild(returnFiber, oldFiber);
        }
      }

这里可以看出:为什么加 key 会提高性能,原因是:如果有 key,并且新老节点是同一个类型的,那么新 fiber 会复用 oldfiber,并且 oldfiber 不需要被标记为删除,也不需要被添加到父节点的 effect 链表上,最终在 dom 树上也不需要去删除这个 dom。此外,阶段一中新的 fiber 也不需要被标记为 Placement,所以综合起来在渲染的时候有相同的 key,并且位置没有变化,那么就不需要移动这个真实的 dom 元素,但是可能会替换其文本内容以及属性或者操作其子节点。在阶段四中如果有相同的 key,但是位置变化了,那么需要移动 oldfiber 的真实的 dom 到特定的位置。

  • 如果不跳出循环,需要处理新的 fiber。
    • 如果是挂载阶段的 diff, placeChild 中会设置 fiber.index 为 newIdx, lastPlaceIndex 不变,也不会标记新 fiber 的 effecttag 为 Placement。
    • 如果是更新阶段的 diff,placeChild 中会设置 fiber.index 为 newIdx
      • 如果当前 oldfiber 没有被复用,标记新的 fiber 为 Placement,lastPlacedIndex保持不变。
      • 如果当前 oldfiber 被复用了
        • 其所处的位置比上一个被复用的 oldfiber 的位置靠后,也就是 index 较大,则 lastPlacedIndex 更新为当前 olffiber 的 index。--- 在阶段一始终会更新 lastPlacedIndex,在阶段四有可能不需要更新。
        • 如果位置靠前,则保持 lastPlacedIndex 保持不变,新的 fiber 标记为 Placement。

lastPlacedIndex 记录了之前所有的循环中被复用的所有 oldfiber 中最大的 index。

阶段一最终遍历得到的 lastPlacedIndex 始终是最后一个被复用的 oldfiber 的 index。

阶段四需要注意:这里 oldfiber 没有被复用或者 被复用了并且比之前被复用的 oldfiber 位置靠前,那么对应的新的 fiber 标记为 Placement ,后期 commit 阶段,会从左到右将被标记为 Placement 的节点的 dom 移动到离它最近的不需要被移动即没有被标记为 Placement 的节点的 dom 的前面。

lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
  • 最后将 newfiber 添加到新 fiber 链表的最后,并设置 oldfiber 为 nextoldfiber,即 oldfiber.sibling

阶段一举例:

diff 阶段二

如果 newChildren 遍历完了,则从当前 oldfiber 开始删除所有的 oldfiber。

diff 阶段三

如果 oldfiber 都遍历完了,但是 newChildren 没有遍历完,那么需要直接根据这些 react 元素创建新的 fiber。并给这些新创建的 fiber 的 effectTag 添加 Placement 标记。

diff 阶段四

如果 oldfiber 与 newChildren 都没遍历完,

  • 先遍历 剩余的 oldfiber,如果fiber节点有key,那么以key的值为健,当前fiber为值,存入一个Map对象中。如果fiber节点没有key,则以fiber的index的值为健,当前fiber为值,存入这个Map对象中。
  • 然后从 newIdx 开始,遍历剩余的 newChildren
    • 每次遍历到一个 react 元素,会先去 map 中搜索,如果 react 元素是文本或者数字,那么会通过 newIdx 去 map 中找 oldfiber,如果是 object,则先用 key 去 map 中找,否则用 index 去 map 中找。如果找到了这样的 oldfiber,然后根据 react 元素与 oldfiber 的 type,决定新的 fiber 是否复用 oldfiber 的状态和数据。如果 react 元素不是上述类型,则新的 fiber 为 null。
    • 当创建出了一个不是 null 的新 fiber 之后,如果是更新阶段的 diff 并且 oldfiber 被复用了,则从 map 中删除这个 oldfiber。
    • 然后需要处理是否将新的 fiber 标记为 Placement,如果复用的那个 oldfiber 在 链表靠后的位置,也就是 index 比较大,那么 lastPlacedIndex 就是这个 index,往后遍历过程中所有生成的新的 fiber 如果没复用则新的 fiber 需要标记为 Placement,如果复用了,但是复用的 oldfiber 比前面的复用的 oldfiber 位置靠前,那么不需要更新 lastPlacedIndex 但是需要将新的 fiber 标记为 Placement。
  • 最后 map 中剩下的 oldfiber 都是没有被复用的,因此后续 commit 是需要删除这些 oldfiber 对应的 dom 节点,因此需要将 map 中剩余的 fiber 标记为删除,并添加到父节点的 effect 链表上。

最终,所有阶段结束之后,会将新的 fiber 组成的链表的头节点返回作为当前父节点 fiber 的 child,表示第一个子节点,子节点之间通过 sibling 链接。

由上面的栗子会发现一些问题,当列表中最后一个元素移动到列表的最前面的时候,dom 操作的顺序是,将列表前 n-1 个 dom 移动到最后一个元素的后面,显然这种情况 react 中的处理方式并不是最优的,所以应该避免这样的列表操作或者利用其他方法规避这样的问题的出现。

diff 中的 key 以及 index 起了什么作用

  • 总结 key 与 index 在整个 diff 阶段的作用:

key 相同是复用 oldfiber 的先决条件。index 表示的是 fiber 之间的相对位置,影响了 dom 是否需要被移动。

  • 总结 key 与 index 对组件实例属性的影响

Key 不相同或者 key 相同 type 不相同 ,总之 oldfiber 没有被复用,那么重新创建的 fiber 的 stateNode 的属性是 null,因此在 updateClassComponent 函数中,发现 stateNode 为 null, 因此会调用 constructClassInstance 函数创建组件的实例,也就导致了之前组件实例不会被复用,属性值被初始化了。当你想用组件实例来持久化存储一些数据的时候,不能修改其在父组件中的 key,并且需要给这样的组件加上一个 key。

  • 什么时候需要加上 key ?

当组件出现的位置不确定的时候,可以加上 key 来复用 fiber

当位置固定的时候,不需要加上 key,因为 react 默认给每个 react 元素以及 fiber 加上了 key,其值为 null,而 null === null,因此每次在 render 的时候,那些位置没有变化的组件或者 hostcompoenet 都会复用 oldfiber,hostcompoenet 对应的 dom 也不需要移动或者创建。

对于列表而言,需要加上 key 来提高渲染性能

  • 列表中对于 key 的处理有三种情况:

不加 key 的时候,react 默认 key 为 null,导致相同位置的节点 fiber 被复用,然后被复用并非就是好的,因为如果被复用的 fiber 子树层级深并且与新的组件的子树结构很不一样那么会导致子树多余的 diff,并直接删除老的子树的部分节点重新构建。因此当节点只是替换了顺序,那么就造成了错误的复用,以及多余的 fiber 重建,dom 的操作。

key 为 index 的时候,与上面一种情况类似,在没有 key 的时候,在阶段四从 map 中寻找可复用 fiber 的时候,react 会默认以 index 作为 key 来处理。

给 key 加上特殊的 id,当列表数据顺序变化的时候,可以复用 key 相同的项,加上 key 还能够避免非受控组件的值的混乱demo

diff 的时间复杂度

传统 diff 算法

diff 算法参考文献 对于两棵树进行 diff 并进行更新,暴力解法一一对比,时间复杂度为 o(n^3),简单来说,A,B两棵树,从A树的根节点开始去B树上遍历找节点o(n),找到那个节点之后,需要替换 A 树上这两个位置的节点时间复杂度为o(n),然后A的每个节点都需要这样的操作为o(n)因此总的时间复杂度为o(n^3)。

react 中 diff

react 中的 diff 的实现是广度优先遍历的方式,一层一层的比对,当某个节点的 key 不一样的时候,直接删除整个节点。时间复杂度为 o(n),删除节点然后重新创建。这样涉及的目的是,对于 dom 操作而言,不会频繁的出现一个节点的 dom 需要移动到某个祖先组件的子节点中。因此对于相对稳定的 dom 树来说层层对比够用了,当出现这种情况的时候,无非就是删除原来的节点,然后在祖先节点子节点中添加新的节点。