Diff算法 -- 简单Diff算法

109 阅读4分钟

当vue数据新旧vnode的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组节点,用于比较的算法就叫做Diff算法,操作DOM的性能开销比较大,Diff算法就是为了解决这个问题而诞生的

DOM复用与key的作用

相比于不断卸载并挂载子节点,通过DOM移动来完成更新性能更优,前提是新旧两组节点中存在可复用的节点,应如何确定新的子节点是否出现在旧的一组子节点当中呢,需要引入额外的key来作为vnode的标识,如下所示:

旧节点

[
    {type: 'p', children: '1', key: 1},
    {type: 'p', children: '2', key: 2},    
    {type: 'p', children: '3', key: 3}
]

新节点

[
    {type: 'p', children: '3', key: 3},
    {type: 'p', children: '1', key: 1}, 
    {type: 'p', children: '2', key: 2}
]

key属性就像虚拟节点的‘身份证’号, 只要两个虚拟节点的type属性值和key属性值相同,则认为他们是相同的,可以进行DOM复用

image.png

如何移动元素

移动节点指的是,移动一个虚拟节点所对应的真实DOM节点,并不是移动虚拟节点本身,既然移动的是真实DOM节点,就需要取得对它的引用,而当虚拟节点被挂载后,其对应的真实DOM节点会存储在它的vnode.el中

因此在代码中,可以通过旧子节点的vnode.el属性取的它对应的真实DOM节点,当更新操作发生时,渲染器会调用patchElement函数在新旧虚拟节点之间进行打补丁(patchElement函数属于渲染器内容,不过多解释)

function patchElement(n1, n2) {
      //新的vnode也引用了真实DOM元素
      const el = n1.el = n2.el
      //省略部分代码
}

更新步骤

image.png

  1. 取新的一组子节点中的第一个节点p-3,它的key为3,尝试在旧的一组子节点中找到具有相同key值得可复用节点,发现能够找到,并且该节点在旧的一组子节点中的索引为2,此时变量lastIndex的值为0,索引2大于0,所以p-3对应的真实DOM不需要移动,但需要更新变量lastIndex的值为2
  2. 取新的一组子节点中第二个节点p-1,它的key为1,能在旧节点中找到具有相同key值的可复用节点,并且再旧子节点中索引为0,此时变量lastIndex值为2,索引0小于2,所以节点p-1对应的真实DOM需要移动

添加新元素

image.png 在新的一组子节点中,多了节点p-4,key值为4,该节点在旧的子节点中不存在,应将其视为新增节点,更新时应正确挂载

定义find变量,代表渲染器能否在旧的一组子节点中找到可复用的节点,初始值定为false,一旦找到可复用的节点,则将变量find的值设为true,若内层循环结束后,变量find的值仍为false,代表当前newVNode是一个全新的节点,需要挂载它,为了将节点挂载正确,先获取锚点元素,找到newVNode的前一个虚拟节点,即prevVNode,若存在,则使用它对应的真实DOM的下一个兄弟节点作为锚点元素,若不存在,说明挂载的newVNode是容器元素的第一个子节点,此时使用容器元素的container.firstChild作为锚点元素

移除不存在元素

image.png 当基本的更新结束后,遍历旧的一组子节点,然后去新的一组子节点中寻找具有相同key值的节点,若找不到,说明应该删除该节点

具体实现

//n1 旧节点, n2 新节点, container 父容器
function patchChildren(n1, n2, container) {
      const oldChildren = n1.children
      const newChildren = n2.children
      //用来存储寻找过程中遇到的最大索引值
      let lastIndex = 0
      for (let i=0; i < newChildren.length; i++) {
          const newNode = newChildren[i]
          let j = 0
          //代表是否在旧的一组节点中找到可复用的节点
          let find = false
          for (j; j < oldChildren.length; j++){
              const oldNode = oldChildren[j]
              if (newNode.key === oldNode.key) {
                  find = true
                  //更新节点内容,patch方法属于编译器内容,不再赘述
                  patch(oldNode, newNode, container)
                  if (j < lastIndex) {
                      //说明newVNode对应的真实DOM需要移动
                      //获取newVNode的前一个vnode,即prevVNode
                      const prevVNode = newChildren[i-1]
                      //若prevVNode不存在,说明当前newVNode是第一个节点,不需要移动
                      if (prevVNode) {
                          //newVNode对应的真实DOM移动到prevVNode所对应的真实DOM后面
                          //获取prevVNode所对应的真实DOM的下一个兄弟节点,并将其作为锚点
                          const anchor = prevVNode.el.nextSibling
                          //调用insert方法插入元素
                          insert(newNode.el, container, anchor)
                      }
                  } else {
                      lastIndex = j
                  }
                  break
              }
          }
          //当前newNode在旧的子节点中找不到,属于新增节点,需要挂载
          if (!find) {
              const prevVNode = newChildren[i - 1]
              let anchor = null
              if (prevVNode) {
                  anchor = prevVNode.el.nextSibling
              }else {
                  //若没有前一个节点,使用容器的firstChild作为锚点
                  anchor = container.firstChild
              }
              patch(null, newNode, container, anchor)
          }
      }
      //更新操作完成后,遍历旧的子节点
      for (let i = 0; i < oldChildren.length; i++) {
          const oldNode = oldChildren[i]
          const has = newChildren.find(vnode => vnode.key === oldNode.key)
          if (!has) {
              //找不到具有相同key的节点,说明需要删除该节点
              unmount(oldNode)
          }
      }
    }

插入节点方法

//anchor被插入哪个元素之前, null 代表插入最后
insert(el, parent, anchor = null) {
    parent.insertBefore(el, anchor)
}

移除节点

function unmount(vnode) {
    const parent = vnode.el.parentNode
    if (parent) {
        parent.removeChild(vnode.el)
    }
}