《React技术揭秘》学习笔记 (三):实现篇之diff算法

406 阅读7分钟

前言

React 中最值得称道的部分莫过于 Virtual DOM 与 diff 的完美结合,然而,diff 算法其实在 React 之前就已经出现了。React的开发团队对传统的 diff 算法进行改进,使它的的时间复杂度从O(n3)降到了O(n)。因此,在这篇文章中,我们就来聊一聊Diff算法的前世今生。在这之前,我已经写过一些跟react源码相关的文章了,前文之路:

传统diff算法

计算一棵树型结构转换成另一棵树形结构的最少操作,是一个复杂且值得研究的问题。传统的diff算法通过循环递归对节点进行一次对比,其时间复杂度达到O(n3),例如1000个节点就要进行数10亿次的比较,效率十分低下。举个栗子:

image.png

对于如图所示的更改,我们需要遍历左侧树的所有节点和右侧树的所有结点,对比对应的节点是否相同,遍历的顺序为:PA -> LA, PA -> LD, PA -> LB, PA -> LC... 其时间复杂度为O(n2),在发现不同的时候,需要进行增加、删除、替换等操作。我们知道,要删除或者添加树的节点需要O(n)的时间。因此,依次Diff算法的时间复杂度为O(n3)。

React diff 改进

React对传统的Diff算法进行优化,为降低算法复杂度,React对Diff算法预设三个限制:

  • 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用它
  • 两个不同类型的元素会产生出不同的树。如果元素由div变成p,React会销毁div及其子孙节点,并新建p及其子孙节点
  • 开发者可以通过key prop来暗示哪些子元素在不同的渲染下能保持稳定

这三个预设限制分别对应于Diff算法的Tree Diff、Component Diff,以及Element Diff,可以用下图进行说明:

image.png

自己实现diff算法

要想自己实现Diff算法,我们首先要抽象出虚拟DOM(virtual dom, 下面简称vd)。我们知道DOM无非这三个属性:

  1. 标签名
  2. 各种属性
  3. 子节点

因此我们可以这样定义虚拟DOM:

class vNode{
    constructor(tagName, attributes, children){
        this.tagName = tagName
        this.attributes = attributes
        this.children = children
    }
}

有了虚拟DOM之后,我们就可以利用Diff算法进行对比了。基于diff算法的对比主要有四种类型的比较:

  • 新增:新的vd树中有该节点,而旧的没有。这时需要添加新的vd节点
  • 删除:新的vd树中没有该节点,而旧的有。这时需要删除旧的vd节点
  • 替换:新旧vd节点的tagName属性不同,这时需要删除旧节点并创建新节点
  • 更新:值tagName属性相同,而另外两种属性不同的情况。需要进行属性的更新

代码实现如下:

diff(newVnode, oldVNode) {
    
    if(!newVNode) {
        // 新节点中没有,说明是删除旧节点的
        return {
            type: 'remove'
        }
    } else if(!oldVNode) {
        // 新节点中有旧节点没有的,说明是删除
        return {
            type: 'create',
            newVNode
        }
    } else if(isDiff(newVNode, oldVNode)) {
        // 只要对比出两个节点的tagName不同,说明是替换
        return {
            type: 'replace',
            newVNode
        }
    } else {
        // 其他情况是更新节点,要对比两个节点的attributes和孩子节点
        return {
            type: 'update',
            attributes: diffAttributes(newVNode, oldVNode),
            children: diffChildren(newVNode, oldVNode)
        }
    }
}

// 对比孩子节点,其实就是遍历所有的孩子节点,然后调用diff对比
function diffChildren(newVnode, oldVNode) {
    var patches = []
    // 这里要获取两个节点中的最大孩子数,然后再进行对比 
    var len = Math.max(newVnode.children.length, oldVNode.children.length);
    for(let i = 0; i <len; i++) {
        patches[i] = diff(newVnode.children[i], oldVnode.children[i])
    }
    return patches
}

// 对比attribute,只有两种情况,要不就是值改变/新建,要不就是删除值,对比dom只有setAttribute和removeAttribute就知道了
function diffAttributes(newVnode, oldVNode) {
    var patches = []
    // 获取新旧节点的所有attributes
    var attrs = Object.assign({}, oldVNode.attributes, newVNode.attributes)
    for(let key in attrs) {
        let value = attrs[key]
        // 只要新节点的属性值和久节点的属性值不同,就判断为新建,不管是更新和真正的新建都是调用setAttribute来更新
        if(oldVNode.attributes[key] !== value) {
            patches.push({
                type: 'create',
                key,
                value: newVnode.attributes[key]
            })
        } else if(!newVNode.attributes[key]) {
            patches.push({
                key,
                type: 'remove'
            })
        }
    }
    return patches
}

// 判断两个节点是否不同
function isDiff(newVNode, oldVNode) {
    // 正常情况下,只对比tagName,但是text节点对比没有tagName,所以要考虑text节点
    return (typeof newVNode === 'string' && newVNode !== oldVNode) 
    || (typeof oldVNode === 'string' && newVNode !== oldVNode) 
    || newVNode.tagName !== oldVNode.tagName
}

React实现diff算法

我们从diff的入口函数reconcileChildFibers出发,该函数会根据newChild类型调用不同的处理函数。关键代码如下:

// 根据newChild类型选择不同diff函数处理
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
): Fiber | null {

  const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 调用 reconcileSingleElement 处理
      // // ...省略其他case
    }
  }

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

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

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

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

在这段代码中,我们可以看到根据同级节点的数量将diff算法分为两类:

  • 单节点diff:同级只有一个节点时
  • 多节点diff:同级有多个节点时

单节点diff

newChild类型为object,number,string时,代表同级只有一个节点。我们以object为例,来说明单节点diff。首先,我们来看在入口函数reconcileChildFiberobject的处理:

const isObject = typeof newChild === 'object' && newChild !== null;

if(isObject) {
    switch(newChild.$$typeof) {
        // 对象类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
        case REACT_ELEMENT_TYPE: 
            return placeSingleElement(
                reconcileSingleElement(
                    returnFiber,
                    currentFirstChild,
                    newChild,
                    lanes,
                ),
            );
        ...
    }
    ...
}
...

从这段代码中,我们可以看出来,对于单节点diff,主要的处理函数是reconcileSingleElement这个函数将会做如下事情:

image.png

我们关键看第二步的具体工作流程,先看代码:

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  
  // 首先判断是否存在对应DOM节点
  while (child !== null) {
    // 上一次更新存在DOM节点,接下来判断是否可复用

    // 首先比较key是否相同
    if (child.key === key) {

      // key相同,接下来比较type是否相同

      switch (child.tag) {
        // ...省略case
        
        default: {
          if (child.elementType === element.type) {
            // type相同则表示可以复用
            // 返回复用的fiber
            return existing;
          }
          
          // type不同则跳出switch
          break;
        }
      }
      // 代码执行到这里代表:key相同但是type不同
      // 将该fiber及其兄弟fiber标记为删除
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // key不同,将该fiber标记为删除
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  // 创建新Fiber,并返回 ...省略
}

在这段代码中,我们关注以下细节:

  • react diff首先判断key是否相同,如果key相同时判断type是否相同,只有在key和type都相同的时候DOM节点才可以复用
  • key相同,type不同时,将该fiber及其兄弟fiber标记为删除
  • key不同,将该fiber标记为删除

多节点diff

newChild类型为Array时,代表同级有多个节点。在文章的最后一个部分,我们来看看React如何处理同级多个节点的diff。首先,我们归纳一下要处理的情况:

  • 节点更新
  • 节点新增或减少
  • 节点位置变化

踩坑姐插一句:感觉这个部分和我们自己实现diff算法的那个部分很多内容重合了

React团队发现,在日常的开发中,相较于新增和删除,更新组件发生的频率更高。因此Diif会优先判断当前节点是否属于更新。基于以上原因,Diff算法会经历两轮遍历:

  • 第一轮遍历:
    • 目的:处理更新节点
    • 步骤:
      1. let i = 0,遍历newChildren,将newChildren[i]oldFiber比较,判断DOM节点是否可复用
      2. 如果可复用,i++,继续比较newChildren[i]oldFiber.sibling,如果可复用则继续便利
      3. 如果不可复用,分为两种情况:
        • key不同导致不可复用,则立即跳出整个遍历,第一轮遍历结束
        • key相同type不同导致不可复用, 则将oldFiber标记为DELETION,并继续便利
      4. 如果newChildren遍历完,或者oldFiber遍历完,跳出遍历,第一轮遍历结束
  • 第二轮遍历:
    • 目的:处理不属于更新的节点
    • 第二轮遍历在第一轮遍历结束之后,我们对第一轮遍历的结果,分别进行讨论:
      • newChildren oldFiber同时遍历完:最理想的状况,直接Diff结束了
      • newChildren没遍历完,oldFiber遍历完:这种情况说明,newChildren多了,需要进行增加操作,我们需要遍历剩下的newChildren为生成的workInProgress fiber依次标记Placement
      • newChildren遍历完,oldFiber没遍历完:这种情况说明,oldFiber多了,需要进行删除操作。和增添操作对应,我们需要遍历剩下的oldFiber,依次标记Deletion
      • newChildrenoldFiber都没遍历完:这种情况是最麻烦的,我们单独来说明。

接下来,我们重点了解一下如何处理newChildrenoldFiber都没有遍历完的情况。首先我们要知道,为什么会出现这种情况。出现这种情况是第一轮遍历在第三步key不同导致不可复用key不同说明对应位置上的两个标签是不同的标签,可能需要移动位置,也可能直接更改了。因此,我们需要处理是否出现节点移动的情况。原文中对于处理节点改变位置的描述如下:

由于有节点可能改变了位置,所以不能再用位置索引i对比前后的节点。这时,我们需要用到key,即将所有还未处理的oldFiber存入以key为key,oldFiber为value的Map中。接下来遍历剩余的newChildren,通过newChildren[i].key就能在existingChildren中找到key相同的oldFiber.

由于本次更新节点是按newFiber的顺序排列。在遍历newChildren过程中,每个遍历到的可复用节点一定是当前遍历到的所有可复用节点中最靠右边的那个,即一定在lastPlaceIndex对应的可复用节点在本次更新中位置的后面。那么我们只需要比较遍历到的可复用节点在上次更新是否也在lastPlaceIndex对应的oldFiber后面,就能知道两次更新中的相对位置改变没有。

哇,真是一段晦涩难懂的文字。我们通过如下的例子来理解一下:

// 之前
abcd

// 之后
acdb

===第一轮遍历开始===
a(之后)vs a(之前)  
key不变,可复用
此时 a 对应的oldFiber(之前的a)在之前的数组(abcd)中索引为0
所以 lastPlacedIndex = 0;

继续第一轮遍历...

c(之后)vs b(之前)  
key改变,不能复用,跳出第一轮遍历
此时 lastPlacedIndex === 0;
===第一轮遍历结束===

===第二轮遍历开始===
newChildren === cdb,没用完,不需要执行删除旧节点
oldFiber === bcd,没用完,不需要执行插入新节点

将剩余oldFiber(bcd)保存为map

// 当前oldFiber:bcd
// 当前newChildren:cdb

继续遍历剩余newChildren

key === c 在 oldFiber中存在
const oldIndex = c(之前).index;
此时 oldIndex === 2;  // 之前节点为 abcd,所以c.index === 2
比较 oldIndex 与 lastPlacedIndex;

如果 oldIndex >= lastPlacedIndex 代表该可复用节点不需要移动
并将 lastPlacedIndex = oldIndex;
如果 oldIndex < lastplacedIndex 该可复用节点之前插入的位置索引小于这次更新需要插入的位置索引,代表该节点需要向右移动

在例子中,oldIndex 2 > lastPlacedIndex 0,
则 lastPlacedIndex = 2;
c节点位置不变

继续遍历剩余newChildren

// 当前oldFiber:bd
// 当前newChildren:db

key === d 在 oldFiber中存在
const oldIndex = d(之前).index;
oldIndex 3 > lastPlacedIndex 2 // 之前节点为 abcd,所以d.index === 3lastPlacedIndex = 3;
d节点位置不变

继续遍历剩余newChildren

// 当前oldFiber:b
// 当前newChildren:b

key === b 在 oldFiber中存在
const oldIndex = b(之前).index;
oldIndex 1 < lastPlacedIndex 3 // 之前节点为 abcd,所以b.index === 1
则 b节点需要向右移动
===第二轮遍历结束===

最终acd 3个节点都没有移动,b节点被标记为移动

在这个例子中,有三个关键点:

  • 跳出第一轮循环之后保存现场,即保存没遍历的oldFiber。不需要保存newChildren是因为跳出循环时的index就是newChildren需要保存的信息
  • 保存遍历到的oldIndex
  • 将两个Index进行比较:
    • 如果oldIndex >= lastPlacedIndex, 则标记不移动
    • 如果oldIndex < lastPlaceIndex, 则标记移动

原因如原文中所说:

节点移动的参照物是:最后一个可复用节点的oldFiber中的位置索引

参考资料