Vue diff算法 - 简单diff

76 阅读1分钟

什么是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来找到可以复用的节点,接下来要考虑这些节点是否需要移动顺序以及如何移动。以上边的两组子节点举例,尝试以下思路:

声明变量maxIndex = 0,开始遍历newChildren数组。 遍历到newChildren[0]时,在oldChildren中找到key相同的元素,下标为2,大于maxIndex,这就说明newChildren[0]这个节点原先是排在oldChildren[maxIndex]之后的,将maxIndex赋值为2。 遍历到newChildren[1]时,在oldChildren中找到key相同的元素,下标为0,小于maxIndex,这就说明newChildren[1]这个节点原先是排在oldChildren[maxIndex]之前的,因此这个子节点需要移动。 遍历到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
    }
  }
}
....

添加新元素

上一节的内容是建立在 遍历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])
  }
}