虚拟DOM——vue2源码探究(3)

104 阅读6分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第14天,点击查看活动详情

之前提到过,响应性和依赖收集使得数据变化后可以通知到所有相关依赖进行刷新,在Vue中,最重要也是使用最多的一个场景就是数据的变化引起页面视图的变化了,也就是MVVM模式。因此,视图层需要在数据变化接收到通知后进行刷新,因此,虚拟DOM开始登场。

虚拟DOM是什么

Vue2.0最大的优化之一便是视图层使用虚拟DOM代替了正则替换,减少了视图渲染中的过多资源消耗,提高了效率。虚拟DOM可以理解为使用一段JS代码来描述DOM节点,比如模板中的:

<div>
  <!-- 注释节点 -->
  文本节点
  <div>元素节点</div>
  <component data="parentData"></component>
</div>

会以这样的方式编译:

import { createCommentVNode as _createCommentVNode, createElementVNode as _createElementVNode, resolveComponent as _resolveComponent, createVNode as _createVNode, createTextVNode as _createTextVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_component = _resolveComponent("component")

  return (_openBlock(), _createElementBlock("div", null, [
    _createCommentVNode(" 注释节点 "),
    _createTextVNode(" 文本节点 "),
    _createElementVNode("div", null, "元素节点"),
    _createVNode(_component_component, { data: "parentData" })
  ]))
}

原来模板中的几个节点也就变成了如下四种虚拟节点:

  • 注释节点
  • 文本节点
  • 元素节点
  • 组件节点

此外,还有两种节点,克隆节点(在模板编译优化时使用)以及函数式组件节点(用于使用渲染函数的组件)。但不管是哪种,都是基于基类VNode的属性变化,VNode的定义如下:

// 源码文件:src\core\vdom\vnode.ts
export default class VNode {
  tag?: string
  data: VNodeData | undefined
  children?: Array<VNode> | null
  text?: string
  elm: Node | undefined
  ns?: string
  context?: Component // rendered in this component's scope
  key: string | number | undefined
  componentOptions?: VNodeComponentOptions
  componentInstance?: Component // component instance
  parent: VNode | undefined | null // component placeholder node

  // strictly internal
  raw: boolean // contains raw HTML? (server only)
  isStatic: boolean // hoisted static node
  isRootInsert: boolean // necessary for enter transition check
  isComment: boolean // empty comment placeholder?
  isCloned: boolean // is a cloned node?
  isOnce: boolean // is a v-once node?
  asyncFactory?: Function // async component factory function
  asyncMeta: Object | void
  isAsyncPlaceholder: boolean
  ssrContext?: Object | void
  fnContext: Component | void // real context vm for functional nodes
  fnOptions?: ComponentOptions | null // for SSR caching
  devtoolsMeta?: Object | null // used to store functional render context for devtools
  fnScopeId?: string | null // functional scope id support

  constructor(
    tag?: string,
    data?: VNodeData,
    children?: Array<VNode> | null,
    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
  }

  // DEPRECATED: alias for componentInstance for backwards compat.
  /* istanbul ignore next */
  get child(): Component | void {
    return this.componentInstance
  }
}

虚拟DOM的更新方式

上文提到,之所以Vue2.0中使用虚拟DOM而非正则替换,是因为要减少对全部DOM进行操作而产生的开销,一个DOM节点包含的属性相当庞大:

const div = document.createElement("div")
let keys = [];
for (key in div) {
    keys.push(key)
}
console.log(keys.length); // 结果是307,仅一个空的div节点就包含307个属性

因此,我们需要尽可能的少操作DOM,而少操作DOM的一个解决方案就是,当数据变化时,通过响应性和依赖收集通知到视图层,根据新的数据生成新的虚拟DOM,之后将新旧的虚拟DOM进行比对,最终完成DOM的刷新。

而比对新旧虚拟DOM的过程,被称之为patch,相关方法写在patch.ts文件中。

新建节点

// 源码文件:src\core\vdom\patch.ts
function createElm(
  vnode,
  insertedVnodeQueue,
  parentElm?: any,
  refElm?: any,
  nested?: any,
  ownerArray?: any,
  index?: any
) {
  // 对已经使用过的节点进行克隆避免潜在比对错误
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  vnode.isRootInsert = !nested // for transition enter check
  // 创建组件节点,如果组件创建成功就不会向后执行
  // 这就是为什么如果你不使用slot而在组件中写子组件就不会渲染
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag
  if (isDef(tag)) {
    if (__DEV__) {
      if (data && data.pre) {
        creatingElmInVPre++
      }
      if (isUnknownElement(vnode, creatingElmInVPre)) {
        // 如果自定义组件上方创建不成功会走到这一步
        // 很多初学者忘了引用自定义组件经常会看到这个警告吧
        warn(
          'Unknown custom element: <' +
            tag +
            '> - did you ' +
            'register the component correctly? For recursive components, ' +
            'make sure to provide the "name" option.',
          vnode.context
        )
      }
    }

    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode)
    setScope(vnode)

    // 递归创建子节点
    createChildren(vnode, children, insertedVnodeQueue)
    if (isDef(data)) {
      // 插入生命周期钩子
      invokeCreateHooks(vnode, insertedVnodeQueue)
    }
    insert(parentElm, vnode.elm, refElm)

    if (__DEV__ && data && data.pre) {
      creatingElmInVPre--
    }
  } else if (isTrue(vnode.isComment)) {
    // 创建注释节点
    vnode.elm = nodeOps.createComment(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  } else {
    // 创建文字节点
    vnode.elm = nodeOps.createTextNode(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  }
}

新建节点中主要对节点的类型进行了判断:

  • 如果是组件节点类型则创建自定义组件
  • 如果tag存在则创建元素节点,并通过createChildren方法递归创建子节点
  • 如果是注释节点类型创建注释节点
  • 如果是文本节点类型创建文本节点

删除节点

// 源码文件:src\core\vdom\patch.ts
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 {
        // 文本节点则可以直接移除
        removeNode(ch.elm)
      }
    }
  }
}

删除节点时需要做一个判断,文本节点可以直接移除,元素节点则需要递归调用所有子节点的钩子,但最终仍然需要通过removeNode方法将节点移除:

// 源码文件:src\core\vdom\patch.ts
function removeNode(el) {
  const parent = nodeOps.parentNode(el)
  if (isDef(parent)) {
    nodeOps.removeChild(parent, el)
  }
}

更新节点

// 源码文件:src\core\vdom\patch.ts
function patchVnode(
  oldVnode,
  vnode,
  insertedVnodeQueue,
  ownerArray,
  index,
  removeOnly?: any
) {
  if (oldVnode === vnode) {
    // 新老节点相同,不做任何处理
    return
  }

  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // 克隆重用节点
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  const elm = (vnode.elm = oldVnode.elm)

  // 注水节点,SSR专用
  if (isTrue(oldVnode.isAsyncPlaceholder)) {
    if (isDef(vnode.asyncFactory.resolved)) {
      hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
    } else {
      vnode.isAsyncPlaceholder = true
    }
    return
  }

  // 静态节点处理
  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)) {
      // 新旧子节点都存在时,递归更新子节点
      if (oldCh !== ch)
        updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
    } else if (isDef(ch)) {
      // 新子节点存在,旧子节点不存在时
      if (__DEV__) {
        // 在开发模式下,这里会检查子节点key的重复性
        // 如果有重复就会发出警告,常见于v-for中key重复的情况
        checkDuplicateKeys(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)
  }
}

更新节点会有点复杂,画个流程图可能直观一些:

vue虚拟节点更新流程图.jpg

大方向的的逻辑遵从以下几点:

  • 旧节点的文本要清掉(除非新旧均为文本节点且文本一模一样)
  • 旧节点没有新节点有的子节点可以直接加进去
  • 新旧节点都有的子节点需要继续递归进行比对

值得一提的是checkDuplicateKeys这个方法,在新旧子节点比对且在开发模式下时,它会去检查子节点的key是否重复,一旦发生重复则会出现Duplicate keys detected: xxx. This may cause an update error.的警告,这个警告经常在v-for命令中出现。