vue中vnode和 diff

前言

我们知道,vue的响应式基础是建立在数据拦截和发布订阅模式上的,创建vue实例时,先遍历数据的属性,为每一个对象和子对象添加_ob_,达到数据拦截,初次渲染时,扫描树中每一个节点,计算值,如果用到了data某个属性,则生成一个watcher,并触发get,将watcher放入dep的subs中。后续数据发生变化,触发set函数,dep的notify执行,通知所有监听此数据的watcher,进行更新。
但是,这样的做法其实是1.x时采用的策略模式。这种做法存在一个很大的弊端,就是每一个用到数据,可能产生动态变化的地方都生成了一个watcher,虽然每个数据变化都可以通知到页面的每一个地方进行及时更新,但是当页面的规模变大,变复杂,对应的watcher将变得十分庞大,带来的性能问题也就比较明显了

Vnode 和 Diff ----- 减少页面更新开销

在vue2中,正式引入了vnode和diff,同时将watcher的颗粒度放大到组件,一个组件对应着一个watcher,当数据变化,dep通知watcher,此时watcher是一个组件了,这时候怎么知道组件的哪个部分使用了数据呢?vnode和diff便应运而生!当dep通知到watcher时,watcher实例化时传递的updateComponent函数将会被调用,我们看看这个过程发生了什么

updateComponent

/src/core/instance/lifecycle.js

//在实例化watcher时丢入updateComponent
  new Watcher(
    vm,
    updateComponent,
    noop,
    watcherOptions,
    true /* isRenderWatcher */
  )

 let updateComponent
  /* istanbul ignore if */
  if (__DEV__ && config.performance && mark) {
    updateComponent = () => {
		```
    }
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }

可以看到,这里有一个vm,vm就是组件实例,是在实例化vue组件时传进来的。vm.render()做的事情,就是通过vue实例生成VNode,将 VNode 传递给 _update 方法

_update

  Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevEl = vm.$el //先前的元素
    const prevVnode = vm._vnode //如果是第一次进入,则先前vm为空,如果是后续渲染更新,之前的vm一定经历过_render,则一定存在_vnode,即先前的vnode
    const restoreActiveInstance = setActiveInstance(vm)
    vm._vnode = vnode //先用新vnode替换原本的vnode(因为无论后续diff如何查找,旧的总是要被新的替换)
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    if (!prevVnode) {
      // initial render
        //初次渲染,先前没有元素
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      // updates
      //更新,走这里
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    restoreActiveInstance()
    // update __vue__ reference
    if (prevEl) {
      prevEl.__vue__ = null
    }
    if (vm.$el) {
      vm.$el.__vue__ = vm
    }
    // if parent is an HOC, update its $el as well
    let wrapper: Component | undefined = vm
    while (
      wrapper &&
      wrapper.$vnode &&
      wrapper.$parent &&
      wrapper.$vnode === wrapper.$parent._vnode
    ) {
      wrapper.$parent.$el = wrapper.$el
      wrapper = wrapper.$parent
    }
    // updated hook is called by the scheduler to ensure that children are
    // updated in a parent's updated hook.
  }

patch

从上面可以看到,无论是初次渲染,还是后续更新渲染,都是要经过_update的。这个函数首先更新vnode,然后判断是否是第一次,执行不同的_patch_函数。这个函数就是我们一直想要介绍的diff算法所在的地方。这个函数实际上和dom操作有关,所以中间转了几次,最后只需要关注
patch函数就好了。在这之前,先看一下一些工具函数的定义,以便阅读
在这里插入图片描述

src/core/vdom/patch.js

//参数分别代表,老vnode,新vnode,是否服务端渲染,只移除操作
  return function patch(oldVnode, vnode, hydrating, removeOnly) {
  //如果新节点不存在,直接把老节点删掉
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }
	
    let isInitialPatch = false
    const insertedVnodeQueue: any[] = []

    if (isUndef(oldVnode)) {
    //如果新vnode存在,老节点不存在,那么直接用新vnode创建新节点插入,这种情况会在第一次渲染出现
      // empty mount (likely as component), create new root element
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } else {
    //这一步实际上是判断是否是第一次渲染,第一次渲染传进来是真实元素
      const isRealElement = isDef(oldVnode.nodeType)
      //如果不是真实元素且两者是同一节点,执行patchVnode函数
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
      //初次渲染 ,后续一大段都是初始化,还要判断是否是服务端渲染
        if (isRealElement) {
          // mounting to a real element
          // check if this is server-rendered content and if we can perform
          // a successful hydration.
          // 挂载到真实元素以及处理服务端渲染的情况
          if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          if (isTrue(hydrating)) {
            if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
              invokeInsertHook(vnode, insertedVnodeQueue, true)
              return oldVnode
            } else if (__DEV__) {
              warn(
                'The client-side rendered virtual DOM tree is not matching ' +
                  'server-rendered content. This is likely caused by incorrect ' +
                  'HTML markup, for example nesting block-level elements inside ' +
                  '<p>, or missing <tbody>. Bailing hydration and performing ' +
                  'full client-side render.'
              )
            }
          }
          // either not server-rendered, or hydration failed.
          // create an empty node and replace it
          oldVnode = emptyNodeAt(oldVnode)
        }
        // replacing existing element
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

        // create new node
        createElm(
          vnode,
          insertedVnodeQueue,
          // extremely rare edge case: do not insert if old element is in a
          // leaving transition. Only happens when combining transition +
          // keep-alive + HOCs. (#4590)
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )

        // update parent placeholder node element, recursively
        if (isDef(vnode.parent)) {
          let ancestor = vnode.parent
          const patchable = isPatchable(vnode)
          while (ancestor) {
            for (let i = 0; i < cbs.destroy.length; ++i) {
              cbs.destroy[i](ancestor)
            }
            ancestor.elm = vnode.elm
            if (patchable) {
              for (let i = 0; i < cbs.create.length; ++i) {
                cbs.create[i](emptyNode, ancestor)
              }
              // #6513
              // invoke insert hooks that may have been merged by create hooks.
              // e.g. for directives that uses the "inserted" hook.
              const insert = ancestor.data.hook.insert
              if (insert.merged) {
                // start at index 1 to avoid re-invoking component mounted hook
                for (let i = 1; i < insert.fns.length; i++) {
                  insert.fns[i]()
                }
              }
            } else {
              registerRef(ancestor)
            }
            ancestor = ancestor.parent
          }
        }

        // destroy old node
        if (isDef(parentElm)) {
          removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }

    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }

从上面源码我们可以得到以下结论

  • 如果没有新节点,直接把老节点销毁
  • 如果有新节点没有老节点,直接根据vnode创建新节点插入dom树
  • 老节点有了,就判断是否是真实元素,代表着是否是初次渲染
  • 如果不是真实元素,且新旧节点一致,则进行patch对比
  • 否则,则代表初次渲染或者节点直接变了,判断是否是服务端渲染等等

patchVnode

接下来,就是diff算法了,我们看看patchVnode函数

function patchVnode(
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly?: any
  ) {
  //新老节点(对象)一致,则完全无差异,直接返回
    if (oldVnode === vnode) {
      return
    }
	//
    if (isDef(vnode.elm) && isDef(ownerArray)) {
      // clone reused vnode
      vnode = ownerArray[index] = cloneVNode(vnode)
    }

    const elm = (vnode.elm = oldVnode.elm)

    if (isTrue(oldVnode.isAsyncPlaceholder)) {
      if (isDef(vnode.asyncFactory.resolved)) {
        hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
      } else {
        vnode.isAsyncPlaceholder = true
      }
      return
    }
	//跳过静态节点的更新
    // reuse element for static trees.
    // note we only do this if the vnode is cloned -
    // if the new node is not cloned it means the render functions have been
    // reset by the hot-reload-api and we need to do a proper re-render.
    if (
      isTrue(vnode.isStatic) &&
      isTrue(oldVnode.isStatic) &&
      vnode.key === oldVnode.key &&
      (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
      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 oldCh = oldVnode.children
    const ch = vnode.children
    //全量更新属性
    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)
    }
    //如果新节点不是文本
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch)
        //最核心的diff算法!!!
          updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
      //老节点没有子元素,那么,创建这些新孩子节点
        if (__DEV__) {
          checkDuplicateKeys(ch)
        }
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
      //新节点没有子元素,老节点有子元素,则将老元素子元素移除
        removeVnodes(oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
      //老元素是文本,将文本置空
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
    //两者都是文本节点,直接更新
      nodeOps.setTextContent(elm, vnode.text)
    }
    if (isDef(data)) {
      if (isDef((i = data.hook)) && isDef((i = i.postpatch))) i(oldVnode, vnode)
    }
  }

总结:

  • 如果新老vnode完全一致,直接返回
  • 跳过静态节点
  • 判断子元素:
    - 如果新节点有孩子,老节点没孩子,则新增新节点的这些孩子节点
    - 如果老节点有孩子,新节点没孩子,则删除老节点的这些孩子
    - 两者都有孩子且至少一方是文本节点,则直接替换
    - 两者都有孩子且均不为文本节点,进入最核心的diff算法

updateChildren

这里最核心的diff算法,就是 updateChildren函数!我们熟悉的旧前旧后,新前新后就是在这

/**
 * diff 过程:
 *   diff 优化:做了四种假设,假设新老节点开头结尾有相同节点的情况,一旦命中假设,就避免了一次循环,以提高执行效率
 *             如果不幸没有命中假设,则执行遍历,从老节点中找到新开始节点
 *             找到相同节点,则执行 patchVnode,然后将老节点移动到正确的位置
 *   如果老节点先于新节点遍历结束,则剩余的新节点执行新增节点操作
 *   如果新节点先于老节点遍历结束,则剩余的老节点执行删除操作,移除这些老节点
 */
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, vnodeToMove, refElm

  // removeOnly是一个特殊的标志,仅由 <transition-group> 使用,以确保被移除的元素在离开转换期间保持在正确的相对位置
  const canMove = !removeOnly

  if (process.env.NODE_ENV !== 'production') {
    // 检查新节点的 key 是否重复
    checkDuplicateKeys(newCh)
  }

  // 遍历新老两组节点,只要有一组遍历完(开始索引超过结束索引)则跳出循环
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStartVnode)) {
      // 如果节点被移动,在当前索引上可能不存在,检测这种情况,如果节点不存在则调整索引
      oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
    } else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      // 老开始节点和新开始节点是同一个节点,执行 patch
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      // patch 结束后老开始和新开始的索引分别加 1
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // 老结束和新结束是同一个节点,执行 patch
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
      // patch 结束后老结束和新结束的索引分别减 1
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      // 老开始和新结束是同一个节点,执行 patch
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
      // 处理被 transtion-group 包裹的组件时使用
      canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      // patch 结束后老开始索引加 1,新结束索引减 1
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      // 老结束和新开始是同一个节点,执行 patch
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      // patch 结束后,老结束的索引减 1,新开始的索引加 1
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 如果上面的四种假设都不成立,则通过遍历找到新开始节点在老节点中的位置索引

      // 找到老节点中每个节点 key 和 索引之间的关系映射 => oldKeyToIdx = { key1: idx1, ... }
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      // 在映射中找到新开始节点在老节点中的位置索引
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      if (isUndef(idxInOld)) { // New element
        // 在老节点中没找到新开始节点,则说明是新创建的元素,执行创建
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
      } else {
        // 在老节点中找到新开始节点了
        vnodeToMove = oldCh[idxInOld]
        if (sameVnode(vnodeToMove, newStartVnode)) {
          // 如果这两个节点是同一个,则执行 patch
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
          // patch 结束后将该老节点置为 undefined
          oldCh[idxInOld] = undefined
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // 最后这种情况是,找到节点了,但是发现两个节点不是同一个节点,则视为新元素,执行创建
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        }
      }
      // 老节点向后移动一个
      newStartVnode = newCh[++newStartIdx]
    }
  }
  // 走到这里,说明老姐节点或者新节点被遍历完了
  if (oldStartIdx > oldEndIdx) {
    // 说明老节点被遍历完了,新节点有剩余,则说明这部分剩余的节点是新增的节点,然后添加这些节点
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  } else if (newStartIdx > newEndIdx) {
    // 说明新节点被遍历完了,老节点有剩余,说明这部分的节点被删掉了,则移除这些节点
    removeVnodes(oldCh, oldStartIdx, oldEndIdx)
  }
}

总结:

  • 循环新老孩子
  • 新前与新前比较,如果相同,执行patch,同时移动startIndex
  • 新后与旧后比较,如果相同,执行patch,则同时移动endIndex
  • 新前与旧后比较,如果相同,执行patch,new startIndex和old endIndex移动
  • 新后与旧前比较,如果相同,执行patch,new endIndex 和 old startIndex 移动
  • 上述四条任何一条成立,退出当前循环进入下一次
  • 上述四条均不成立,则新节点的头节点去老节点孩子数组中查找,找不到代表是新的,直接添加;找到了但是不相同,仍然创建添加;找到了并且相同,执行patch,startIndex移动
  • 当循环结束,如果老孩子还有剩余,删除;如果新孩子还有剩余,添加;

总结图

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

SuperHaker~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值