adodo0829 / blog

搭建知识体系
29 stars 4 forks source link

patch过程中的diff规则梳理 #73

Open adodo0829 opened 4 years ago

adodo0829 commented 4 years ago

patch 过程中的 diff 算法

这里经常会问 template 会什么只能有一个根节点? 参考这里 设计如此, 因为template 会转化为 vNode, 而 diff 算法是比较的 两颗 vnode 树; 一个 tree 结构能有两个根吗? emm...

还有 key 是用来干嘛的?

patch 方法

我们来看一下 patch 方法,patch会将新老VNode节点进行比对,然后将根据两者的比较结果进行最小单位地修改视图,而不是将整个视图根据新的VNode重绘

function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
    // vnode不存在则直接调用销毁钩子
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) {
      // 初始态 patch, 老节点不存在
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue, parentElm, refElm) // 生成 DOM
    } else {
      const isRealElement = isDef(oldVnode.nodeType)

      /*
       * 只有新旧VNode节点判定为同一节点的时候才会进行patchVnode这个过程
       * 否则就是创建新的DOM,移除旧的DOM
       * diff 过程在patchVnode里执行
       */

      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
      } 
      else {
        if (isRealElement) {
          if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
            // 当旧的VNode是服务端渲染的元素,hydrating记为true
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          // 如果不是服务端渲染或者合并到真实DOM失败,则创建一个空的VNode节点替换它
          // 创建空节点
          oldVnode = emptyNodeAt(oldVnode)
        }
        // 省略...

        // 移除旧节点
        if (isDef(parentElm)) {
          removeVnodes(parentElm, [oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }
    // 省略...
  }

patchVnode 方法

patch 前置条件

/*
  判断两个VNode节点是否是同一个节点,需要满足以下条件
  key相同
  tag(当前节点的标签名)相同
  isComment(是否为注释节点)相同
  是否data(当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型
  当标签是<input>的时候,type必须相同
*/
function sameVnode (a, b) {
  return (
    a.key === b.key &&
    a.tag === b.tag &&
    a.isComment === b.isComment &&
    isDef(a.data) === isDef(b.data) &&
    sameInputType(a, b)
  )
}
function sameInputType (a, b) {
  if (a.tag !== 'input') return true
  let i
  // 判断当标签是<input>的时候,type是否相同
  const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
  const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
  return typeA === typeB
}

patch 过程

function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
    // 1.两个VNode节点相同则直接返回, 直接使用老节点
    if (oldVnode === vnode) {
      return
    }

    // 2.如果新旧节点满足 static,key,once: 新替换旧,直接赋值
    if (isTrue(vnode.isStatic) &&    // 如果新旧VNode都是静态的
        isTrue(oldVnode.isStatic) && 
        vnode.key === oldVnode.key && // 同时它们的key相同
        // 新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次)
        (isTrue(vnode.isCloned) || isTrue(vnode.isOnce)) 
      ) 
    { 
      // 只需要替换真实elm以及componentInstance
      vnode.elm = oldVnode.elm 
      vnode.componentInstance = oldVnode.componentInstance
      return
    }

    let i
    const data = vnode.data
    if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
      i(oldVnode, vnode)
    }

    const elm = vnode.elm = oldVnode.elm

    const oldCh = oldVnode.children // 老节点的子节点
    const ch = vnode.children       // 新节点的子节点

    // 调用update回调以及update钩子
    if (isDef(data) && isPatchable(vnode)) {
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
    }

    // 3.当新节点没有 text 时
    if (isUndef(vnode.text)) {
      // 1.新老节点均有children子节点
      if (isDef(oldCh) && isDef(ch)) {
        // 对比子节点, 不相同则进行 diff
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } 
      // 2.老节点没有子节点而新节点存在子节点
      else if (isDef(ch)) {
        // 先清空elm的文本内容,然后为当前节点加入子节点
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } 
      // 3.当新节点没有子节点而老节点有子节点
      else if (isDef(oldCh)) {
        // 移除所有elm的子节点
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } 
      // 4.当新老节点都无子节点的时候
      else if (isDef(oldVnode.text)) {
        // 只是文本的替换,因为这个逻辑中新节点text不存在,所以直接去除ele的文本
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      // 当新老节点text不一样时,直接替换这段文本
      nodeOps.setTextContent(elm, vnode.text)
    }

    if (isDef(data)) {
      if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
    }
  }

patch 子节点方法 updateChildren

// 新老节点均有children子节点时进入这一步
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0                // 老节点头指针
    let newStartIdx = 0                // 新节点头指针
    let oldEndIdx = oldCh.length - 1   // 老节点尾指针
    let oldStartVnode = oldCh[0]       // 老节点首个节点对象
    let oldEndVnode = oldCh[oldEndIdx] // 老节点尾部节点对象
    let newEndIdx = newCh.length - 1   // 新节点尾指针
    let newStartVnode = newCh[0]       // 新节点首个节点对象
    let newEndVnode = newCh[newEndIdx] // 新节点尾部节点对象
    let oldKeyToIdx, idxInOld, elmToMove, refElm // 临时变量

    // during leaving transitions
    const canMove = !removeOnly

    // 如果存在key,并且满足sameVnode,会将该DOM节点进行复用,否则则会创建一个新的DOM节点
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        // 老节点头指针 右移
        oldStartVnode = oldCh[++oldStartIdx] 
      } else if (isUndef(oldEndVnode)) {
        // 老节点尾指针 左移
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 1.比较新旧头指针节点
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // 2.比较新旧尾指针节点
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
        // 3.比较旧头 和 新尾
        // 说明 oldStartVnode 已经跑到了 oldEndVnode后面, 同时真实 DOM 位移
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldEndVnode, newStartVnode)) {
        // 4.比较旧尾 和 新头
        // 说明oldEndVnode跑到了oldStartVnode的前面,进行patchVnode的同时真实的DOM节点移动到了oldStartVnode的前面
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        /*
          以上4种情况均不符合时:
          生成一个key与旧VNode的key对应的哈希表(只有第一次进来undefined的时候会生成,也为后面检测重复的key值做准备)
          比如childre是这样的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}]  
          beginIdx = 0   endIdx = 2  
          结果生成{key0: 0, key1: 1, key2: 2}
        */
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        // 如果newStartVnode新的VNode节点存在key并且这个key在oldVnode中能找到
        // 则返回这个节点的idxInOld(即第几个节点,下标)
        idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null
        if (isUndef(idxInOld)) { 
          // newStartVnode没有key或者是该key没有在老节点中找到则创建一个新的节点
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
          newStartVnode = newCh[++newStartIdx]
        } else {
          // 获取相同key的老节点
          elmToMove = oldCh[idxInOld]
          if (process.env.NODE_ENV !== 'production' && !elmToMove) {
            // 如果elmToMove不存在说明之前已经有新节点放入过这个key的DOM中,提示可能存在重复的key,
            // 确保v-for的时候item有唯一的key值
            warn(
              'It seems there are duplicate keys that is causing an update error. ' +
              'Make sure each v-for item has a unique key.'
            )
          }
          if (sameVnode(elmToMove, newStartVnode)) {
            // 如果新VNode与得到的有相同key的节点是同一个VNode则进行patchVnode
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
            // 因为已经patchVnode进去了,所以将这个老节点赋值undefined,
            // 之后如果还有新节点与该节点key相同可以检测出来提示已有重复的key
            oldCh[idxInOld] = undefined
            // 当有标识位canMove实可以直接插入oldStartVnode对应的真实DOM节点前面
            canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm)
            newStartVnode = newCh[++newStartIdx]
          } else {
            // 当新的VNode与找到的同样key的VNode不是sameVNode的时候
            // 比如说tag不一样或者是有不一样type的input标签),创建一个新的节点
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
            newStartVnode = newCh[++newStartIdx]
          }
        }
      }
    }
    if (oldStartIdx > oldEndIdx) {
      // 全部比较完成以后,发现oldStartIdx > oldEndIdx的话,说明老节点已经遍历完了,新节点比老节点多,
      // 所以这时候多出来的新节点需要一个一个创建出来加入到真实DOM中
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      // 如果全部比较完成以后发现newStartIdx > newEndIdx,则说明新节点已经遍历完了,老节点多余新节点,
      // 这个时候需要将多余的老节点从真实DOM中移除
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
  }

diff 规则小结

对比新旧 vnode 对象, 新旧替换的过程(如何减少新旧替换的开销和效率),有一套规则

1.前置条件: 新旧 vnode 相同?
  相同则patch, 不同则销毁旧创建新

2.进入 patch 过程(5种情况)
  - 1.老新节点都是静态的,同时它们的key相同(代表同一节点),并且新的VNode是clone或者是标记了v-once:
    只替换elm以及componentInstance

  - 2.老有儿子,新的没儿子:
    移除该DOM节点的所有子节点

  - 3.老没儿子,新的有儿子:
    先清空老节点DOM的文本内容,然后为该DOM节点加入子节点

  - 4.老,新都没儿子:
    只是文本的替换

  - 5.新老节点均有儿子(diff): 进入diff过程
    遍历老新节点对象并比较同层的树节点, 然后递归比较子节点对象
    通过判断 key 和 sameVnode, 是否将该DOM节点进行复用,否则则会创建一个新的DOM节点
    - 1.如果只是节点移动
      只是位移节点, 同时位移 DOM 元素

    - 2.如果有增删改
      会生成一个key与旧VNode的key对应的哈希表, 判断新VNode 中是否有 key与旧的比对,
      进而进行创建或者移除操作