Vue 源码剖析 —— 虚拟 DOM

544 阅读13分钟

什么是虚拟 DOM?

在Vue.js 等主流框架中,我们只需要描述应用状态以及 DOM 之间的映射关系,具体渲染由框架负责。那框架是如何确定状态中发生了什么变化以及需要在哪里更新 DOM 呢?最简单粗暴的方法是把所有 DOM 都删了重新生成一份 DOM。显然这是不可取的,访问 DOM 的操作是相当昂贵的,这样会造成相当多的性能浪费。Vue.js 针对上述问题引入了虚拟 DOM:通过状态生成一棵虚拟节点树,然后使用虚拟节点树进行渲染,渲染前会对比新的虚拟节点树与旧节点树,再渲染不同的部分。

在变化侦测部分我们提到过,Vue.js 在一定程度上能够获知具体是哪个状态发生了变化。如果直接将状态绑定在具体节点上,那状态变化时就可以直接操作具体的节点,不需要做比对。但是这样会造成一个问题,由于粒度太细,每个节点都拥有一个对应的 watcher 来侦测变化。对于大型项目来说,造成的内存开销以及跟踪依赖的开销是非常大的。Vue.js 2.0 正是出于解决这个问题的目的引入了虚拟 DOM。

拿一个典型的 .vue 文件来说,先框架将模板编译成渲染函数(render),再执行渲染函数就能得到一个虚拟节点树,最后使用这个虚拟节点树就可以渲染页面。

虚拟DOM

如果直接用新节点覆盖旧节点的话会有很多不必要的 DOM 操作,所以实际上渲染过程并没有那么简单,需要先将虚拟节点与上一次渲染视图用的旧虚拟节点进行对比,从而找出真正需要更新的节点来进行 DOM 操作。

总结一下,可以看出虚拟 DOM 在 Vue.js 中主要做两件事情:

  • 提供与真实 DOM 节点所对应的虚拟节点 vnode
  • 将虚拟节点与旧虚拟节点进行对比,然后更新视图

什么是 VNode?

VNode 是 Vue.js 中的一个类,我们用不同类型的 VNode 实例表示不同类型的 DOM 节点。下面是源码中 VNode 的实现:

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
    this.children = children
    this.text = text
    this.elm = elm
    this.ns = undefined
    this.context = context
    this.fnContext = undefined
    this.fnOptions = undefined
    this.fnScopeId = undefined
    this.key = data && data.key
    this.componentOptions = componentOptions
    this.componentInstance = undefined
    this.parent = undefined
    this.raw = false
    this.isStatic = false
    this.isRootInsert = true
    this.isComment = false
    this.isCloned = false
    this.isOnce = false
    this.asyncFactory = asyncFactory
    this.asyncMeta = undefined
    this.isAsyncPlaceholder = false
  }

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

每次渲染视图时,都是先创建 vnode,然后使用它创建真实 DOM 插入页面,所以可以将上一次渲染视图的 vnode 缓存起来,之后每当需要重新渲染视图时,就将新创建的 vnode 和上一次的 vnode 对比,找出不一致的地方更新真实 DOM。

Vue.js 对状态的侦测策略采用了中等粒度,每当状态发生变化时,只通知到组件级别,然后在组件内使用虚拟 DOM 来渲染视图。也就是说,只要组件使用的众多状态中的一个发生了变化,整个组件就要重新渲染。也是因此,缓存上一次的 vnode 并将其与最新的 vnode 进行对比才显得十分重要。

VNode 类型

目前 VNode 有以下几种类型:

  • 注释节点

    创建注释节点的过程如下,通过下面这段代码可以很清楚的看到它有哪些属性:

    createEmptyNode = text => {
      const node = new VNode()
      node.text = text
      node.isComment = true
      return node
    }
    
  • 文本节点

    文本节点的创建过程类似:

    createTextNode(val) {
      return new VNode(undefined, undefined, undefined, String(val))
    }
    
  • 元素节点

    元素节点顾名思义是用来描述一个 DOM 节点的,通常会存在以下属性:

    1. tag: 元素标签名;
    2. data: 该属性包含了一些节点上的数据,比如 attrsclassstyle 等;
    3. children: 当前节点的子节点列表,注意此处子节点也是 VNode 实例;
    4. context: 当前组件的 Vue 实例。
  • 组件节点

    组件节点和元素节点类似,只是有以下单独的两个属性:

    • componentOpitons: 该属性包含了组件节点的选项参数,包括 propsDatatagchildren
    • componentInstance: 组件的 Vue 实例
  • 函数式组件

    函数式组件与元素组件类似也有两个独有的属性:functionalOptionsfunctionalContext

  • 克隆节点

    克隆节点是将现有节点的属性复制到新的节点中,让新创建的节点和被克隆的节点的属性保持一致,从而实现克隆效果。它的作用是优化静态节点和插槽节点。比如静态节点,我们知道当组件的某个状态变化之后会通过虚拟 DOM 重新渲染视图,但静态节点的视图不依赖于状态,所以我们可以通过将节点克隆一份,每次使用克隆节点渲染。

patch

前面提到的将新旧节点对比后再渲染视图的过程,是虚拟 DOM 中最核心的部分,我们称之为 patch。patch 的目的是渲染视图,而要达到这个目的,我们需要分析新旧 vnode 之间的差异从而修改现有 DOM,一般来说,对现有 DOM 修改无非这三种方式:

  • 创建新增的节点
  • 删除废弃的节点
  • 修改需要更新的节点

patch

新增节点

虽然 VNode 有很多种类型,但实际上只有三种类型会被创建并插入到 DOM 中:元素节点,注释节点,文本节点。

要判断是否是元素节点,只需要判断是否有 tag 属性即可,接着就调用 createElement 来创建真实元素节点,然后再调用 appendChild 方法将节点插入到父节点。但如果这个元素节点自身又有子节点呢?

创建子节点的过程其实是一个递归的过程。vnode 中的 children 属性保存了当前节点的所有子虚拟节点,所以只需要将 children 循环一遍,将每个子虚拟节点都执行创建元素的逻辑,具体可以查看源码中 createElm 函数的实现(./src/core/vdom/patch.js)。

注释节点和文本节点的创建就比较简单了,在 tag 属性不存在的情况下,通过判断 isComment 区分注释节点和文本节点。

删除节点

当一个节点只出现在 oldVnode 中时,就需要进行删除操作了。实现过程很简单,如下:

function removeVnodes (vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (isDef(ch)) {
      if (isDef(ch.tag)) {
        removeAndInvokeRemoveHook(ch)
        invokeDestroyHook(ch)
      } else { // Text node
        removeNode(ch.elm)
      }
    }
  }
}

更新节点

  1. 判断是否是静态节点

    在更新节点时,先判断是否是静态节点,如果是,就不需要更新。源码中具体判断过程如下:

    if (isTrue(vnode.isStatic) &&
      isTrue(oldVnode.isStatic) &&
      vnode.key === oldVnode.key &&
      (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
      vnode.componentInstance = oldVnode.componentInstance
      return
    }
    
  2. 新虚拟节点有文本节点

    当节点通过静态节点检测时,要以新的节点为准来更新视图,如果新节点有 text 属性,那不论之前旧节点是什么,直接设置 DOM 内容。

  3. 新虚拟节点没有文本节点 如果新节点没有 text 属性,那么他就是一个元素节点。元素节点又分为有子节点和没有子节点两种情况:

    • children 的情况

      newVnodechildren 的情况下,需要区分 oldVnode 是否有 children 属性。如果有,那么就需要将各自的 children 进行详细的对比,具体过程我们会在下面详细描述;如果没有,那么旧标签要么就是空要么就是文本节点。如果是文本节点,就先清空变为空标签,再将新节点下的子节点渲染为真实 DOM 后插入当前 DOM 节点。

    • 没有 children 的情况:这种情况下直接清空,将 DOM 节点变为空标签即可。

子节点的更新

更新子节点分为四种操作:更新节点,新增节点,删除节点,移动节点位置。而要更新子节点首先要对比两个子节点有哪些不同,再针对不同情况做不同处理。比如,newChildren 有一个节点在 oldChildren 中找不到相同节点,那就说明是新增节点。

对比两个子节点列表,首先要做的是循环。循环 newChildren 中每一个新子节点,并在 oldChildren 中找到对应的旧子节点。如果找不到,就做新增节点的操作;找到了就做更新节点的操作。如果找到的旧节点位置和新节点不同,则需要移动位置。

更新策略
  1. 创建子节点

    前面提到过,我们要在 oldChildren 列表中寻找对应的旧子节点。如果没有找到,那就说明本次循环的新子节点是新增节点,需要执行创建子节点的操作,并将本次新创建的子节点插入到所有未处理节点(未处理就是还没有进行任何更新操作的节点)的前面,也就是下图中虚线指向的位置。成功插入 DOM 后,这一轮循环就结束了。

    newNode

    这里插入所有未处理节点之前是有原因的,不妨思考一下如果是插入到所有已处理的节点之后,若某次操作中有两个新增节点,那么第二个新增节点就会插入到在前一个新增节点的前面,显然这是不可取的。

  2. 更新子节点

    如果新旧两个字节点是同一个节点且位置相同,那么我们就进行更新节点的操作,具体内容在上一届提到过。

  3. 移动子节点

    移动子节点发生在两个子节点是同一节点旦位置不同的情况下,通常用 Node.insertBefore 来将一个节点移动到另一个位置。那怎么找到新虚拟节点的位置呢?和创建子节点的情况类似,只需要将需要移动的节点移动到所有未处理的节点前面。

  4. 删除子节点

    删除子节点,本质上就是删除那些出现在 oldChildren 但不在 newChildren 中的子节点。所以,当我们把 newChildren 遍历完一边之后,oldChildren 中还没有被处理的剩余节点就是需要删除的节点。

优化策略

通常情况下,并不是所有子节点位置都会发生移动,所以通过循环来查找是很费时间的。假设有一个场景,我们只是修改了列表中某一项的内容,没有新增和删除,在这种情况下每个新旧子节点的位置都是相同的,是可预测的。 Vue.js 针对这种情况做出了优化:先尝试使用相同位置的两个节点来比对是否是同一个节点,如果是就进入更新节点的操作;尝试失败之后再用循环来查找节点,下面我们来详细了解下这种策略:

在优化后的快速查找中,首先要设置四个锚点分别是: oldStartIdx, oldEndIdx, newStartIdx, newEndIdx,与之下相对应的子节点为 oldStartVnode, oldEndVnode, newStartVnode, newEndVnode,它们分别指向oldChildrennewChildren 中未处理节点的第一个和最后一个节点。

当锚点满足 oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx 这个条件时,我们循环进行一系列节点之间的比较:

  1. 快捷查找

    先对上面四个指定的节点进行优先查找:

    • 判断 oldStartVnode 是否为空,如果是,则 oldStartIdx 向后移动
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } 
      
    • 判断 oldEndVnode 是否为空,如果是,则 oldEndIdx 向前移动
      else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
      } 
      
    • 判断 oldStartVnode 与 newStartVnode 是否是同一节点,如果是,则更新节点并移动锚点位置
      else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      }
      
    • 判断 oldEndVnode 和 newEndVnode 是否是同一节点,如果是,则更新节点并移动锚点位置
      else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      }
      
    - 判断 oldStartVnode和 newEndVnode 是否是同一节点,如果是,则更新节点,并将对应的 DOM 节点移动至真实节点列表的最后
      ```
      else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } 
      ```
    - 判断 oldEndVnode 和 newStartVnode 是否为同一节点,如果是,则更新节点,并将对应的 DOM 节点移动至所有未处理的节点之前
      ```
      else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      }
      ```
    
    这一系列的快速查找之后,一些不需要移动的节点得到了快速处理,且减少了待处理节点列表,缩小了后续的查找范围。
    
    
  2. 循环查找

    若节点不满足上面任一条件,则需要进入到循环查找阶段:

    else {
      // oldKeyToIdx 为 oldChildren 中 key 到 index 的映射
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      if (isUndef(idxInOld)) { // 在 oldChildren 中找不到对应子节点
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
      } else {
        vnodeToMove = oldCh[idxInOld]
        // 找到了对应子节点,先判断是否为同一节点
        if (sameVnode(vnodeToMove, newStartVnode)) {
          // 若相同,则做更新操作,将 oldChildren 中节点置空,DOM 移动至所有未处理节点的前面
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
          oldCh[idxInOld] = undefined
          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]
    }
    

最后当循环结束后,也就是说 oldStartIdx > oldEndIdx && newStartIdx > newEndIdx 至少存在一个条件不满足的情况下,我们需要进行收尾的新增和删除操作:

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)
}

下面具体看一个例子来分析一下它的子节点列表更新过程:

  1. 初始化过后,循环第一步发现 oldStartVnode和 newEndVnode 为同一节点,则更新锚点,并在真实 DOM 中将 A 移动至队伍的最后

step1
2. 第二次循环,先快速查找,查找失败后,进入循环查找,依旧找不到对应节点,那么可以判断节点 E 是新增节点。此处先创建节点,并插入到 oldStartVnode 节点前,再次更新锚点。

step2
3. 第三次循环,在循环查找中找到了 C 的对应节点,此处需要移动节点位置,将 C 移动至 oldStartVnode 节点前,再次更新锚点并将 oldChildren 中对应节点置空。

step3
4. 第四次循环,此时 newStartVnode 指向了 F,且 newStartIdx 和 newEndIdx 指向同一位置了。而在旧子节点列表中我们找不到对应的节点,所以这是一个新增节点,同样创建节点后也将其插入到 oldStartVnode 节点前,并再次更新锚点。锚点更新后,由于 newStartIdx > newEndIdx,循环退出。

step4

  1. 最后进入循环后的收尾处理,从 oldStartIdx 到 oldEndIdx 遍历 oldChildren,并删除对应的节点。

step5

到此整个子节点更新过程就完成了。

本系列文章均是深入浅出 Vue.js的学习笔记,有兴趣的小伙伴可以去看书哈。