vue源码之虚拟DOM

虚拟DOM简介

首先,什么是虚拟DOM?虚拟DOM就是一个JS对象来描述一个DOM节点,像如下示例:

<div class="a" id="b">我是内容</div>

{
  tag:'div',        // 元素标签
  attrs:{           // 属性
    class:'a',
    id:'b'
  },
  text:'我是内容',  // 文本内容
  children:[]       // 子元素
}

那么,为什么要有虚拟DOM呢?我们知道,vue是靠数据驱动视图的,数据发送变化视图就要发生变化,在更新视图时就要操作DOM,而真实的DOM元素的设计非常复杂,所以操作真实DOM元素非常消耗性能。所以我们可以用JS的计算性能来换取操作DOM所消耗的性能。
也就是说,在更新视图之前,先去对比数据变化前后的不同,只更新不同的地方,其他地方不需要关系,这样就能尽可能少的操作DOM了。

Vue中的虚拟DOM

1.Vnode类
虚拟DOM就是用JS来描述一个真实DOM节点,而在vue中存在一个Vnode类,就是用来实例化不同的DOM节点,源码:

export default class VNode {
  constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
    this.tag = tag                                /*当前节点的标签名*/
    this.data = data        /*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息*/
    this.children = children  /*当前节点的子节点,是一个数组*/
    this.text = text     /*当前节点的文本*/
    this.elm = elm       /*当前虚拟节点对应的真实dom节点*/
    this.ns = undefined            /*当前节点的名字空间*/
    this.context = context          /*当前组件节点对应的Vue实例*/
    this.fnContext = undefined       /*函数式组件对应的Vue实例*/
    this.fnOptions = undefined
    this.fnScopeId = undefined
    this.key = data && data.key           /*节点的key属性,被当作节点的标志,用以优化*/
    this.componentOptions = componentOptions   /*组件的option选项*/
    this.componentInstance = undefined       /*当前节点对应的组件的实例*/
    this.parent = undefined           /*当前节点的父节点*/
    this.raw = false         /*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/
    this.isStatic = false         /*静态节点标志*/
    this.isRootInsert = true      /*是否作为跟节点插入*/
    this.isComment = false             /*是否为注释节点*/
    this.isCloned = false           /*是否为克隆节点*/
    this.isOnce = false                /*是否有v-once指令*/
    this.asyncFactory = asyncFactory
    this.asyncMeta = undefined
    this.isAsyncPlaceholder = false
  }

  get child (): Component | void {
    return this.componentInstance
  }
}

Vnode的作用:
在视图渲染之前,把写好的template模板先编译成Vnode并缓下来,等数据发生变化需要重写渲染时,把数据发生变化后生成的Vnode和前一次缓存下来的Vnode进行对比,然后根据有差异的Vnode创建出真实DOM节点再插入视图中,完成一次视图更新。

Diff算法

Diff算法就是对比新旧两份Vnode并找出差异。
在vue中,把DOM-diff的过程叫做patch过程。在patch过程中,要以生成的新Vnode为基准,对比旧的Vnode,大概有这几种情况:

  • 创建节点:新节点有,旧节点没有
  • 删除节点:新节点没有,节点有
  • 更新节点:新旧节点都有

创建节点

vue有三种类型的节点能够被创建并插入到DOM中,分别是:元素节点、文本节点、注释节点。所以我们只需要在创建节点的时候判断在新的Vnode中而旧的oldVnode中没有的这个节点是什么类型的节点,从而创建并插入到DOM中,源码如下:

function createElm (vnode, parentElm, refElm) {
    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    if (isDef(tag)) {
      	vnode.elm = nodeOps.createElement(tag, vnode)   // 创建元素节点
        createChildren(vnode, children, insertedVnodeQueue) // 创建元素节点的子节点
        insert(parentElm, vnode.elm, refElm)       // 插入到DOM中
    } else if (isTrue(vnode.isComment)) {
      vnode.elm = nodeOps.createComment(vnode.text)  // 创建注释节点
      insert(parentElm, vnode.elm, refElm)           // 插入到DOM中
    } else {
      vnode.elm = nodeOps.createTextNode(vnode.text)  // 创建文本节点
      insert(parentElm, vnode.elm, refElm)           // 插入到DOM中
    }
  }

流程图:

删除节点

如果某些节点在新的Vnode中没有而在旧的oldVnode中有,那么就需要把这些节点从oldVnode中删除,源码如下:

function removeNode (el) {
    const parent = nodeOps.parentNode(el)  // 获取父节点
    if (isDef(parent)) {
      nodeOps.removeChild(parent, el)  // 调用父节点的removeChild方法
    }
  }

更新节点

在更新节点之前先明白一个概念,什么是静态节点?例子:
<p>我是不会变化的文字</p>
应该明白了把,就是在这个节点中没有任何变量,只有纯文字,也就是说它以后永远都不会发生变化了,所以数据发生任何变化都与他无关,这种节点称之为静态节点。
那么我们对更新情况进行判断并分别处理;

  1. 如果Vnode和oldVnode都是静态节点
    静态节点无论数据发生任何变化都与他无关,所以都是静态节点的话直接跳过,无需处理
  2. 如果Vnode是文本节点
    首先查看oldVnode是否也是文本节点,如果是,就比较两个文本是否不同,如果不同就把oldVnode中的文本改成跟Vnode的文本一样;如果oldVnode不是文本节点,那么不论它是什么,直接调用setTextNode方法把它改成文本节点,并且文本内容跟Vnode相同。
  3. 如果Vnode是元素节点
    如果是Vnode是元素节点,又细分以下两种情况:
    • 该节点包含子节点
      如果新的节点内包含了子节点,那么此时要看旧的节点是否包含子节点,如果旧节点里也包含了子节点,那就需要递归对比更新子节点;如果旧的节点里不包含子节点,那么这个旧节点有可能是空节点或者是文本节点,如果是空节点就把新的节点里的子节点创建一份然后插入到oldVnode中,如果是文本节点就清空文本,然后把新的节点里的子节点创建一份插入到oldVnode中。
    • 该节点不包含子节点
      如果该节点不包含子节点,同时又不是文本节点,那就说明该节点是个空节点,那么直接把之前的旧节点全部清空就可以了。
      理清楚了逻辑后,那么我们来看看源码中具体是怎么实现的:
// 更新节点
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
  // vnode与oldVnode是否完全一样?若是,退出程序
  if (oldVnode === vnode) {
    return
  }
  const elm = vnode.elm = oldVnode.elm

  // vnode与oldVnode是否都是静态节点?若是,退出程序
  if (isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    return
  }

  const oldCh = oldVnode.children
  const ch = vnode.children
  // vnode有text属性?若没有:
  if (isUndef(vnode.text)) {
    // vnode的子节点与oldVnode的子节点是否都存在?
    if (isDef(oldCh) && isDef(ch)) {
      // 若都存在,判断子节点是否相同,不同则更新子节点
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
    }
    // 若只有vnode的子节点存在
    else if (isDef(ch)) {
      /**
       * 判断oldVnode是否有文本?
       * 若没有,则把vnode的子节点添加到真实DOM中
       * 若有,则清空Dom中的文本,再把vnode的子节点添加到真实DOM中
       */
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    }
    // 若只有oldnode的子节点存在
    else if (isDef(oldCh)) {
      // 清空DOM中的子节点
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    }
    // 若vnode和oldnode都没有子节点,但是oldnode中有文本
    else if (isDef(oldVnode.text)) {
      // 清空oldnode文本
      nodeOps.setTextContent(elm, '')
    }
    // 上面两个判断一句话概括就是,如果vnode中既没有text,也没有子节点,那么对应的oldnode中有什么就清空什么
  }
  // 若有,vnode的text属性与oldVnode的text属性是否相同?
  else if (oldVnode.text !== vnode.text) {
    // 若不相同:则用vnode的text替换真实DOM的文本
    nodeOps.setTextContent(elm, vnode.text)
  }
}

官方流程图:

更新子节点

当新的Vnode和旧的Vnode都是元素节点并且都包含子节点时,我们把新的Vnode上的子节点数组记为newChildren,旧的oldVnode上的子节点数组记为oldChildren。我们把newchildren和oldchildren中的元素都要进行一一对比,这时又有两种方案,一种是拿一个新的然后旧的里面一个一个的找,伪代码如下:

for (let i = 0; i < newChildren.length; i++) {
  const newChild = newChildren[i];
  for (let j = 0; j < oldChildren.length; j++) {
    const oldChild = oldChildren[j];
    if (newChild === oldChild) {
      // ...
    }
  }
}

这样的话当有很多子节点的时候,就会很消耗性能。所以选择第二种。
首先,我们有如下定义:
newChildren数组里的所有未处理子节点的第一个子节点称为:新前;
newChildren数组里的所有未处理子节点的最后一个子节点称为:新后;
oldChildren数组里的所有未处理子节点的第一个子节点称为:旧前;
oldChildren数组里的所有未处理子节点的最后一个子节点称为:旧后;

然后我们定义一个更新规则:

  1. 旧前和新前进行对比,如果相同,进入更新节点操作后无需进行节点移动操作,只需要旧前和新前后移继续对比。如果不同,进行下面的规则;
  2. 旧后和新后进行对比,如果相同,进入更新节点操作后无需进行节点移动操作,只需要旧后和新后前移然后又第一规则进行对比。如果不同,进行下面的规则;
  3. 旧前和新后进行对比,如果相同,进入更新节点操作后再将oldchildren数组里的该节点移动到oldchildren所有未更新节点之后,旧前后移,新后前移。如果不同,进行下面规则;
  4. 旧后和新前进行对比,如果相同,进入更新节点操作后再将oldchildren数组里的该节点移动到所有未处理节点之前,旧后前移,新前后移。如果不同,进行下面规则;
  5. 上面四种规则都不能找到,那么就使用最费劲的循环,如果在oldchildren所有未处理的数组中找到和新前相同的,就将该节点移动到所有未处理节点之前,然后新前后移;如果没有找到,就创建一个节点然后移动到数组中所有未处理节点之前,新前后移。
    思路理清,分析源码:
// 循环更新子节点
  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0               // oldChildren开始索引
    let oldEndIdx = oldCh.length - 1   // oldChildren结束索引
    let oldStartVnode = oldCh[0]        // oldChildren中所有未处理节点中的第一个
    let oldEndVnode = oldCh[oldEndIdx]   // oldChildren中所有未处理节点中的最后一个

    let newStartIdx = 0               // newChildren开始索引
    let newEndIdx = newCh.length - 1   // newChildren结束索引
    let newStartVnode = newCh[0]        // newChildren中所有未处理节点中的第一个
    let newEndVnode = newCh[newEndIdx]  // newChildren中所有未处理节点中的最后一个

    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // removeOnly is a special flag used only by <transition-group>
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    const canMove = !removeOnly

    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }

    // 以"新前"、"新后"、"旧前"、"旧后"的方式开始比对节点
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // 如果oldStartVnode不存在,则直接跳过,比对下一个
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 如果新前与旧前节点相同,就把两个节点进行patch更新
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // 如果新后与旧后节点相同,就把两个节点进行patch更新
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        // 如果新后与旧前节点相同,先把两个节点进行patch更新,然后把旧前节点移动到oldChilren中所有未处理节点之后
        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)) { // Vnode moved left
        // 如果新前与旧后节点相同,先把两个节点进行patch更新,然后把旧后节点移动到oldChilren中所有未处理节点之前
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        // 如果不属于以上四种情况,就进行常规的循环比对patch
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        // 如果在oldChildren里找不到当前循环的newChildren里的子节点
        if (isUndef(idxInOld)) { // New element
          // 新增节点并插入到合适位置
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          // 如果在oldChildren里找到了当前循环的newChildren里的子节点
          vnodeToMove = oldCh[idxInOld]
          // 如果两个节点相同
          if (sameVnode(vnodeToMove, newStartVnode)) {
            // 调用patchVnode更新节点
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
            oldCh[idxInOld] = undefined
            // canmove表示是否需要移动节点,如果为true表示需要移动,则移动节点,如果为false则不用移动
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    if (oldStartIdx > oldEndIdx) {
      /**
       * 如果oldChildren比newChildren先循环完毕,
       * 那么newChildren里面剩余的节点都是需要新增的节点,
       * 把[newStartIdx, newEndIdx]之间的所有节点都插入到DOM中
       */
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      /**
       * 如果newChildren比oldChildren先循环完毕,
       * 那么oldChildren里面剩余的节点都是需要删除的节点,
       * 把[oldStartIdx, oldEndIdx]之间的所有节点都删除
       */
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
  }

posted @ 2021-08-18 14:27  卿六  阅读(107)  评论(0编辑  收藏  举报