你不能不知道的Vue和React的Diff算法

556 阅读5分钟

前言

本篇文章主要是记录和学习VueReactDiff算法,其实关于两者笔者都有去了解但是并没有总结,借此篇文章来做一个总结。

React diff算法

可以参考我以前的文章,在原先的beginWork中提到,在update时,即更新组件的时候,他会将当前组件与该组件在上次更新的时候的Fiber节点进行比较,比较的过程就是我们所称为的diff算法。

React中diff算法有三个预设,如下:

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。
  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。
  3. 开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定。考虑如下例子:
// 更新前
<div>
  <p key="a">a</p>
  <h3 key="b">b</h3>
</div>

// 更新后
<div>
  <h3 key="b">b</h3>
  <p key="a">a</p>
</div>

如果我们我们没有设置key值,React会认为div的第一个子节点由p标签变为了h标签,并销毁而创建,如果设置了key值,则可以复用节点,只是交换顺序。

我们从Diff的入口函数reconcileChildFibers出发,该函数会根据newChild(即JSX对象)类型调用不同的处理函数

// 根据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分为两类:

  1. newChild类型为objectnumberstring,代表同级只有一个节点
  2. newChild类型为Array,同级有多个节点

单节点Diff

对于单个节点,我们以类型object为例,会进入reconcileSingleElement

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

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

如下图所示

微信图片_20211010152850.png

那么如何判断DOM节点是否可以复用呢

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通过先判断key是否相同,如果key相同则判断type是否相同,只有都相同时一个DOM节点才能复用。

多节点Diff

这里我们考虑三种情况

  1. 节点更新
// 之前
<ul>
  <li key="0" className="before">0<li>
  <li key="1">1<li>
</ul>

// 之后 情况1 —— 节点属性变化
<ul>
  <li key="0" className="after">0<li>
  <li key="1">1<li>
</ul>

// 之后 情况2 —— 节点类型更新
<ul>
  <div key="0">0</div>
  <li key="1">1<li>
</ul>

2.节点新增和减少

// 之前
<ul>
  <li key="0">0<li>
  <li key="1">1<li>
</ul>

// 之后 情况1 —— 新增节点
<ul>
  <li key="0">0<li>
  <li key="1">1<li>
  <li key="2">2<li>
</ul>

// 之后 情况2 —— 删除节点
<ul>
  <li key="1">1<li>
</ul>

3.节点位置发生变化

// 之前
<ul>
  <li key="0">0<li>
  <li key="1">1<li>
</ul>

// 之后
<ul>
  <li key="1">1<li>
  <li key="0">0<li>
</ul>

Diff思路

Diff算法的逻辑会经过两轮遍历

  1. 第一轮遍历处理更新节点
  2. 第二轮处理不属于更新的节点
第一轮遍历
  1. 遍历newChildrenoldFiber, 判断Dom节点是否可复用
  2. 如果不可复用,则可能是以下两种情况引起的
  • key不同导致不可复用,立即跳出整个遍历,第一轮遍历结束。
  • key相同type不同导致不可复用,会将oldFiber标记为DELETION,并继续遍历
  1. 如果newChildren或者olderFiber某一个先遍历完则也是跳出第一轮遍历
第二轮遍
  1. 比较理想的结果是newChildrenoldFiber同时遍历完,这样意味着只存在节点更新,这样遍历第一遍就可以了,diff算法结束
  2. newChildren遍历完,olderFiber没有遍历完,这样意味着本次更新删除了某些节点,这样的话遍历剩下的olderFiber,并标记为Deletion
  3. newChildren没有遍历完,olderFiber遍历完,这样意味着本次更新新增了一些节点,这样的话需要遍历剩下的newChildren为生成的workInProgress fiber依次标记Placement
  4. newChildrenoldFiber 都没有遍历完,那就要从下面考虑,也是diff算法的核心 首先,我们要确定标记节点是否移动,因此我们会用到几个名词,lastPlacedIndex,遍历到最后一个可复用节点的下标,oldIndexoldFiber中节点的下标。

如果oldIndex < lastPlacedIndex,代表本次更新该节点需要向右移动。

lastPlacedIndex初始为0,每遍历一个可复用的节点,如果oldIndex >= lastPlacedIndex,则lastPlacedIndex = oldIndex。我们从下面的例子考虑:

如下所示 每个字母代表一个节点,字母的值代表节点的`key`
// 之前
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节点被标记为移动

以上也相对比较好理解,就是比较顺序判断是否移动

vue diff算法

这里讲的是对应vue2的diff算法, vue中diff算法也是同层比较,当数据变化触发Xsetter,调用Dep.notify,通知wathcer去调用update,然后调用patch,就是vue响应式原理的那一部分,可以参考我以前的文章,然后给真是DOM打补丁。

patch

对比是否为同一类型的标签,如果相同则继续比较,不是的话就将整个节点替换为新的节点.

function patch(oldVnode, newVnode) {
  // 比较是否为一个类型的节点
  if (sameVnode(oldVnode, newVnode)) {
    继续进行深层比较
    patchVnode(oldVnode, newVnode)
  } else {
    // 不同
    const oldEl = oldVnode.el // 旧虚拟节点的真实DOM节点
    const parentEle = api.parentNode(oldEl) // 获取父节点
    createEle(newVnode) // 创建新虚拟节点对应的真实DOM节点
    if (parentEle !== null) {
      api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
      api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
      // 设置null,释放内存
      oldVnode = null
    }
  }

  return newVnode
}

sameVnode

判断节点是否为同一类型节点

function sameVnode(oldVnode, newVnode) {
  return (
    oldVnode.key === newVnode.key && // key值是否一样
    oldVnode.tagName === newVnode.tagName && // 标签名是否一样
    oldVnode.isComment === newVnode.isComment && // 是否都为注释节点
    isDef(oldVnode.data) === isDef(newVnode.data) && // 是否都定义了data
    sameInputType(oldVnode, newVnode) // 当标签为input时,type必须是否相同
  )
}

patchVnde

当为同类型节点调用patchVnde

function patchVnode(oldVnode, newVnode) {
  const el = newVnode.el = oldVnode.el // 获取真实DOM对象
  // 获取新旧虚拟节点的子节点数组
  const oldCh = oldVnode.children, newCh = newVnode.children
  // 如果新旧虚拟节点是同一个对象,则终止
  if (oldVnode === newVnode) return
  // 如果新旧虚拟节点是文本节点,且文本不一样
  if (oldVnode.text !== null && newVnode.text !== null && oldVnode.text !== newVnode.text) {
    // 则直接将真实DOM中文本更新为新虚拟节点的文本
    api.setTextContent(el, newVnode.text)
  } else {
    // 否则

    if (oldCh && newCh && oldCh !== newCh) {
      // 新旧虚拟节点都有子节点,且子节点不一样

      // 对比子节点,并更新
      updateChildren(el, oldCh, newCh)
    } else if (newCh) {
      // 新虚拟节点有子节点,旧虚拟节点没有

      // 创建新虚拟节点的子节点,并更新到真实DOM上去
      createEle(newVnode)
    } else if (oldCh) {
      // 旧虚拟节点有子节点,新虚拟节点没有

      //直接删除真实DOM里对应的子节点
      api.removeChild(el)
    }
  }
}

updateChildren

这个是diff算法比较中要的点之一 这样我们也举个例子

// 之前
abcd

// 之后
acdb

首先是调用首尾指针法 然后会进行互相进行比较,总共有五种比较情况:

  • 1、oldStart 和 newStart 使用sameVnode方法进行比较,sameVnode(oldS, newS)
  • 2、oldStart 和 newEend 使用sameVnode方法进行比较,sameVnode(oldS, newE)
  • 3、oldEend 和 newStart 使用sameVnode方法进行比较,sameVnode(oldE, newS)
  • 4、oldEend 和 newEnd 使用sameVnode方法进行比较,sameVnode(oldE, newE)
  • 5、如果以上逻辑都匹配不到,再把所有旧子节点的 key 做一个映射到旧节点下标的 key -> index 表,然后用新 vnode 的 key 去找出在旧节点中可以复用的位置。

第一步

微信图片_20211024152239.png

oldStart = a  oldEnd = d
newStart = a  oldEnd = b

此时oldStart = newStart, a节点不需要移动 第二步

微信图片_20211024163419.png

oldStart = b  oldEnd = d
newStart = c  oldEnd = b

此时oldStart = oldEnd

image.png

此时b往后移动,接下来就一样了。

总结

在此总结了vuereactdiff算法,可能有些地方不对的地方希望大家能够指正,以后需要多加研究。