Vue源码学习 - 虚拟Dom 和 diff算法

前言

这是一个系列学习源码的文章,感兴趣的可以继续阅读其他文章
Vue源码学习 - new Vue初始化都做了什么?
Vue源码学习 - 数据响应式原理
Vue源码学习 - 异步更新队列 和 nextTick原理

因为 Diff 算法,计算的就是虚拟 DOM 的差异,所以先铺垫一点点虚拟 DOM,了解一下其结构,再去看Diff 算法原理,循循渐进会更好些。

渲染真实的 DOM 时,并不是暴力覆盖原有的 DOM ,而是比对新旧两个vnode(虚拟节点),如果不是同一个节点,删除老的,替换成新的;如果是同一个节点,就复用老节点,增加新节点的属性。

一、认识虚拟DOM

虚拟 DOM 简单说就是 用JS对象来模拟 DOM 结构

用 JS 对象模拟 DOM 结构

用 JS 对象模拟 DOM 结构的例子:

<template>
    <div id="app" class="container">
        <h1>铁锤妹妹</h1>
    </div>
</template>

上面的模板转成 JS对象 就是下面这样。
这样的 DOM 结构就称之为 虚拟 DOM (Virtual Node),简称 vnode

{
    
    
  tag:'div',
  props:{
    
     id:'app', class:'container' },
  children: [
    {
    
     tag: 'h1', children:'铁锤妹妹' }
  ]
}

它的表达方式就是把每一个标签都转为一个对象,这个对象可以有三个属性:tagpropschildren

  • tag:必选。就是标签也可以是组件或者函数。
  • props:非必选。就是这个标签上的属性和方法。
  • children:非必选。就是这个标签的内容或者子节点,如果是文本节点就是字符串,如果有子节点就是数组。换句话说 如果判断 children 是字符串的话,就表示一定是文本节点,这个节点肯定没有子元素

用JS对象模拟DOM节点的好处

假设在一次操作中有1000个节点 需要更新,那么 虚拟DOM 不会立即去操作Dom,而将这1000次更新的 diff 内容保存到本地的一个JS对象上,之后将这个 JS 对象一次性 attach 到 DOM 树上,最后再进行后续的操作,这样子就避免了大量没必要的计算

所以,用JS对象模拟DOM节点的好处就是:先将页面的更新全部反映到 虚拟DOM 上,这样子就 先操作内存中的JS对象。值得注意的是,操作内存中的 JS对象 速度是相当快的。然后等到全部DOM节点更新完成后,再将最后的 JS对象 映射到 真实DOM 上,交给 浏览器 去绘制。

这样就解决了 真实DOM 渲染速度慢,性能消耗大 的问题。

为什么要使用虚拟 DOM 呢?

我们先创建一个空div,打印看看上面自带的所有属性和事件。

    let div = document.createElement('div')
    let props = ''
    for (let key in div) {
    
    
      props += key + ' '
    }
    console.log(props)

打印结果:

在这里插入图片描述

如图可以看出原生 DOM 有非常多的属性和事件,就算是创建一个空div也要付出不小的代价。而使用虚拟 DOM 来提升性能的点在于 DOM 发生变化的时候,通过 diff 算法和数据改变前的 DOM 对比,计算出需要更改的 DOM,然后只对变化的 DOM 进行操作,而不是更新整个视图。

虚拟Dom 和 diff算法的关系

其实,vdom 是一个大的概念,而 diff算法 是 vdom 中的一部分。vdom 的核心价值在于最大程度上减少 真实DOM 的频繁更新
vdom 通过把 DOM 用 JS的方式 进行模拟,通过比较新旧虚拟DOM,只更新差异部分,然后批量操作真实DOM,减少了对真实DOM 的频繁操作,提高了性能。那么这个对比的过程就是diff算法。也就是说两者是包含关系,如下图所示:
在这里插入图片描述

二、认识diff算法

diff算法的优化

假如有1000个节点,就需要计算 1000³ 次,也就是10亿次,这样是无法接受的,所以 Vue 里使用 Diff 算法的时候都遵循深度优先同层比较的策略做了一些优化,来计算出最小变化

1)只比较同一层级,不跨级比较

Diff 过程只会把同颜色框起来的同一层级的 DOM 进行比较,这样来简化比较次数。
在这里插入图片描述

2) 比较tag标签名
如果同一层级的标签名不同,就直接删掉旧的虚拟 DOM 重建,不继续做深度比较。

在这里插入图片描述

3)比较 key
如果标签名相同,key 也相同,就会认为是相同节点,不继续做深度比较。
比如我们写 v-for 的时候会比较 key,不写 key 就会报错,这也就是因为 Diff 算法需要比较 key。

key的作用

通过图形例子会更好理解些:
比如有一个列表,需要在 列表中间 插入一个元素,会发生什么变化呢?先看个图
在这里插入图片描述

如图的 li1 和 li2 不会重新渲染,而 li3、li4、li5 都会重新渲染。

因为在不使用 key 或者列表的 index 作为 key 的时候,每个元素对应的位置关系都是索引 index,上图中的结果直接导致我们插入的元素到后面的全部元素,对应的位置关系都发生了变更,所以全部都会执行更新操作,这不是我们想要的,我们希望的是只渲染添加的那一个元素 li5,其他四个元素不做任何变更,就地复用就好,不要重新渲染。

而在使用唯一 key 的情况下,每个元素对应的位置关系就是 key,看一下使用唯一 key 值的情况下:

在这里插入图片描述

这样如图中的 li3 和 li4 就不会重新渲染,因为元素内容没发生改变,对应的位置关系也没有发生改变。
这也是为什么 v-for 必须要写 key,而且不建议开发中使用数组的 index 作为 key 的原因。

总结一下:

  • key 的作用主要是为了更高效的更新虚拟 DOM,因为它可以非常精确的找到相同节点,diff 操作可以更高效。
    如果数据项的顺序发生了改变,Vue 不会移动 DOM 元素来匹配数据项的顺序,而是简单“就地复用”此处的每个元素。

diff算法 在什么时候执行?

1. 页面 首次渲染 的时候,会调用一次 patch 并创建新的 vnode,不会进行更深层次的比较。
2. 然后就是在 组件中的数据发生变化 的时候,会触发 setter ,然后通过 notify() 通知 watcher,对应的 watcher 会通知更新,并执行更新函数,它会执行 render 函数获取新的虚拟 DOM,然后执行 patch 对比旧的虚拟 DOM,并计算出最小变化,然后再去根据这个最小变化去更新 真实的DOM,也就是视图更新。

三、深入diff算法源码

patch 函数

用于比较新旧 VNode,并进行 DOM 更新的核心函数。

需要注意的是,patch 函数在进行 DOM 更新时会尽可能地复用已有的 DOM 元素和节点,从而提高性能。它会通过对比新旧 VNode 的差异,只对真正发生变化的部分进行更新,而不会重新创建整个 DOM 结构。

主要流程是这样的:

  • vnode 不存在,oldVnode 存在,就删掉 oldVnode。(vnode 不存在表示组件被移除或不在需要渲染,为保持视图与数据的同步,所以删掉 oldVnode)

  • vnode 存在,oldVnode 不存在,就创建 vnode。

  • 两个都存在的话,通过 sameVnode() 比较两者是否是同一节点。

    1)如果是同一节点的话,通过 patchVnode() 函数进行后续对比 节点文本变化 或者 子节点变化
    2)如果不是同一节点,则删除该节点重新创建新节点进行替换
    (对于组件节点,Vue 将尽可能地复用已有的组件实例,而不是销毁和重新创建组件)

// src/core/vdom/patch.ts

// 两个判断函数
export function isUndef(v: any): v is undefined | null {
    
    
  return v === undefined || v === null
}

export function isDef<T>(v: T): v is NonNullable<T> {
    
    
  return v !== undefined && v !== null
}

 return function patch(oldVnode, vnode, hydrating, removeOnly) {
    
    
   // 当新的 VNode 不存在时,如果旧的 VNode 存在,则调用旧的 VNode 的销毁钩子函数,以确保在组件更新过程中正确地执行销毁逻辑。
   // 如果新的 VNode 不存在,通常表示组件 被移除 或者 不再需要渲染。
   // 如果旧的 VNode 仍然存在,它对应的 DOM 元素需要被删除,以保持视图与数据的同步。确保不留下无用的 DOM 节点,避免内存泄漏和不必要的性能开销。
    if (isUndef(vnode)) {
    
    
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue: any[] = []

  // 如果 oldVnode 不存在的话,新的 vnode 是肯定存在的,比如首次渲染的时候
    if (isUndef(oldVnode)) {
    
    
      isInitialPatch = true
       // 就创建新的 vnode
      createElm(vnode, insertedVnodeQueue)
    } else {
    
    
    // 剩下的都是新的 vnode 和 oldVnode 都存在的话
    
    // 旧的 VNode是不是元素节点
      const isRealElement = isDef(oldVnode.nodeType)
     // 如果旧的 VNode 是真实的 DOM 元素节点 && 与新的 VNode 是同一个节点
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
    
    
        // 如果是,就用 patchVnode 对现有的根节点进行更新操作,而不是重新创建整个组件树。
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
    
    
      // 如果不是同一元素节点的话
        if (isRealElement) {
    
    
          // const SSR_ATTR = 'data-server-rendered'
          // 如果是元素节点 并且有 'data-server-rendered' 这个属性
          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('一段很长的警告信息')
            }
          }
          // 如果不是服务端渲染的,或者混合失败,就创建一个空的注释节点替换 oldVnode
          oldVnode = emptyNodeAt(oldVnode)
        }

        // 拿到 oldVnode 的父节点
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

         // 根据新的 vnode 创建一个 DOM 节点,挂载到父节点上
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )

       // 如果新的 vnode 的根节点存在,就是说根节点被修改了,就需要遍历更新父节点
        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)
              }
              const insert = ancestor.data.hook.insert
              if (insert.merged) {
    
    
                for (let i = 1; i < insert.fns.length; i++) {
    
    
                  insert.fns[i]()
                }
              }
            } else {
    
    
              registerRef(ancestor)
            }
            // 更新父节点
            ancestor = ancestor.parent
          }
        }

        // 如果旧节点还存在,就删掉旧节点
        if (isDef(parentElm)) {
    
    
          removeVnodes([oldVnode], 0, 0)
        // 否则直接卸载 oldVnode
        } else if (isDef(oldVnode.tag)) {
    
    
          invokeDestroyHook(oldVnode)
        }
      }
    }
    // 返回更新后的节点
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }
}

sameVnode 函数

这个是用来判断 新旧Vnode 是不是 同一节点 的函数。

function sameVnode(a, b) {
    
    
  return (
    a.key === b.key &&  // key 是不是一样
    a.asyncFactory === b.asyncFactory &&  // 是不是异步组件
    ((a.tag === b.tag &&  // 标签是不是一样
      a.isComment === b.isComment &&  // 是不是注释节点
      isDef(a.data) === isDef(b.data) &&  // 内容数据是不是一样
      sameInputType(a, b)) ||   // 判断 input 的 type 是不是一样
      (isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error)))   // 判断区分异步组件的占位符否存在
  )
}

patchVnode 函数

这个是在新的 vnode 和 oldVnode 是同一节点的情况下,才会执行的函数,主要是对比 节点文本变化 或 子节点变化。

主要流程是这样的:

  • 如果 oldVnode 和 vnode 的引用地址是一样的,就表示节点没有变化,直接返回。

  • 如果 oldVnode 的 isAsyncPlaceholder 存在,就跳过异步组件的检查,直接返回。

  • 如果 oldVnode 和 vnode 都是静态节点 && 有相同的key && vnode是克隆节点 || v-once 指令控制的节点时,把 oldVnode.elm 和 oldVnode.child 都复制到 vnode 上,然后返回。

  • 如果 vnode 不是文本节点 也不是注释的情况下

    1)如果 oldVnode 和 vnode 都有子节点,并且 子节点不一样 的时候,调用 updateChildren() 函数 更新子节点。
    2)如果只有 vnode 有子节点,就调用 addVnodes() 创建子节点。
    3)如果只有 oldVnode 有子节点,就调用 removeVnodes() 删除该子节点。
    4)如果 oldVnode 是文本节点,就清空。

  • 如果 vnode 是文本节点但是和 oldVnode 文本内容不一样,就更新文本

 function patchVnode(
    oldVnode,  // 旧的虚拟 DOM 节点
    vnode,   // 新的虚拟 DOM 节点
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly?: any
  ) {
    
    
  // 新老节点引用地址是一样的,return 返回
  // 比如 props 没有改变的时候,子组件就不做渲染,直接复用
    if (oldVnode === vnode) {
    
    
      return
    }

    if (isDef(vnode.elm) && isDef(ownerArray)) {
    
    
      vnode = ownerArray[index] = cloneVNode(vnode)
    }

    const elm = (vnode.elm = oldVnode.elm)
    
    // 如果当前节点是注释或 v-if 的,或者是异步函数,就跳过检查异步组件
    if (isTrue(oldVnode.isAsyncPlaceholder)) {
    
    
      if (isDef(vnode.asyncFactory.resolved)) {
    
    
        hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
      } else {
    
    
        vnode.isAsyncPlaceholder = true
      }
      return
    }

    // 当前节点是静态节点的时候,key 也一样,并且vnode 是克隆节点,或者有 v-once 的时候,就直接赋值返回
    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)) {
    
    
      // 但是子节点不一样,就调用 updateChildren 函数,对比子节点
        if (oldCh !== ch)
          updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(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)
    }
  }

updateChildren 函数

这个是新的 vnodeoldVnode 都有子节点,且 子节点不一样 的时候进行对比子节点的函数。

这个函数 很关键,很关键!

比如现在有两个子节点列表对比,对比主要流程如下:

循环遍历两个列表,循环停止条件是:其中一个列表的开始指针 startIdx 和 结束指针 endIdx 重合
循环内容是:

  • 新的头 和 老的头 对比
  • 新的尾 和 老的尾 对比
  • 新的尾 和 老的头 对比
  • 新的头 和 老的尾 对比

以上四种只要有一种判断相等,就调用 patchVnode() 对比节点文本变化 或 子节点变化,然后移动对比的下标,继续下一轮循环对比。

如果以上 四种情况 都没有命中,就要用 循环 来寻找了,不断拿新的节点的 key 去老的 children 里找。

  • 如果 没找到,就创建一个新的节点。

  • 如果 找到了,再对比标签是不是同一节点。

    1)如果是同一个节点,调用 pathVnode() 进行后续对比,然后把这个节点插入到老的开始前面,并且移动新的开始下标,继续下一轮循环对比。
    2)如果不是相同节点,就创建一个新的节点。

  • 如果老的 vnode 先遍历完,就添加 新的vnode 没有遍历的节点。

  • 如果新的 vnode 先遍历完,说明老节点中还有剩余节点,就删除 老的vnode 没有遍历的节点。

为什么会有头对尾,尾对头的操作?

  • 头对尾 和 尾对头是 Diff 算法的一种优化策略,目的是尽可能地 复用 已存在的 DOM 节点来减少重新渲染的成本
  • 头对尾的操作指的是比较新旧节点列表中开头和结尾位置的节点对,然后逐步向内部移动比较。这样做的原因是在许多情况下,节点的变更主要发生在列表的首尾位置,而中间的节点相对稳定。通过首尾节点的对比,可以避免不必要的节点移动和更新,只需对新增或删除的节点进行插入或删除操作。
  • 尾对头的操作与头对尾类似。
  function updateChildren(
    parentElm,
    oldCh,
    newCh,
    insertedVnodeQueue,
    removeOnly
  ) {
    
    
    let oldStartIdx = 0  // 老 vnode 遍历的开始下标
    let newStartIdx = 0  // 新 vnode 遍历的开始下标
    let oldEndIdx = oldCh.length - 1   // 老 vnode 遍历的结束下标
    let oldStartVnode = oldCh[0]   // 老 vnode 列表第一个子元素
    let oldEndVnode = oldCh[oldEndIdx]   // 老 vnode 列表最后一个子元素
    let newEndIdx = newCh.length - 1  // 新 vnode 遍历的结束下标
    let newStartVnode = newCh[0]  // 新 vnode 列表第一个子元素
    let newEndVnode = newCh[newEndIdx]   // 老 vnode 列表最后一个子元素
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    const canMove = !removeOnly

    if (__DEV__) {
    
    
      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)) {
    
    
      // 是同一节点 递归调用 继续对比这两个节点的内容和子节点
        patchVnode(
          oldStartVnode,
          newStartVnode,
          insertedVnodeQueue,
          newCh,
          newStartIdx
        )
        // 然后把指针后移一位,从前往后依次对比
        // 比如第一次对比两个列表[0],然后对比[1]...,后面同理
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
        
       // 老结束和新结束对比
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
    
    
        patchVnode(
          oldEndVnode,
          newEndVnode,
          insertedVnodeQueue,
          newCh,
          newEndIdx
        )
         // 然后把指针前移一位,从后往前依次对比
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]

      // 老开始和新结束对比
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
    
    
        patchVnode(
          oldStartVnode,
          newEndVnode,
          insertedVnodeQueue,
          newCh,
          newEndIdx
        )
        canMove && nodeOps.insertBefore(parentElm,oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        // 老的列表从前往后取值,新的列表从后往前取值,然后对比
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]

      // 老结束和新开始对比
      } else if (sameVnode(oldEndVnode, newStartVnode)) {
    
    
        patchVnode(
          oldEndVnode,
          newStartVnode,
          insertedVnodeQueue,
          newCh,
          newStartIdx
        )
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        // 老的列表从后往前取值,新的列表从前往后取值,然后对比
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
        
      // 以上四种情况都没有命中的情况
      } else {
    
    
        if (isUndef(oldKeyToIdx))
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
         // 拿到新开始的 key,在老的 children 里去找有没有某个节点有这个 key
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

       // 新的 children 里有,可是没有在老的 children 里找到对应的元素
        if (isUndef(idxInOld)) {
    
    
          // 创建新的元素
          createElm(
            newStartVnode,
            insertedVnodeQueue,
            parentElm,
            oldStartVnode.elm,
            false,
            newCh,
            newStartIdx
          )
        } else {
    
    
        // 在老的 children 里找到了对应的元素
          vnodeToMove = oldCh[idxInOld]
          // 判断是否是同一个元素
          if (sameVnode(vnodeToMove, newStartVnode)) {
    
    
          // 是同一节点 递归调用 继续对比这两个节点的内容和子节点
            patchVnode(
              vnodeToMove,
              newStartVnode,
              insertedVnodeQueue,
              newCh,
              newStartIdx
            )
            oldCh[idxInOld] = undefined
            canMove &&
              nodeOps.insertBefore(
                parentElm,
                vnodeToMove.elm,
                oldStartVnode.elm
              )
          } else {
    
    
            // 不同的话,就创建新元素
            createElm(
              newStartVnode,
              insertedVnodeQueue,
              parentElm,
              oldStartVnode.elm,
              false,
              newCh,
              newStartIdx
            )
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    
   // 说明老的 vnode 先遍历完
    if (oldStartIdx > oldEndIdx) {
    
    
    // 就添加从 newStartIdx 到 newEndIdx 之间的节点
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(
        parentElm,
        refElm,
        newCh,
        newStartIdx,
        newEndIdx,
        insertedVnodeQueue
      )

    // 否则就说明新的 vnode 先遍历完
    } else if (newStartIdx > newEndIdx) {
    
    
    // 就删除老的 vnode 里没有遍历的结点
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }

总结

1. 虚拟DOM的解析过程

首先对将要插入到文档中的DOM树结构进行分析,使用 js对象 将其表示出来,比如一个元素对象,包含 TagNamepropsChildren 这些属性。然后将这个 js对象树 给保存下来,最后再将DOM片段插入到文档中。

当页面的状态发生改变,需要对页面的DOM的结构进行调整的时候,首先根据变更的状态,重新构建起一棵对象树,然后将这棵 新的对象树旧的对象树 进行 比较,记录下两棵树的的差异。

最后将记录的有差异的地方应用到 真正的DOM树 中去,这样视图就更新了。

2. diff 算法的原理

在新老虚拟dom对比时:

首先,对比节点本身,通过 sameVnode() 判断是否是同一节点。

  • 如果不为相同节点,则 删除 该节点重新创建新节点进行替换。
  • 如果为相同节点,进行 patchVnode() ,判断如何对该节点的子节点进行处理。

先判断一方有子节点一方没有子节点的情况。

1)如果新的children 有子节点,就调用 addVnodes() 创建新子节点。
2)如果新的children 没有子节点, 就调用 removeVnodes() 删除旧子节点。

如果都有子节点,但是子节点不一样时候,则进行 updateChildren(),判断如何对这些新老节点的子节点进行操作(diff 核心)。

匹配时,找到相同的子节点,递归调用 patchVnode() 函数来进一步比较和更新这些子节点

在 diff 中,只对同层的子节点进行比较,放弃跨级的节点比较,使得时间复杂从 O(n3)降低值 O(n),也就是说,只有当新旧 children都为多个子节点时才需要用核心的 Diff 算法进行同层级比较。

可参考:
面试中的网红虚拟DOM,你知多少呢?深入解读diff算法
深入浅出虚拟 DOM 和 Diff 算法,及 Vue2 与 Vue3 中的区别

猜你喜欢

转载自blog.csdn.net/weixin_45811256/article/details/131815014