Vue diff算法 - 简单diff

diff算法是渲染引擎用于优化DOM操作的核心技术,通过对比新旧虚拟节点,减少实际DOM变更次数以提升性能。文章介绍了当子节点为一组时,如何通过diff算法高效地更新DOM,避免无谓的销毁和挂载操作。同时,引入key的概念,利用key实现更精确的节点匹配和复用,进一步优化了移动节点和添加、删除节点的效率。

什么是diff算法

简单来说当新旧vnode的子节点都是一组子节点时,为了以最小的性能开销完成更新操作。需要比较两组子节点,而用于比较的算法就叫做diff算法。渲染器的核心diff算法就是为了解决频繁操作DOM操作开销大的问题。

为什么减少DOM操作的性能开销

如果没有diff算法的话,要想实现dom的变化必须要把旧的节点销毁掉,再挂载新的节点。由于没有复用任何的DOM元素,会产生极大的性能开销。以下面的虚拟节点为例:

const oldNode = {
    type: 'div',
    children: [
        {type: 'p', children: '1'},
        {type: 'p', children: '2'},
        {type: 'p', children: '3'}
    ]
}
const newNode = {
    type: 'div',
    children: [
        {type: 'p', children: '4'},
        {type: 'p', children: '5'},
        {type: 'p', children: '6'}
    ]
}

在没有diff算法的情况下,我们需要执行6次DOM操作。两组子节点之间差的只是children中每个子节点的内容,因此理论上我们只需要3次DOM就能够完成节点的更新,性能也会提升一倍。

/**
 * 
 * @param {新节点} newNode 
 * @param {旧节点} oldNode 
 * @param {父节点} container 
 */
const patchChildren = (newNode, oldNode, container) => {
  const newChildren = newNode.children;
  const oldChildren = oldNode.children;

  const newLen = newChildren.length;
  const oldLen = oldChildren.length;

  const commonLen = Math.min(newLen, oldLen);

  let i = 0;
  while (i < commonLen) {
    // 比对新旧节点
    patchNode(newChildren[i], oldChildren[i]);
    i++;
  }

  if (newLen > oldLen) {
    while (i < newLen) {
      // 插入新节点
      patchNode(newChildren[i], null, container);
      i++;
    }
  } else {
    while (i < oldLen) {
      // 卸载旧节点
      unmount(oldChildren[i]);
      i++;
    }
  }
};

DOM复用和key的作用

以上方法仍然存在优化的空间,比如以下两组子节点拿来patch

const oldNode = {
    type: 'div',
    children: [
        {type: 'p'},
        {type: 'span'},
        {type: 'div'}
    ]
}
const newNode = {
    type: 'div',
    children: [
        {type: 'div'},
        {type: 'p'},
        {type: 'span'}
    ]
}

两组子节点的区别就是标签的顺序不同而已,按照刚才的方法进行patch,也同样需要6次DOM操作,因为相同DOM下标不是一一对应的,那么这时key就可以登场了,它就像是虚拟节点的“身份证”号,当两个字节点key相等时,我们认为两个DOM是一样的,可以拿来复用,当然DOM可复用不代表可以不用更新,还是需要将两个节点patchNode一下。

// 旧node
const oldNode = {
  type: 'div',
  children: [
      {type: 'p', key: '1'},
      {type: 'span', key: '2'},
      {type: 'div', key: '3'}
  ]
}
// 新node
const newNode = {
  type: 'div',
  children: [
      {type: 'div', key: '3'},
      {type: 'p', key: '1'},
      {type: 'span', key: '2'}
  ]
}

// 双层循环 遍历两组子节点找到可复用的节点
.....
for (let i = 0; i < newChildren.length; i++) {
  for (let j = 0; j < oldChildren.length; j++) {
    if (newChildren[i].key === oldChildren[j].key) {
      patchNode(newChildren[i], oldChildren[j])
      break
    }
  }
}
....

找到需移动的节点

经过以上处理,已经可以通过key来找到可以复用的节点,接下来要考虑这些节点是否需要移动顺序以及如何移动。以上边的两组子节点举例,尝试以下思路:

  1. 声明变量maxIndex = 0,开始遍历newChildren数组。
  2. 遍历到newChildren[0]时,在oldChildren中找到key相同的元素,下标为2,大于maxIndex,这就说明newChildren[0]这个节点原先是排在oldChildren[maxIndex]之后的,将maxIndex赋值为2。
  3. 遍历到newChildren[1]时,在oldChildren中找到key相同的元素,下标为0,小于maxIndex,这就说明newChildren[1]这个节点原先是排在oldChildren[maxIndex]之前的,因此这个子节点需要移动。
  4. 遍历到newChildren[2]时,在oldChildren中找到key相同的元素,下标为1,小于maxIndex,这就说明newChildren[2]这个节点原先是排在oldChildren[maxIndex]之前的,因此这个子节点也需要移动。
// 双层循环 遍历两组子节点找到可复用的节点,并检验其是否需要移动
.....
let maxIndex = 0
for (let i = 0; i < newChildren.length; i++) {
  for (let j = 0; j < oldChildren.length; j++) {
    if (newChildren[i].key === oldChildren[j].key) {
      patchNode(newChildren[i], oldChildren[j])
      if (j < maxIndex) {
        // 当前节点需要移动
        ....
      } else {
        // 当前节点不需要移动
        maxIndex = j
      }
      break
    }
  }
}
....

如何移动节点

节点的移动指的是移动一个虚拟节点对应的真实DOM,并不是移动虚拟节点本身,当虚拟节点被挂载后,其对应的真实DOM会存储在vnode.el属性中。因此我们可以通过旧子节点的el属性来获得真实的DOM节点。当节点更新操作发生时,渲染器会调用patchElement方法在新旧虚拟节点之间打补丁,伪代码如下:

const patchElement = (newNode, oldNode) => {
    // 此时新节点的el属性也引用了真实DOM元素
    const el = newNode.el = oldNode.el
    ......
}

改进上一节陈述的步骤

  1. 声明变量maxIndex = 0,开始遍历newChildren数组。
  2. 遍历到newChildren[0]时,在oldChildren中找到key相同的元素,下标为2,大于maxIndex,这就说明newChildren[0]这个节点原先是排在oldChildren[maxIndex]之后的,将maxIndex赋值为2。
  3. 遍历到newChildren[1]时,在oldChildren中找到key相同的元素,下标为0,小于maxIndex,这就说明newChildren[1]这个节点原先是排在oldChildren[maxIndex]之前的,因此这个子节点对应的真实DOM需要移动到newChildren[0]对应的真实DOM之后。
  4. 遍历到newChildren[2]时,在oldChildren中找到key相同的元素,下标为1,小于maxIndex,这就说明newChildren[2]这个节点原先是排在oldChildren[maxIndex]之前的,因此这个子节点对应的真实DOM也需要移动到newChildren[1]对应的真实DOM之后。
// 双层循环 遍历两组子节点找到可复用的节点,并检验其是否需要移动
.....
let maxIndex = 0
for (let i = 0; i < newChildren.length; i++) {
  for (let j = 0; j < oldChildren.length; j++) {
    if (newChildren[i].key === oldChildren[j].key) {
      // patchNode过程中newChildren[i].el已经被挂上了真实DOM
      patchNode(newChildren[i], oldChildren[j])
      if (j < maxIndex) {
        /**
        * 1. 取下标为i-1的node,赋值给prevNode,如果取不到就说明当前节点为首个子节点。
        * 2. 查找到prevNode对应的真实DOM的下一个兄弟节点,即prevNode.el.nextSibling
        * 3. 调用insert方法进行DOM操作,伪代码写在下面
        */
        const prevNode = newChildren[i - 1]
        
        if (prevNode) {
          insert(newChildren[i], container, prevNode?.el.nextSibling || container.firstChild)
        }
      } else {
        // 当前节点不需要移动
        maxIndex = j
      }
      break
    }
  }
}
....

// insert方法,将el插入到anchor之前
const insert = (el, parent, anchor = null) => {
  parent.insetrtBefore(el, anchor)
}

添加新元素

上一节的内容是建立在 遍历newChildren时能够找到新节点在oldChildren中的可复用DOM的基础上进行探究的,那么如果找不到呢?那就意味着需要有新的元素插入到原先的DOM列表中。首先我们需要找到新增的节点,然后将它挂载到正确的位置即可。

// 双层循环 遍历两组子节点找到可复用的节点,并检验其是否需要移动
.....
let maxIndex = 0
for (let i = 0; i < newChildren.length; i++) {
  let find = false
  for (let j = 0; j < oldChildren.length; j++) {
    if (newChildren[i].key === oldChildren[j].key) {
      find = true
      .....
      break
    }
  }
  if (!find) {
    const prevNode = newChildren[i - 1]
    let anchor = null

    if (prevNode) {
      anchor = prevNode.el.nextSibling
    } else {
      anchor = container.firstChild
    }

    // 挂载新的DOM节点
    patchNode(newChildren[i],null,container,anchor)
  }
}
...

移除不存在的元素

等newChildren遍历结束后,可能还存在需要移除的DOM节点。遍历oldChildren,如果在newChildren中找不到oldChildren[i].key对应的节点就说明该节点对应的真实DOM应该被移除。

for (let i = 0; i < oldChildren.length; i++) {
  const node = newChildren.find(vnode => vnode.key === oldChildren[i].key)

  if (!node) {
    unmount(newChildren[i])
  }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值