React虚拟DOM 和 DOM Diff 调和算法解析

734 阅读12分钟

代码已经关联到github: 链接地址 觉得不错可以顺手点个star,这里会持续分享自己的开发经验(:

虚拟DOM

什么是虚拟DOM

一种编程的概念,与真实DOM节点对应,一个能表示真实DOM的对象。

React下的虚拟DOM

在React属于ReactElement对象,格式如下:

const reactElement = {
  key:null,
  props:{
    className:"",
  	onClick: () => {},
  	children:[]
  },
  type:'div'
}

代码创建: 使用 React.createElement 或者 jsx

React.createElement('div',{className:"",onClick: () => {}},[
  //child...
])

DOM操作慢?虚拟DOM快?

二者其实不能简单的概括,因为虚拟DOM最终还是会操作真实DOM。

  • 真实DOM操作慢是对比于js原生的api,如数组操作。
  • 任何基于DOM的库(React、Vue),因为都会操作DOM,所以不会比真实DOM快。
  • 正常节点较少时,虚拟DOM会比真实的DOM渲染快(不是操作快,也就是不可交互时间短),而当节点太多时,如10W,则真实的DOM操作更快。

那为什么还会有人说DOM慢,虚拟DOM快呢?因为在某些情况下,虚拟DOM确实比真实DOM快。首先,我们要理解一个概念,JS计算比操作dom更快

  1. 减少DOM操作
    1. 合并DOM操作,减少DOM操作次数。(增加1000个节点,计算1000次,插入一次)
    2. 虚拟DOM借助DOM diff可以把多余的操作省略,减少DOM操作的范围(增加1000个节点,比较后,只有10个是新增的)
  2. 跨平台

虚拟DOM本质上是js对象,所以可以转化成如小程序的组件,安卓的视图等等。

React和JS控制原生DOM的差异

事件(如 click )

  • JS的点击事件是监听在对应dom上的,当然也可以通过事件委托给直接委托给父辈元素,
  • React的点击事件,是监听在虚拟dom上的,而React的事件是合成事件,可以理解为委托到root节点的事件

属性(如class style)

  • JS直接去修改对应的属性
  • React则是通过props或者state的改变,触发重新渲染,domDiff,合并修改,修改属性。

DOM diff

什么是DOM diff

当前更新的组件与该组件在上次渲染时对应的Fiber节点比较(也就是俗称的Diff算法),将比较的结果生成新Fiber节点这一过程。

DOM Diff 策略

两棵树两两比较,即使是最优的算法,复杂度仍然为O(n 3 ),而React基于三个策略将算法优化:

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。
  2. 两个不同类型的元素会产生出不同的树,所以只要当前节点的类型改变,React会销毁其子孙节点并创建新的子孙节点。
  3. 设置key属性可以对子元素的复用提供帮助。特别是对于同一层级的数组节点,每个子节点会通过唯一 key 进行Diff,相同key的节点保留。

最新的Fiber 调和算法,是旧fiber树与新ReactElement树进行深度优先遍历,在遍历的过程中,每遍历一个节点,只比较同层的元素,避免对两棵树做完全比较,所以算法的复杂度可以达到O(n)。

Key的作用例子

  1. 两个子元素,删除后一个(不存在key的情况)。 标签类型和标签属性不变,不用更新;子元素从[1,2]变成了[2],1标签没变,但是children变了,更新内容(子元素2的内容放到了这边);子元素2不见了,删除对应dom。

  2. 两个子元素,删除后一个(存在key的情况)。 标签类型和标签属性不变,不用更新;子元素从[1,2]变成了[2],但是因为存在key,计算机知晓是 key:1 的元素删除了,2不变,所以会直接删除1,保留2.

  3. 两个子元素,首位插入元素(不存在key的情况) 标签属性改变,插入新的元素,元素从[1,2]变成[3,1,2],两两比较发现元素均已改变,需全部重建,比较耗费性能。

  4. 两个子元素,首位插入元素(存在key的情况) 标签属性改变,插入新的元素,元素从[1,2]变成[3,1,2],两两比较发现元素均已改变,但是React内部获取查找是否存在复用的节点,这里只需新建节点3,提高了性能。

DOM Diff 流程全解析

新版的Diff入口函数为: reconcileChildFibers,属于 React Fiber 任务循环中的一环,会在构建Fiber树阶段运行。

该函数使用旧的fiber节点与新的ReactElement对象相比较,返回新的fiber节点:

React包位置:packages/react-reconciler/src/ReactChildFiber.js

function reconcileChildFibers(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChild: any,
    lanes: Lanes,
):  Fiber | null {
  const isObject = typeof newChild === 'object' && newChild !== null;
  if (isObject) {
    // object类型,根据不同的元素类型分别处理
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
      // 调用 reconcileSingleElement 处理
      // ...省略
    }
  }

  if (typeof newChild === 'string' || typeof newChild === 'number') {
    // 调用 reconcileSingleTextNode 处理
    // ...省略
  }

  if (isArray(newChild)) {
    // 调用 reconcileChildrenArray 处理
    // ...省略
  }

  // 一些其他情况调用处理函数
  // ...省略

  // 以上都没有命中,删除节点
  return deleteRemainingChildren(returnFiber, currentFirstChild);

}

从源码中可以看到,DOM diff会根据节点类型去使用不同的比较算法,当然主要还是单节点和多节点的情况,我们下面从分别深入分析二者Diff算法:

单节点Diff

我们以object类型中的是否复用一个React元素为例,Diff会进入到reconcileSingleElement,其整体逻辑为:

  • 根据key和元素类型type是否一致
    • 如果都一致,则可复用真实DOM节点(stateNode),
    • 如果不一致,标记该fiber节点需要删除,并返回新生成的Fiber节点

该函数使用旧的fiber节点与新的ReactElement对象相比较,返回新的fiber节点,具体源码解析如下:

function reconcileSingleElement(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    element: ReactElement,
    lanes: Lanes,
  ): Fiber {
    const key = element.key;
    let child = currentFirstChild;
    //判断当前节点是否存在
    while (child !== null) {
      // TODO: If key === null and child.key === null, then this only applies to
      // the first item in the list.
      //key是否一样
      if (child.key === key) {
        switch (child.tag) {
					//...省略case,核心都是判断tag是否一样 , type相同则表示可以复用
          if (child.elementType === element.type) {
            //其他兄弟元素都标记为删除
            deleteRemainingChildren(returnFiber, child.sibling);
            // 使用新的属性克隆当前fiber,并返回
            const existing = useFiber(child, element.props);
            return existing;
          }
        }
        //key一样,但是tag不一样,后续的兄弟节点也不需要遍历了,自身和兄弟节点均标记为删除
        deleteRemainingChildren(returnFiber, child);
        break;
      } else {
        //key不一样,则当前节点不可复用,标记删除
        deleteChild(returnFiber, child);
      }
      //遍历所有同层级的兄弟节点
      child = child.sibling;
    }

    //省略创建新的Fiber并返回
  }

可以看出来,单节点的Diff的代码还是比较简单的,唯一不好理解的点在于,将兄弟节点标记为删除的两种情况:

  • 复用了节点,标记其他兄弟节点删除
  • key一样,但是type不一样,标记自身和其他兄弟节点删除

这是因为要兼容旧的Fiber树渲染了多个节点,而新的只有单个节点的情况。 比如之前有三个节点,更新后只有一个,那当复用了其中一个的时候,其他两个自然要标记为删除;同理如果key一样的节点都不能复用,那所有的节点当然要标记为删除。

多节点Diff

多节点的Diff比单节点稍微复杂一些,React根据在日常界面中DOM更新频率更高的特点,提高了判断更新的优先级,将Diff分成了两个遍历阶段:

  • 第一轮遍历,判断节点是否可以复用
    • 如果可复用,继续遍历
    • 如果不可复用,key不同导致的直接结束遍历type不同导致的标记当前fiber节点需要删除,继续遍历
    • 直到新的React元素数组newChildren遍历完毕,或者旧的fiber节点oldFiber遍历完毕,结束遍历。
  • 第二轮遍历,会根据第一轮遍历的不同情况处理
    • newChildrenoldFiber 均遍历完,说明此处只做更新,不需要二次遍历。
    • newChildren 遍历完, oldFiber 没遍历完,说明此处需要删除节点,所以需要遍历剩下的oldFiber节点,标记删除。
    • newChildren 没遍历完,oldFiber 遍历完了,说明此处需要插入新节点,且旧节点都复用了,所以需要遍历剩下的newChildren生成新的fiber节点。
    • newChildrenoldFiber都没有遍历完,说明这次更新中有节点改变了位置,需要移动,这也是该Diff难以理解的地方,后续我们会详细举例分析。

对于多节点的Diff,会进入到reconcileChildrenArray,该函数使用旧的fiber节点与新的ReactElement数组相比较,返回数组的第一个fiber节点,具体源码解析如下:

React包位置:packages/react-reconciler/src/ReactChildFiber.js

  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;
    //==第一轮遍历,遍历newChildren
    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
      //旧fiber的指针向后移动,保证两次遍历的元素索引是一致的
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
        nextOldFiber = oldFiber.sibling;
      }
      // 同索引的new和old进行比较, 如果key不同, 返回null
      // key相同, 比较type是否一致. type一致则执行useFiber(update逻辑), type不一致则运行createXXX(insert逻辑)
      const newFiber = updateSlot(
        returnFiber,
        oldFiber,
        newChildren[newIdx],
        lanes,
      );
      if (newFiber === null) {
        // 如果返回null, 表明key不同, 存在非更新节点的情况, 退出循环
        if (oldFiber === null) {
          oldFiber = nextOldFiber;
        }
        break;
      }
      //非初次创建fiber, 此时shouldTrackSideEffects被设置为true
      if (shouldTrackSideEffects) {
        //type不同,新建了节点,旧的节点需要标记为删除
        if (oldFiber && newFiber.alternate === null) {
          deleteChild(returnFiber, oldFiber);
        }
      }
      // lastPlacedIndex 最后一个可复用的节点在oldFiber中的位置索引
      // 如果当前节点可复用, 则要判断位置是否移动.
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      //构造新的fiber链表
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
      oldFiber = nextOldFiber;
    }

    // newChildren 遍历完, oldFiber 没遍历完,说明此处需要删除节点,所以需要遍历剩下的oldFiber节点,标记删除。
    if (newIdx === newChildren.length) {
      deleteRemainingChildren(returnFiber, oldFiber);
      return resultingFirstChild;
    }

    //  oldFiber 遍历完了,newChildren 没遍历完,说明此处需要插入新节点,且旧节点都复用了,所以需要遍历剩下的newChildren生成新的fiber节点。
    if (oldFiber === null) {
			//...省略该部分代码
      //注意同第一次遍历,如果当前节点可复用, 此处也存在判断位置是否移动.
      return resultingFirstChild;
    }

    //==第二轮遍历,遍历newChildren

    // 将旧的fiber转为map,方便使用key快速找到
    const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

    // 遍历剩余的newChildren,找出可复用的节点
    for (; newIdx < newChildren.length; newIdx++) {
      //根据key查找旧节点是否可复用
      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) {
      // newChildren已经遍历完, 那么oldFiber序列中剩余节点都视为删除(打上Deletion标记)
      existingChildren.forEach(child => deleteChild(returnFiber, child));
    }

    return resultingFirstChild;
  }
节点存在移动情况解析

我们的目标是寻找移动的节点,那么我们需要明确:节点是否移动参照物是什么? React使用的参照物是:最后一个可复用的节点在oldFiber中的位置索引 lastPlacedIndex。 ​

因为我们更新是按照newChildren遍历的,在遍历过程中,如果没有移动情况,当前React元素的可复用的节点的索引oldIndex总是比上次复用的节点索引lastPlacedIndex大。 所以一旦 当前React元素 使用的_可复用节点 的索引oldIndex,比上次_可复用节点的 索引lastPlacedIndex小,那就说明现在这个更新的对应的旧的节点要往右移动。 ​

下面我们跟随源码分析一下:

  function placeChild(
    newFiber: Fiber,
    lastPlacedIndex: number,
    newIndex: number,
  ): number {
    //新的fiber节点更新索引
    newFiber.index = newIndex;
    if (!shouldTrackSideEffects) {
      // Noop.
      return lastPlacedIndex;
    }
    //获取可复用的节点fiber
    const current = newFiber.alternate;
    if (current !== null) {
      const oldIndex = current.index;
      //该可复用节点之前位置索引小于这次更新需要插入的位置索引,代表该节点需要移动(向右)
      if (oldIndex < lastPlacedIndex) {
        // This is a move.
        newFiber.flags = Placement;
        return lastPlacedIndex;
      } else {
        // This item can stay in place.
        // 该可复用节点之前位置索引大于或者等于这次更新需要插入的位置索引,代表该节点不需要移动
        // 每遍历一个可复用的节点,如果oldIndex >= lastPlacedIndex,则lastPlacedIndex = oldIndex
        return oldIndex;
      }
    } else {
      // This is an insertion.
      // 不存在可复用的节点
      newFiber.flags = Placement;
      return lastPlacedIndex;
    }
  }

单纯的文字和源码可能还是难以理解,我们下面举个例子逐步分析:

//更新前
abcd

//更新后
abdc

==第一轮遍历==
newChildren === abdc
oldFiber === abcd
lastPlacedIndex === 0

a(之前) vs a(之后)
可复用
oldIndex = 0,等于lastPlacedIndex,不需要移动,更新lastPlacedIndex =  oldIndex = 0

b(之前) vs b(之后)
可复用
oldIndex = 1,大于lastPlacedIndex,不需要移动,更新lastPlacedIndex =  oldIndex = 1

c(之前) vs d(之后)
key改变,不可复用,结束遍历,此时 lastPlacedIndex = 1

//..省略中间,因为未执行删除和插入

==第二轮遍历==
newChildren === dc
oldFiber === cd
lastPlacedIndex === 1

比较d
oldFiber中存在,可复用
oldIndex = 3, 大于lastPlacedIndex,不需要移动,lastPlacedIndex =  oldIndex = 3


比较c
oldFiber中存在,可复用
oldIndex = 2, 小于lastPlacedIndex,需要移动,lastPlacedIndex 不变,还是3

newChildren遍历完毕

==最终==
abd三个节点不动,c节点向右移动到d之后

从例子我们可以看出,节点的顺序是以更新后的元素顺序优先占位,abcd => abdc,React实际上是先定位好abd的顺序,然后c向左移动。 这也提醒我们,要避免后面的节点往前移动的操作,因为这样React会保持后面的节点位置不变,前面的节点依次往右移动,耗费性能。比如 abcd=>dabc,看起来肯定是d往最前面移动,但是实际上,React会定位好dabc依次向后移动。 ​

如果还是难以理解或者想调试源码加深理解,可以到github.com/wqhui/react…下载调试。 ​

DOM Diff例子

Key的作用

  1. 两个子元素,删除后一个(不存在key的情况)。

    标签类型和标签属性不变,不用更新;子元素从[1,2]变成了[2],1标签没变,但是children变了,更新内容(子元素2的内容放到了这边);子元素2不见了,删除对应dom。

  2. 两个子元素,删除后一个(存在key的情况)。

标签类型和标签属性不变,不用更新;子元素从[1,2]变成了[2],但是因为存在key,计算机知晓是 key:1 的元素删除了,2不变,所以会直接删除1,保留2.

参考

React 算法之调和算法 react.iamkasong.com/diff/multi.… On react virtual DOM, diff algorithm and key mechanism