站在巨人肩膀上看vue3-第9章 简单Diff算法

852 阅读2分钟

站在巨人的肩膀上看vue,来自霍春阳的vue设计与实现。作者以问题的形式一步步解读vue3底层的实现。思路非常的巧妙,这里会将书中的主要逻辑进行串联,也是自己读后的记录,希望通过这种形式可以和大家一起交流学习。

开篇

简单Diff算法利用虚拟节点的key属性,尽可能的复用DOM元素,通过移动DOM的方式来完成更新,从而减少不断的创建和销毁DOM元素带来的性能开销。

简单Diff算法主要思想是寻找旧节点最大索引值,因为旧节点索引值都是呈递增趋势。如果当前新节点的索引比最大索引值小则需要移动,否则更新最大索引。

当遍历新节点的时候,如果在旧节点中没有找到,表明这个新节点是需要新增的,当新节点更新完毕后,后需要单独遍历旧节点,如果在新节点中没有出现则表明这个旧节点是需要删除的。

这就是简单的Diff算法过程。

9.1、减少DOM操作的性能开销

diff算法:当新旧vnode的字节点都是同一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,找到可复用的节点,减少操作DOM带来的性能消耗。

情况1:节点都相同只是文本节点的内容不同

只需要遍历其中oldChildren、newChildren一个,然后调用patch函数进行更新,patch函数在执行更新是发现新旧字节点只有文本内容不同,因此只会更新其文本节点的内容

function patchChildren(newChildren, oldChildren) {
  for (let i = 0; i < oldChildren.length; i++) {
    patch(newChildren[i], oldChildren[i])
  }
}

情况2:新旧节点的数量不相同

在遍历的时候不应该总是遍历旧的节点或新的节点,而是应该遍历其中长度最短的那一组,新节点数量比旧节点数量多需要mount挂在,反之需要unmount卸载。这样无论两组字节点的数量关系如何,渲染器都能正确的挂在或卸载他们

function patchChildren(newChildren, oldChildren) {
  let newLen = newChildren.length
  let oldLen = oldChildren.length
  const commonLength = Math.min(newLen, oldLen)
  for (let i = 0; i < commonLength; i++) {
    patch(newChildren[i], oldChildren[i])
  }
  if (newLen > oldLen) {
    for (let i = commonLength; i < newLen; i++) {
      patch(null, newChildren[i])
    }
  } else if (oldLen < newLen) {
    for (let i = commonLength; i < oldLen; i++) {
      unmount(oldChildren[i])
    }
  }
}

9.2、DOM 复用与 Key 的作用

情况3:新旧两个节点内容完全相同,但位置不同

这种情况通过DOM的移动来完成字节点的更新,如何判断新的节点是否出现在旧节点中?

这时,需要引入额外的key来作为vnode的标识,只要两个虚拟节点的type属性值和key都相同,那么认为他们是相同的,即可以进行DOM的复用

[
	{
		type: 'p', children: '1', key: 1
	}
]
function patchChildren(newChildren, oldChildren) {
  let newLen = newChildren.length
  let oldLen = oldChildren.length
  for (let i = 0; i < newLen; i++) {
    const newVnode = newChildren[i]
    for (let j = 0; j < oldLen; j++) {
      const oldVnode = oldChildren[j]
      if (newVnode.key === oldVnode.key) {
        patch(oldVnode, newVnode)
        break
      }
    }
  }
}

9.3、找到需要移动的元素

情况4:如何判断一个节点是否需要移动

编译找到旧节点的索引值,索引值应该是一个递增的顺序,比如0,1,2,当新节点遍历寻找相同key的时候,发现索引值递增顺序被打破了,这表明改节点是需要移动的。

在旧节点中寻找具有相同节点的过程中,遇到最大索引值,如果在后续寻找的过程中,存在索引值比当前遇到的最大索引值还要小的节点,则意味着该节点需要移动

function patchChildren(newChildren, oldChildren) {
  let newLen = newChildren.length
  let oldLen = oldChildren.length
  let lastIndex = 0 // 存储过程中遇到的最大索引值
  for (let i = 0; i < newLen; i++) {
    const newVnode = newChildren[i]
    for (let j = 0; j < oldLen; j++) {
      const oldVnode = oldChildren[j]
      if (newVnode.key === oldVnode.key) {
        patch(oldVnode, newVnode)
        if (j < lastIndex) {
          // 当前找到的节点在就节点中的索引值小于lastIndex
          // 需要移动
        } else {
          lastIndex = j
        }
        break
      }
    }
  }
}

9.4、如何移动元素

情况5:如何移动元素

移动元素指的是移动一个虚拟节点对应的真实DOM节点,需要移动真实的DOM节点,需要取得对它的引用。

  1. 如果 小于lastIndex,则说明当前的节点所对应的节点需要移动
  2. 获取当前节点的前一个虚拟节点,newChildren[i - 1]
  3. 如果上面步骤不存在,则说明是第一个节点,不需要移动
  4. 如果存在,获取但钱节点的下一个兄弟节点作为锚点,利用 insertBefore 将其插入到锚点的前面
function patchChildren(newChildren, oldChildren) {
  let newLen = newChildren.length
  let oldLen = oldChildren.length
  let lastIndex = 0 // 存储过程中遇到的最大索引值
  for (let i = 0; i < newLen; i++) {
    const newVnode = newChildren[i]
    for (let j = 0; j < oldLen; j++) {
      const oldVnode = oldChildren[j]
      if (newVnode.key === oldVnode.key) {
        patch(oldVnode, newVnode)
        if (j < lastIndex) {
          // 获取newVnode的前一个节点
          const prevVNode = newChildren[i -1]
          if (prevVNode) {
            // 获取prevnode 对应的真实的dom的下一个兄弟子节点,将其作为锚点
            const anchor = prevVNode.el.nextSibling
            // 对应的真实dom插入到锚点的前面
            insert(newVnode.el, anchor)
          }
          
        } else {
          lastIndex = j
        }
        break
      }
    }
  }
}

9.5、添加元素

情况6:当新节点元素比旧节点元素多时如何添加元素

遍历新节点将新增的元素挂载在上一个节点之下,定义一个find的变量记录当前节点是否在旧节点中能否找到,如何找到了可复用的节点,将变量find设为true。如果没有找到说明是需要新增的节点,这时寻找当前节点的上一个节点的兄弟节点作为锚点,如果没有找到说明是第一个元素,相反将节点插入到锚点之前,最后挂载节点。

function patchChildren(newChildren, oldChildren) {
  let newLen = newChildren.length
  let oldLen = oldChildren.length
  let lastIndex = 0 // 存储过程中遇到的最大索引值
  for (let i = 0; i < newLen; i++) {
    const newVnode = newChildren[i]
    let j = 0
    let find = false
    for (j; j < oldLen; j++) {
      const oldVnode = oldChildren[j]
      if (newVnode.key === oldVnode.key) {
        find = true
        patch(oldVnode, newVnode)
        if (j < lastIndex) {
          // 获取newVnode的前一个节点
          const prevVNode = newChildren[i -1]
          if (prevVNode) {
            // 获取prevnode 对应的真实的dom的下一个兄弟子节点,将其作为锚点
            const anchor = prevVNode.el.nextSibling
            // 对应的真实dom插入到锚点的前面
            insert(newVnode.el, anchor)
          }
          
        } else {
          lastIndex = j
        }
        break
      }
      if (!find) {
        const prevVNode = newChildren[i - 1]
        let anchor = null
        if (prevVNode) {
          anchor = prevVNode.el.nextSibling
        } else {
          anchor = container.firstChild
        }
        // 挂载 newVnode
        patch(null, newVnode, container, anchor)
      }
    }
  }
}

9.6、移除不存在的元素

情况7:当新节点元素比旧节点少,如何移除

当新节点基本的更新结束之后,需要遍历旧的一组节点,然后去和新的一组节点中寻找具有相同key值的点,如果找不到,则说明应该删除该节点

....省略
for (let i = 0; i < oldLen; i++) {
    const oldVNode = oldChildren[i]
    const has = newChildren.find(vnode => vnode.key === oldVNode.key)
    if (!has) {
      // 卸载该节点
      unmount(oldVNode)
    }
  }