《Vue.js设计与实现读书笔记》-Diff算法第一节(简单Diff算法)

100 阅读1分钟

什么是Diff算法?当新旧vnode的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫diff算法;操作dom的性能开销通常比较大,diff算法就是为了解决这个问题诞生的

简单diff算法

减少DOM操作开销

核心diff只关心新旧虚拟节点都存在一组子节点的情况。如果直接卸载全部子节点,再挂载全部新子节点,没有复用任何DOM元素,会产生极大的性能开销

const oldVnode = {
  type: 'div',
  children: [
    {type: 'p', children: '1'},
    {type: 'p', children: '2'},
    {type: 'p', children: '3'},
  ]
}

const newVnode = {
  type: 'div',
  children: [
    {type: 'p', children: '1'},
    {type: 'p', children: '5'},
    {type: 'p', children: '6'},
  ]
}

上面的例子中,如果直接卸载后挂载,需要进行6次DOM操作,但是如果仔细看可以发现,子节点的标签类型是没有变化的,并且有一个子节点没有修改,那么可以直接修改有变化的子节点的文本内容,这样就只需要进行两次DOM操作

function patchChildren(n1, n2, container){
  if(typeof n2.children === 'string') {
    //无子节点
  } else if(Array.isArray(n2.children)){
    const oldChildren = n1.children
    const newChildren = n2.children
    for(let i = 0; i < oldChildren.length; i++){
      patch(oldChildren[i], newChildren[i], container)
    }
  }
  else {
    //其他节点,例如组件
  }
}

可以遍历旧节点,循环调用patch函数进行更新(此处的patch函数可以实现挂载更新等操作,此处patch进行更新)

这种做法虽然能减少DOM节点操作次数,但是新旧节点子节点数量不一定相同,会有一些节点必须被卸载或者新挂载一些节点;因此,我们在遍历时不应该总是遍历旧节点的长度,而是应该遍历长度较短的那一组节点,这样我们能尽可能调用patch进行更新,然后再处理新挂载的子节点或者需要卸载的子节点。(可以自己尝试写出伪代码)

function patchChildren(n1, n2, container){
  if(typeof n2.children === 'string') {
    //无子节点
  } else if(Array.isArray(n2.children)){
    const oldChildren = n1.children
    const newChildren = n2.children

    const oldLen = oldChildren.length
    const newLen = newChildren.length
    //遍历较短的长度
    const commonLength = Math.min(oldLen, newLen)
    for(let i = 0; i < commonLength; i++){
      patch(oldChildren[i], newChildren[i], container)
    }

    if(newLen > oldLen) { // 有新子节点需要挂载
      for(let i = commonLength; i < newLen; i++){
        patch(null, newChildren[i], container)
      }
    } else if(oldLen > newLen) { // 有旧子节点需要卸载
      for(let i = commonLength; i < newLen; i++){
        unmount(oldChildren[i])
      }
    }
  }
  else {
    //其他节点,例如组件
  }
}

复用DOM节点

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

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

以上例子中,如果使用上一节的方法进行更新,需要6次DOM操作,显然不太合适;观察两组节点,我们发现子节点只是顺序不同,所以最优的处理方式就是通过DOM的移动来完成子节点的更新。所以问题变成了怎么确定旧子节点是否出现在新子节点中,通过标签类型比较显然不可靠,因为还有其他的一些属性可能不相同。这时,我们就需要引入key值来作为vnode的标识,只要标签类型与key值都相同,我们就认为这个DOM元素是可复用的。如果没有key,我们无法知道新旧子节点之间的映射关系,也就无法知道该怎么移动节点。

需要强调一点,DOM元素可以复用并不意味着不需要更新,我们可以复用的同时更新节点其他的属性;因此在讨论如何移动DOM之前,我们需要先完成更新操作:

function patchChildren(n1, n2, container){
  if(typeof n2.children === 'string') {
    //无子节点
  } else if(Array.isArray(n2.children)){
    const oldChildren = n1.children
    const newChildren = n2.children
    
    //遍历新的children
    for(let i = 0; i < newChildren.lenght; i++){
      const newVnode = newChildren[i]
      //遍历旧的children
      for(let j = 0; j < oldChildren.lenght; j++){
        const oldVnode = oldChildren[j]
        //key相等,可以复用,但仍然需要先进行patch更新
        if(newVnode.key === oldVnode.key) {
            patch(oldVnode, newVnode, container)
            break;
        }
      }
    }

  }
  else {
    //其他节点,例如组件
  }
}

找到需要移动的元素

当新旧两组子节点的节点顺序不变时,就不需要额外的移动操作。

我们根据上图,按照上一节的更新算法的顺序遍历一下这组节点:

  1. 取第一个节点p-3,key为3,索引为0,在旧节点中尝试找到具有相同key值的节点,能够找到,并且在旧子节点中索引为2
  2. 取新的一组子节点中的第二个子节点p-1,key为1,在旧子节点中索引为2,在旧节点中尝试找到具有相同key值的节点,能够找到,并且在旧子节点中索引为0
  3. 取新的一组子节点中的第三个子节点p-2,key为2,在旧子节点中索引为3,在旧节点中尝试找到具有相同key值的节点,能够找到,并且在旧子节点中索引为1

我们按先后顺序记录在寻找节点中所遇到的位置索引,将会得到序列2、0、1,可以发现,这个序列没有递增趋势;而如果是一组顺序完全享用的节点,位置索引的序列将会呈现递增趋势,因此当位置索引开始出现不递增的索引时,就代表该索引的节点需要移动,如上面的p-1与p-2;我们可以将节点在旧children中的索引定义为在旧children中寻找具有相同key值节点的过程中,遇到的最大索引值(这里不说定义为key值相同的节点的索引值的原因是后面可能会出现找不到key值相同节点的情况)。在后续的寻找过程中,存在索引值比当前遇到的最大索引值还要小的节点,则意味着该节点需要移动;可以用lastIndex存储整个寻找过程中遇到的最大索引值,伪代码如下:

function patchChildren(n1, n2, container){
  if(typeof n2.children === 'string') {
    //无子节点
  } else if(Array.isArray(n2.children)){
    const oldChildren = n1.children
    const newChildren = n2.children
    
    //遍历新的children
    for(let i = 0; i < newChildren.lenght; i++){
      const newVnode = newChildren[i]
      //遍历旧的children
      for(let j = 0; j < oldChildren.lenght; j++){
        const oldVnode = oldChildren[j]
        //key相等,可以复用,但仍然需要先进行patch更新
        if(newVnode.key === oldVnode.key) {
            patch(oldVnode, newVnode, container) // 更新节点内容】
            if(j < lastIndex){
                // 如果当前找到的节点在旧children中的索引小于最大索引值lastIndex
                // 说明该节点对应的DOM元素需要移动
            } else {
                 lastIndex = j   
            }
            break;
        }
      }
    }

  }
  else {
    //其他节点,例如组件
  }
}

这里只看代码可能有点难理解lastIndex的作用,可以代入例子里循环一遍理解一下

如何移动元素

移动节点指的是移动一个虚拟节点所对应的真实DOM节点,并不是移动虚拟节点本身。既然移动的是真实DOM节点,那么就要取得引用。真实DOM节点会存在vnode.el属性中。

更新操作发生时patch函数其实就是DOM元素的复用,复用之后新节点会持有对真实DOM的引用

可以看到无论是新节点还是旧节点,都存在对真实DOM的引用;在此基础上就可以进行DOM移动操作了

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

我们发现需要移动,但是要移动到哪里呢?我们知道,新children的顺序就是更新后真实DOM的顺序,所以p-1在新children中的位置就代表了真实DOM更新后的位置,所以我们应该把p-1移动到p-3的houmian,如上图

第三步同理,如上图

接下来我们可以实现下伪代码

function patchChildren(n1, n2, container){
  if(typeof n2.children === 'string') {
    //无子节点
  } else if(Array.isArray(n2.children)){
    const oldChildren = n1.children
    const newChildren = n2.children
    
    //遍历新的children
    for(let i = 0; i < newChildren.lenght; i++){
      const newVnode = newChildren[i]
      //遍历旧的children
      for(let j = 0; j < oldChildren.lenght; j++){
        const oldVnode = oldChildren[j]
        //key相等,可以复用,但仍然需要先进行patch更新
        if(newVnode.key === oldVnode.key) {
            patch(oldVnode, newVnode, container) // 更新节点内容】
            if(j < lastIndex){
                // 如果当前找到的节点在旧children中的索引小于最大索引值lastIndex
                // 说明该节点对应的DOM元素需要移动
                const prevVnode = newChildren[i-1] //获取newVnode的前一个vnode,若不存在不需要移动
                if(prevVnode){
                // 获取prevVNode所对应真实DOM的下一个兄弟节点,并将其作为锚点,调用insert方法插入
                    const anchor = prevVnode.el.nextSibling
                    insert(newVnode.el, container, anchor)
                    
                }
            } else {
                 lastIndex = j   
            }
            break;
        }
      }
    }

  }
  else {
    //其他节点,例如组件
  }
}

添加新元素

对于新增节点,在更新时我们应该正确地将它挂在,主要分为两步:

  1. 想办法找到新增节点
  2. 将新增节点挂载到正确位置

···省略前几步

取新子节点的第三个节点p-4,key值为4,在旧子节点中没有key值为4的节点,因此需要挂载它,

需要观察p-4在新子节点中的位置,由于出现在p-1后面,所以我们应该挂在到p-1所对应的真实DOM后面

···省略后几步

伪代码如下:

function patchChildren(n1, n2, container){
  if(typeof n2.children === 'string') {
    //无子节点
  } else if(Array.isArray(n2.children)){
    const oldChildren = n1.children
    const newChildren = n2.children
    
    //遍历新的children
    for(let i = 0; i < newChildren.lenght; i++){
      const newVnode = newChildren[i]
      //遍历旧的children
      let j = 0
      let find = false
      for(j; j < oldChildren.lenght; j++){
        const oldVnode = oldChildren[j]
        //key相等,可以复用,但仍然需要先进行patch更新
        if(newVnode.key === oldVnode.key) {
            find = true
            patch(oldVnode, newVnode, container) // 更新节点内容】
            if(j < lastIndex){
                // 如果当前找到的节点在旧children中的索引小于最大索引值lastIndex
                // 说明该节点对应的DOM元素需要移动
                const prevVnode = newChildren[i-1] //获取newVnode的前一个vnode,若不存在不需要移动
                if(prevVnode){
                // 获取prevVNode所对应真实DOM的下一个兄弟节点,并将其作为锚点,调用insert方法插入
                    const anchor = prevVnode.el.nextSibling
                    insert(newVnode.el, container, anchor)
                    
                }
            } else {
                 lastIndex = j   
            }
            break;
        }
        if(!find){
            //先获取锚点元素
            //首先获取当前newVnode的前一个vnode节点
            const prevVnode = newChildren[i-1]
            let anchor = null
            if(prevVnode){
                anchor = prevVnode.el.nextSibling
            } else {
                //如果没有前一个节点,说明即将挂载的新节点时第一个子节点
                // 这时我们使用容器元素的firstChildren作为锚点
                anchor = container.firstChild
            }
            patch(null,newVnode,container,anchor)
        }
      }
    }

  }
  else {
    //其他节点,例如组件
  }

移除不存在的元素

进行移动与挂载节点后,我们需要遍历旧子节点一遍,然后取新子节点中寻找具有相同key值的节点,如果找不到,说明应该删除该节点,伪代码如下:

function patchChildren(n1, n2, container){
  if(typeof n2.children === 'string') {
    //无子节点
  } else if(Array.isArray(n2.children)){
    const oldChildren = n1.children
    const newChildren = n2.children
    
    //遍历新的children
    for(let i = 0; i < newChildren.lenght; i++){
        //省略更新、插入代码
    }
    
    //上一步更新操作完成后
    //遍历旧的一组子节点
    for(let -i=0;i<oldChildren.length;i++){
        const oldVnode = oldChildren[i]
        const has = newChildren.find(
            vnode=>vnode.key === oldVnode.key
        )
        if(!has){
            //如果没有相同key的节点,说明需要删除该节点
            //调用unmount函数将其卸载
            unmount(oldVnode)
        }
    }

  }
  else {
    //其他节点,例如组件
  }

第二节-双端Diff算法正在写作中...

欢迎关注B站小南前端の干货分享