avatar
原创2019/04/01

Vue2 虚拟 DOM 原理与 Diff 算法

AI 总结

深入解析 Vue 2.x 虚拟 DOM 的实现机制,包括 VNode 结构定义、createElement 创建过程,以及 patch 函数中 Diff 算法的核心逻辑——同级比较、key 优化、双端对比策略,并结合图示说明新旧节点树的对比与更新过程。

我们知道,当 Vue 模板编译完成后会生成一个 render 函数,该函数内部会通过 createElement 创建虚拟 dom 并返回。

本节将展示 vue 是如何创建虚拟 dom 的,以及如何进行虚拟 dom 的 patch

在开始之前需要先明确下 VNode 的定义:

export default class VNode {
  tag: string | void // 节点标签名
  data: VNodeData | void // 节点数据
  children: ?Array<VNode> // 节点的子节点
  text: string | void // 文本节点的值
  elm: Node | void // 真实的dom节点
  ns: string | void // 命名空间
  context: Component | void // 编译的作用域
  key: string | number | void // 节点的key值
  componentOptions: VNodeComponentOptions | void
  componentInstance: Component | void // 组件的实例
  parent: VNode | void // 父节点
  raw: boolean // contains raw HTML? (server only)
  isStatic: boolean // 是否是静态节点
  isRootInsert: boolean // 是否作为根节点插入
  isComment: boolean // 注释节点
  isCloned: boolean // 克隆节点
  isOnce: boolean // v-once指令节点
  asyncFactory: Function | void // async component factory function
  asyncMeta: Object | void
  isAsyncPlaceholder: boolean
  ssrContext: Object | void
  fnContext: Component | void // real context vm for functional nodes
  fnOptions: ?ComponentOptions // for SSR caching
  devtoolsMeta: ?Object // used to store functional render context for devtools
  fnScopeId: ?string // functional scope id support
}

VNode 对象就是虚拟 DOM 的节点对象,其内部属性很好的描述了一个 HTML 标签所包含的内容。

下面介绍下 Vue 是如何创建虚拟 DOM 的

创建虚拟 DOM

const SIMPLE_NORMALIZE = 1
const ALWAYS_NORMALIZE = 2
 
function createElement(
  context,
  tag,
  data,
  children,
  normalizationType,
  alwaysNormalize
) {
  // 兼容不传data的情况
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  // 如果alwaysNormalize是true
  // 那么normalizationType应该设置为常量ALWAYS_NORMALIZE的值
  if (alwaysNormalize) normalizationType = ALWAYS_NORMALIZE
  // 调用_createElement创建虚拟节点
  return _createElement(context, tag, data, children, normalizationType)
}
 
function _createElement(context, tag, data, children, normalizationType) {
  /**
   * 如果存在data.__ob__,说明data是被Observer观察的数据
   * 不能用作虚拟节点的data
   * 需要抛出警告,并返回一个空节点
   *
   * 被监控的data不能被用作vnode渲染的数据的原因是:
   * data在vnode渲染过程中可能会被改变,这样会触发监控,导致不符合预期的操作
   */
  if (data && data.__ob__) {
    process.env.NODE_ENV !== 'production' &&
      warn(
        `Avoid using observed data object as vnode data: ${JSON.stringify(
          data
        )}\\n` + 'Always create fresh vnode data objects in each render!',
        context
      )
    return createEmptyVNode()
  }
  // 当组件的is属性被设置为一个falsy的值
  // Vue将不会知道要把这个组件渲染成什么
  // 所以渲染一个空节点
  if (!tag) {
    return createEmptyVNode()
  }
  // 作用域插槽
  if (Array.isArray(children) && typeof children[0] === 'function') {
    data = data || {}
    data.scopedSlots = { default: children[0] }
    children.length = 0
  }
  // 根据normalizationType的值,选择不同的处理方法
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  let vnode, ns
  // 如果标签名是字符串类型
  if (typeof tag === 'string') {
    let Ctor
    // 获取标签名的命名空间
    ns = config.getTagNamespace(tag)
    // 判断是否为保留标签
    if (config.isReservedTag(tag)) {
      // 如果是保留标签,就创建一个这样的vnode
      vnode = new VNode(
        config.parsePlatformTagName(tag),
        data,
        children,
        undefined,
        undefined,
        context
      )
      // 如果不是保留标签,那么我们将尝试从vm的components上查找是否有这个标签的定义
    } else if ((Ctor = resolveAsset(context.$options, 'components', tag))) {
      // 如果找到了这个标签的定义,就以此创建虚拟组件节点
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // 兜底方案,正常创建一个vnode
      vnode = new VNode(tag, data, children, undefined, undefined, context)
    }
    // 当tag不是字符串的时候,我们认为tag是组件的构造类
    // 所以直接创建
  } else {
    vnode = createComponent(tag, data, context, children)
  }
  // 如果有vnode
  if (vnode) {
    // 如果有namespace,就应用下namespace,然后返回vnode
    if (ns) applyNS(vnode, ns)
    return vnode
    // 否则,返回一个空节点
  } else {
    return createEmptyVNode()
  }
}

当首次渲染时,会创建 Watcher 实例

new Watcher(
  vm,
  updateComponent,
  noop,
  {
    before() {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  },
  true /* isRenderWatcher */
)

updateComponent 首先会执行 render 方法得到 VNode ,之后执行_update方法将虚拟 Dom 渲染成真实DOM

虚拟 DOM 生成真实 DOM

渲染成真实DOM的 _update 方法定义在src/core/instance/lifecycle.js文件中

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  const prevEl = vm.$el
  const prevVnode = vm._vnode
  const restoreActiveInstance = setActiveInstance(vm)
  vm._vnode = vnode
  // 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 的核心就是调用 vm.__patch__ 方法。其定义在src/platforms/web/runtime/patch.js

import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'
 
// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)
 
export const patch: Function = createPatchFunction({ nodeOps, modules })

该方法的定义是调用 createPatchFunction 方法的返回值,这里传入了一个对象,包含 nodeOps 参数和 modules 参数。其中,nodeOps 封装了一系列 DOM 操作的方法,modules 定义了一些模块的钩子函数的实现。而createPatchFunction定义在 src/core/vdom/patch.js 中:

const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
 
export function createPatchFunction(backend) {
  let i, j
  const cbs = {}
 
  const { modules, nodeOps } = backend
 
  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      if (isDef(modules[j][hooks[i]])) {
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }
 
  // ...
 
  return function patch(oldVnode, vnode, hydrating, removeOnly) {
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }
 
    let isInitialPatch = false
    const insertedVnodeQueue = []
 
    if (isUndef(oldVnode)) {
      // empty mount (likely as component), create new root element
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } else {
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, 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 (process.env.NODE_ENV !== 'production') {
              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(parentElm, [oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }
 
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }
}

createPatchFunction 最终会返回了一个 patch 方法,这个方法就赋值给了 vm._update 函数里调用的 vm.patch

patch方法接收四个参数

  • oldVnode: 旧的虚拟节点或旧的真实 dom 节点
  • vnode: 新的虚拟节点
  • hydrating: 是否是服务端渲染
  • removeOnly: 用于 <transition-group> 组件

根据源码可以知道 patch 的过程

  • 如果vnode不存在且oldVnode存在,则说明要是销毁oldVnode
  • 如果vnode存在但oldVnode不存在,则说明需要创建新的节点直接调用createElm
  • 如果vnode和oldVnode同时存在
    • oldVnode和vnode指向同一个对象且oldVnode不是真实的 dom 节点,则调用 patchVnode
    • oldVnode和vnode不是同一个对象,如果oldVnode是真实 dom 节点且hydrating为 true,则调用hydrate函数将虚拟 dom 和真是 dom 进行映射
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
  invokeInsertHook(vnode, insertedVnodeQueue, true)
  return oldVnode
} else if (process.env.NODE_ENV !== 'production') {
  warn(...)
}

明确流程后,就可以看看真正的patch算法,理解oldVnode和vnode如何去对比的。该算法流程定义在patchVnode函数中

patch算法:

  • oldVnode和vnode相同,则直接返回
  • oldVnode和vnode都是静态节点,且key相同,且当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elm和oldVnode.child都复制到 vnode 上
  • 否则,如果vnode不是文本节点或注释节点
    • 如果oldVnode和vnode都有子节点,且他们的子节点指向的对象不一致,就执行更新子节点的操作,即执行 updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      • 分别获取 oldVnode和 vnode 的 firstChild、lastChild,赋值给 oldStartVnode、oldEndVnode、newStartVnode、newEndVnode
      • 如果oldStartVnode和newStartVnode是同一节点,调用patchVnode进行patch,并将oldStartVnode和newStartVnode都设置为下一个子节点,重复上述流程
      • 如果oldEndVnode和newEndVnode是同一节点,调用patchVnode进行patch,并将oldEndVnode和newEndVnode都设置为上一个子节点,重复上述流程
      • 如果oldStartVnode和newEndVnode是同一节点,调用patchVnode进行patch,如果removeOnly是false,那么可以把oldStartVnode.elm移动到oldEndVnode.elm之后,然后把oldStartVnode设置为下一个节点,newEndVnode设置为上一个节点,重复上述流程
      • 如果newStartVnode和oldEndVnode是同一节点,调用patchVnode进行patch,如果removeOnly是false,那么可以把oldEndVnode.elm移动到oldStartVnode.elm之前,然后把newStartVnode设置为下一个节点,oldEndVnode设置为上一个节点,重复上述流程
      • 如果以上都不匹配,就尝试在oldChildren中寻找跟newStartVnode具有相同key的节点,如果找不到相同key的节点,说明newStartVnode是一个新节点,就创建一个,然后把newStartVnode设置为下一个节点
      • 如果上一步找到了跟newStartVnode相同key的节点,那么通过其他属性的比较来判断这 2 个节点是否是同一个节点,如果是,就调用patchVnode进行patch,如果removeOnly是false,就把newStartVnode.elm插入到oldStartVnode.elm之前,把newStartVnode设置为下一个节点,重复上述流程
      • 如果在oldChildren中没有寻找到newStartVnode的同一节点,那就创建一个新节点,把newStartVnode设置为下一个节点,重复上述流程
      • 如果oldStartVnode跟oldEndVnode重合了,并且newStartVnode跟newEndVnode也重合了,这个循环就结束了
    • 如果只有oldVnode有子节点,那就把这些节点都删除
    • 如果只有vnode有子节点,那就创建这些子节点
    • 如果oldVnode和vnode都没有子节点,但是oldVnode是文本节点或注释节点,就把vnode.elm的文本设置为空字符串
  • 如果vnode文本节点或注释节点,但是vnode.text != oldVnode.text,只需要更新node.elm文本内容就可以

以上便是 dom 更新的流程中

点击播放