Vue2.5源码阅读笔记02—虚拟DOM的创建与渲染 【原创】

Vue2.5源码阅读笔记02—虚拟DOM的创建与渲染

[TOC]

Write By CS逍遥剑仙
我的主页: www.csxiaoyao.com
GitHub: github.com/csxiaoyaojianxian
Email: sunjianfeng@csxiaoyao.com
QQ: 1724338257

1. 数据驱动与虚拟DOM

Vue是数据驱动的MVVM框架,视图是由数据驱动生成的,因此对视图的修改不是通过操作 DOM,而是通过修改数据,相比传统使用jQuery的前端开发,能够大大简化代码量,尤其在交互逻辑复杂的情况下,减少DOM操作,直接操作数据会让代码的逻辑变的非常清晰、利于维护。

真实DOM存储的节点信息非常多,频繁的DOM操作会带来明显的性能问题,虚拟DOM能有效解决性能问题。在Vue中,虚拟DOM由Vue中$mount实例方法调用mountComponent 函数生成,vm._render负责创建虚拟DOM,vm._update负责渲染虚拟DOM。

2. 虚拟DOM渲染流程

虚拟DOM的渲染是按照下面的流程运行的,后面会详细介绍。

(1) new Vue ==> (2) init ==> (3) $mount ==> (4) compile ==> (5) render ==> (6) vnode ==> (7) patch ==> (8) DOM

3. Vue实例挂载

Vue通过 $mount 实例方法挂载 vm$mount 方法的实现和平台、构建方式都相关,因此在项目中有多处实现,其中,带 compiler 版本的 $mount 可以在浏览器中使用,有利于对源码进行调试分析,作为学习,应该从带 compiler 的版本入手,具体的实现在 src/platform/web/entry-runtime-with-compiler.js 中。

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)

  /* istanbul ignore if */
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    )
    return this
  }

  const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) {
    ...
      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
    ...
  }
  return mount.call(this, el, hydrating)
}

首先对原型上的 $mount 方法进行缓存,目的是为了重新定义该方法,以便在 $mount 方法执行前执行平台差异的代码。以web平台为例,传入两个参数:elhydrating(服务端渲染相关,此处无需传入),重新定义的$mount执行了一些平台相关的额外操作,首先限制 el 不能为 bodyhtml 这类根节点,接着,检查是否有 render 方法,如果没有则会把 el 或者 template 字符串转换成 render 方法,最后调用 compileToFunctions 方法实现render在线编译。

原型上的 $mount 方法在 src/platform/web/runtime/index.js 中定义,$mount 方法实际会调用定义在 src/core/instance/lifecycle.js 中的 mountComponent 方法进行挂载。

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
    ...
  }
  callHook(vm, 'beforeMount')

  let updateComponent
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    updateComponent = () => {
      const name = vm._name
      const id = vm._uid
      const startTag = `vue-perf-start:${id}`
      const endTag = `vue-perf-end:${id}`

      mark(startTag)
      const vnode = vm._render()
      mark(endTag)
      measure(`vue ${name} render`, startTag, endTag)

      mark(startTag)
      vm._update(vnode, hydrating)
      mark(endTag)
      measure(`vue ${name} patch`, startTag, endTag)
    }
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

mountComponent 先调用 vm._render 方法先生成虚拟 Node,再实例化一个Watcher,由此看出,渲染最核心的 2 个方法:vm._rendervm._update

4. vm._render创建VDOM

Vue 的 _render 方法是实例的一个私有方法,可以把实例渲染成一个虚拟 Node,定义在 src/core/instance/render.js 中。平时开发工作中很少手写 render ,大多是写 template 模板,在上面的 mounted 方法中会把 template 编译成 render 方法。VDOM是由VNODE组成的树形结构,_render 函数中创建VNODE的实现是通过调用 createElement方法,定义在 src/core/vdom/create-elemenet.js 中

4.1 createElement创建VNODE

Virtual DOM 的节点定义的描述在 src/core/vdom/vnode.js 中,vnode.js 详细描述了VNODE的结构,比真实DOM结构简化了很多,Vue 的 Virtual DOM 是借鉴了开源库 snabbdom 的实现。除自身的数据结构的定义,映射到真实 DOM 要经历 VNode 的 create、diff、patch 等过程。

VNode 的创建通过 createElement 方法创建,定义在 src/core/vdom/create-elemenet.js 中。

export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  return _createElement(context, tag, data, children, normalizationType)
}

createElement 方法的最后调用了 _createElement 私有方法。

exporte  function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  ...    
}

_createElement 方法有 5 个参数,context 表示 VNode 的上下文环境;tag 表示标签;data 表示 VNode 的数据,它是一个 VNodeData 类型,定义在 flow/vnode.js 中;children 表示当前 VNode 的子节点,将会被规范为标准的 VNode 数组;normalizationType 表示子节点规范的类型,类型不同规范的方法不同,由 render 函数是编译生成的还是用户手写决定。createElement 中最关键的两个流程是 normalizeChildren 和 VNODE 创建。

4.2 normalizeChildren子节点规范化

Virtual DOM 是树状结构,每一个 VNode 可能会有若干个子节点,并且这些子节点也为 VNode 类型,因此需要在 createElement 过程中将传入的 any 类型的 children 参数规范化为 VNODE。

_createElement 会根据传入的 normalizationType 参数的不同,分别调用 normalizeChildren(children)simpleNormalizeChildren(children) 方法,二者都定义在 src/core/vdom/helpers/normalzie-children.js 中。simpleNormalizeChildren 是当render 由函数是编译生成时调用,大部分编译生成的 children 已是 VNode 类型的,除了 functional component 函数式组件返回的是一个数组而不是一个根节点,所以需要通过 Array.prototype.concat 方法把 children 数组变成深度只有一层的一维数组。normalizeChildren 方法存在两种调用场景,一是 render 函数由用户手写,当 children只有一个节点时,Vue调用 createTextVNode 创建一个文本节点的 VNode;另一场景是当编译 slotv-for 的时候会产生嵌套数组的情况,会调用 normalizeArrayChildren 方法进行处理。

export function simpleNormalizeChildren (children: any) {
  for (let i = 0; i < children.length; i++) {
    if (Array.isArray(children[i])) {
      return Array.prototype.concat.apply([], children)
    }
  }
  return children
}
export function normalizeChildren (children: any): ?Array<VNode> {
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}

4.3 VNODE创建

规范化 children 后便可以创建 VNode 的实例。如果是内置节点,则直接创建普通 VNode,如果是为已注册的组件名,则通过 createComponent 创建一个组件类型的 VNode,否则创建一个未知的标签的 VNode。

let vnode, ns
if (typeof tag === 'string') {
  let Ctor
  ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
  if (config.isReservedTag(tag)) {
    vnode = new VNode(
      config.parsePlatformTagName(tag), data, children,
      undefined, undefined, context
    )
  } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
    vnode = createComponent(Ctor, data, context, children, tag)
  } else {
    vnode = new VNode(
      tag, data, children,
      undefined, undefined, context
    )
  }
} else {
  vnode = createComponent(tag, data, context, children)
}

5. vm._update渲染VDOM

Vue 的 _update 是实例的私有方法,它只在首次渲染和数据更新两种情况下被调用,_update 方法把 VNode 渲染成真实的 DOM,定义在 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 prevActiveInstance = activeInstance
  activeInstance = vm
  vm._vnode = vnode
  if (!prevVnode) {
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  activeInstance = prevActiveInstance
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
}

_update 的核心是调用 vm.__patch__ 方法,定义在 src/platforms/web/runtime/index.js 中,不同平台的定义不同,浏览器端渲染的 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'
const modules = platformModules.concat(baseModules)
export const patch: Function = createPatchFunction({ nodeOps, modules })

patch 方法的定义是调用 createPatchFunction 方法的返回值,传入 nodeOps 参数和 modules 参数。其中,nodeOps 封装了一系列 DOM 操作方法,modules 定义了一些模块的钩子函数的实现。

vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)

首次渲染执行 patch 函数的时候,传入的 vm.$el 是例子中形如<div id="app">的 DOM 对象, vm.$el 的赋值在之前 mountComponent 函数中完成,vnode 是调用 render 函数的返回值,hydrating 在非服务端渲染时为 false,removeOnly 为 false。patch的关键操作如下:

const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
  patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
} else {
  if (isRealElement) {
    if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
      oldVnode.removeAttribute(SSR_ATTR)
      hydrating = true
    }
    ...  
    oldVnode = emptyNodeAt(oldVnode)
  }
  const oldElm = oldVnode.elm
  const parentElm = nodeOps.parentNode(oldElm)
  createElm(
    vnode,
    insertedVnodeQueue,
    oldElm._leaveCb ? null : parentElm,
    nodeOps.nextSibling(oldElm)
  )
}

emptyNodeAt 方法把 oldVnode 转换成 VNode 对象,然后再调用 createElm 方法。createElm 的作用是通过虚拟节点创建真实的 DOM 并插入到它的父节点中。 对于创建真实DOM子元素,调用了createChildren方法。

function createChildren (vnode, children, insertedVnodeQueue) {
  if (Array.isArray(children)) {
    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(children)
    }
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
    }
  } else if (isPrimitive(vnode.text)) {
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
  }
}

createChildren 遍历子虚拟节点,递归调用 createElm实现深度优先遍历。接着再调用 invokeCreateHooks 方法执行所有的 create 的钩子并把 vnode push 到 insertedVnodeQueue 队列中。

function invokeCreateHooks (vnode, insertedVnodeQueue) {
  for (let i = 0; i < cbs.create.length; ++i) {
    cbs.create[i](emptyNode, vnode)
  }
  i = vnode.data.hook // Reuse variable
  if (isDef(i)) {
    if (isDef(i.create)) i.create(emptyNode, vnode)
    if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
  }
}

最后调用 insert 方法把 DOM 插入到父节点中,因为是递归调用,子元素会优先调用 insertinsert方法定义在 src/core/vdom/patch.js 上,最终使用原生DOM操作进行了渲染,实际上整个过程就是递归创建了一个完整的 DOM 树并插入到 Body 上。在 createElm 过程中,如果 vnode 节点不包含 tag,可能是注释或者纯文本节点,可以直接插入到父元素中。

function insert (parent, elm, ref) {
  if (isDef(parent)) {
    if (isDef(ref)) {
      if (ref.parentNode === parent) {
        nodeOps.insertBefore(parent, elm, ref)
      }
    } else {
      nodeOps.appendChild(parent, elm)
    }
  }
}
export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
  parentNode.insertBefore(newNode, referenceNode)
}
export function appendChild (node: Node, child: Node) {
  node.appendChild(child)
}

至此,虚拟DOM渲染为真实DOM。

www.csxiaoyao.com

【By CS逍遥剑仙】 未经允许不得转载:禅林阆苑 » Vue2.5源码阅读笔记02—虚拟DOM的创建与渲染 【原创】

赞 (0) 打赏

评论 0

如果觉得有用就请我喝一杯咖啡吧

支付宝扫一扫打赏

微信扫一扫打赏