Vue2的虚拟DOM与DOM-Diff算法

633 阅读11分钟

什么是虚拟DOM

简单来说,虚拟DOM是指使用JS对象来描述一个DOM节点。

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

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

为什么要使用虚拟DOM?

由于浏览器的标准,真实DOM对象本身被设计的非常复杂,而且由于JS引擎、DOM引擎和排版引擎共享浏览器的一个主线程,多次使用JS操作DOM会造成频繁的上下文切换,进而导致性能急剧下降(一些测试)。所以虚拟DOM真正要解决的问题是减少不必要的DOM API调用,通过DOM-Diff对比数据变化前后的状态,计算出哪些地方需要更新。

实际上对于单次的DOM API操作来说,使用虚拟DOM不一定会效率更高,但是框架提供了一个普适的性能能够接受的解决方案。Evan You的回答

Vue的虚拟DOM

通过Vue中的VNode类,我们可以实例化出不同类型的虚拟DOM节点,而一棵虚拟DOM树就是由VNode组成的。

Vue虚拟DOM的实现方式取材于一个虚拟DOM库snabbdom

image.png

// 源码位置:src/core/vdom/vnode.js

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类中包含了描述一个节点的必要属性,如tag表示节点的标签名,text表示节点中包含的文本,children表示该节点包含的子节点等,elm表示当前VNode对应的真实DOM节点。

我们在视图渲染之前,把写好的template模板先编译成VNode并缓存下来,等到数据发生变化页面需要重新渲染的时候,我们把数据发生变化后生成的VNode与前一次缓存下来的VNode进行对比,找出差异,然后有差异的VNode对应的真实DOM节点就是需要重新渲染的节点,最后根据有差异的VNode创建出真实的DOM节点再插入到视图中,最终完成一次视图更新。

下面要讨论的重点自然是如何找出数据发生变化后的NewVNode和变化前OldVnode的差异了。

DOM-Diff

Vue中的DOM-Diff叫做patch过程。从语义角度来理解,patch即打补丁,通过对比新旧两份VNode的变化,以新的newVNode为基准对比旧的newVNode,来更新real DOM,使之成为newVNode映射的real DOM。通过patch函数作为入口,执行的动作无非有三种,创建节点、删除节点、更新节点。

创建节点

能够被插入到DOM的节点类型共有三种,元素节点、文本节点和注释节点,对于这三种节点类型Vue也是封装了不同的创建方法。

// 源码位置: /src/core/vdom/patch.js
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中
    }
  }

代码中的nodeOps是Vue为了跨平台兼容性,对所有节点操作进行了封装,例如nodeOps.createTextNode()在浏览器端等同于document.createTextNode()

删除节点

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

更新节点

function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
  // vnode与oldVnode是否完全一样?若是,退出程序
  if (oldVnode === vnode) {
    return
  }
  /** 
   * vnode的elm对象属性、oldVnode的elm对象属性以及elm都指向同一个内存地址
   * 这样改变elm就会改变所有
  */
  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)
  }
}

所谓静态节点,就是指内容固定、与Vue data无关的节点,比如

<p>我是不会变化的文字</p>

下面将会主要介绍更新子节点的updateChildren方法。

更新子节点

// 循环更新子节点如果发现新旧节点相同则递归更新两者的子节点...
  function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    // oldChildren开始索引
    let oldStartIdx = 0
    // oldChildren结束索引
    let oldEndIdx = oldCh.length - 1
    // 记录当前oldChildren所有未被处理的第一个
    let oldStartVnode = oldCh[0] 
    // 记录当前oldChildren所有未被处理的最后一个
    let oldEndVnode = oldCh[oldEndIdx]   
    
    // newChildren开始索引
    let newStartIdx = 0
    // newChildren结束索引
    let newEndIdx = newCh.length - 1
    // 记录当前newChildren所有未被处理的第一个
    let newStartVnode = newCh[0]
    // 记录当前newChildren所有未被处理的最后一个
    let newEndVnode = newCh[newEndIdx]  

    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // 与transition相关,可以忽略
    const canMove = !removeOnly
    
    // 非生产环境会校验newCh是否有重复的key
    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }
    // 注意此时结束索引已经确定,所以之后的oldCh数组改变并不会影响指针的遍历
    // 以"头头"、"尾尾"、"头尾"、"尾头"的方式开始比对节点
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] 
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
        // 以上对oldVnode为undefined的情况做了跳过处理。为什么会有undefined?下面会解释
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 如果新前与旧前节点相同,就递归地调用patchVnode,近一步更新二者的子节点
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // 与上类似
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
        // 如果新后与旧前节点相同,先把两个节点进行patch更新,然后把旧前节点移动到oldChilren中所有未处理节点之后,即当前oldEndVnode的下一个节点之前
        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)) {
        // 如果新前与旧后节点相同,先把两个节点进行patch更新,然后把旧后节点移动到oldChilren中所有未处理节点之前
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        /**
         * 如果不属于以上四种情况,就先试图通过key来找
         * 对oldCh生成oldKeyToIdx:{key:index} 
         * 通过这个map来找和当前newCh的key相同的oldCh的index
         * 但是如果newCh没有key,就只能循环暴力去找在oldCh中的索引了
        */
        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(vnodeToMove, newStartVnode, insertedVnodeQueue)
            /** 
             * 在这里把会被移动的oldCh[idxInOld]置为undefined
             * 因为这种非特殊的更新只会移动newStartIdx
             * 所以在之后的循环中随着可能的oldIdx的移动,这个oldCh节点还有可能被遍历到
             * 当他被遍历到时由于被标注为已移动即undefined,所以就会走入代码开头的判断中
             */
            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)
    }
  }

另附sameVnode函数:

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)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

最主要解决的问题是,如何快速的定位到与新节点相同的老节点(或者确定没有这样的老节点),如果每次都是暴力的双循环,则时间复杂度平均在O(n^2)数量级。但是根据前端变化DOM实际的频繁操作,增加了头尾组合的比较,通过O(1)的时间复杂度快速锁定与头尾有关的变化,并且通过双指针夹逼的方式不断更新索引,记录需要移动节点的被移动位置并在一个数组遍历完成后,获得纯粹需要删除或者新增的子节点。当出现头尾比较不能解决的问题时,兜底策略一是使用key,帮助锁定带有someKey的新子节点在老子节点中有相同key的老字子节点的位置,再进行sameVnode的比较,并在需要移动老子节点后将当前位置设为undefined避免二次查找;如果新节点不存在key或老节点不存在key,都会触发最终的兜底策略,即遍历oldCh暴力寻找是否存在相同节点。整个过程newCh并没有变化,而是不断的拿newCh的节点在oldCh中寻找相同节点并更新、移动,来达到patch的效果。

几个例子

Page1.jpg

Page2.jpg

Page3.jpg

Vue2 Diff算法的特点

Vue2的这种DOM-Diff算法(即snabbdom的算法)和React的diff算法相同之处在于都认为跨层移动出现的很少,所以只对同一层级的进行比较,不考虑跨层级的变化,通过这种tricky的方式,将O(n^3)的树的编辑距离问题(最后会提到关于编辑距离的简单介绍)变为接近于O(n)时间复杂度的列表更新问题,并且二者都采用了列表key的方式来加速更新和节点复用;不同之处在于React在比较列表的过程中只会使用lastIndex记录相同元素在老列表出现的最大位置,如果遍历发现新的相同元素出现位置小于lastIndex,则需要移动。Vue2 引入了双端比较的算法,通过在新旧列表中分别采用两个指针指向列表头部和尾部,每次执行新旧列表中四次指针比较判断是否存在节点复用,从而避免 React 算法中尾部元素移动至列表头部的低效问题。

编辑距离问题

编辑距离问题是一道经典的动态规划问题,问题大致是给定两个字符串word1和word2,求将word1转换为word2最少需要多少次操作,操作包括新增、删除和替换(leetcode#72)。解决这个问题需要一个二维的dp table,每一个位置(i, j)代表word1.substr(0, i)转化到word2.substr(0, j)的最少操作次数,我们可以发现这个dp table的状态转移方程是:

注意dp table的(i,j)对应的是子字符串长度,所以在获得这个长度的最后一个字符时,要使用word1[i - 1],word2[j - 1]

dp[i][j] = dp[i-1][j-1]  (if word1[i-1] === word2[j-1])
           min(dp[i-1][j-1]+1, dp[i-1][j]+1, dp[i][j-1]+1) (else)

解释一下,如果在dp table新的(i,j)位置,对应word1子串的最后一位于word2子串的最后一位相等,那么什么都不用做,此时最小的操作次数和dp [i-1][j-1]即两者都减少最后一个字符串的状态是一样的;当最后一位不相等时,需要从别的状态进行某种操作才能到达要求的状态,还记得我们有三种操作方式新增、删除和替换,从dp[i][j-1]状态转化来需要新增,从dp[i-1][j]转化来 需要删除,从dp[i-1][j-1]转化来需要替换,这三者各自+1的最小值便是dp[i][j]对应的最小操作次数。直到完成dp table,dp[word1.length][word2.length]的值即为这两个字符串的编辑距离,时间复杂度是填满二维的dp table,即O(n^2)级别的。

// dp[i][j] *** 表示word1.substr(0,i)(即word1从idx0开始截取i长的子串)和word2.substr[0,j]的编辑距离 ***
// dp[i][j] = dp[i-1][j-1]  (if word1[i-1] === word2[j-1])
//            min(dp[i-1][j-1]+1, dp[i-1][j]+1, dp[i][j-1]+1)   (else)
var minDistance = function(word1, word2) {
    const dp = new Array(word1.length + 1).fill(0).map(_=>new Array(word2.length + 1).fill(0))
    for(let i = 1; i <= word1.length;i++) {
        dp[i][0] = i
    }
    for(let j = 1; j <= word2.length;j++) {
        dp[0][j] = j
    }
    for (let i = 1; i <= word1.length; i++) {
        for (let j = 1; j <= word2.length; j++){
            if(word1[i-1] === word2[j-1]){
                dp[i][j] = dp[i-1][j-1]
            } else {
                dp[i][j] = Math.min(dp[i-1][j-1]+1, dp[i-1][j]+1, dp[i][j-1]+1)
            }
        }
    }
    return dp[word1.length][word2.length]

zhuanlan.zhihu.com/p/91667128

所以其实DOM的Diff在传统意义上说就是两颗虚拟DOM树的编辑距离问题,但显然这个问题更加复杂,时间复杂度是O(n^3)数量级,具体可以阅读这篇论文,能力有限,我就不在此赘述了。

References:

vue-js.com/learn-vue/v…

hustyichi.github.io/2020/09/16/…

ustbhuangyi.github.io/vue-analysi…