vue源码解读二:数据驱动之生成虚拟dom(vnode)

244 阅读7分钟

本文主要内容摘抄自黄轶老师的慕课网课程Vue.js 源码全方位深入解析 全面深入理解Vue实现原理,主要用于个人学习和复习,不用作其他用途。

数据驱动

Vue.js 一个核心思想是数据驱动。所谓数据驱动,是指视图是由数据驱动生成的,我们对视图的修改,不会直接操作 DOM,而是通过修改数据

它相比我们传统的前端开发,如使用 jQuery 等前端库直接修改 DOM,大大简化了代码量。特别是当交互复杂的时候,只关心数据的修改会让代码的逻辑变的非常清晰,因为 DOM 变成了数据的映射,我们所有的逻辑都是对数据的修改,而不用碰触 DOM,这样的代码非常利于维护。

在 Vue.js 中我们可以采用简洁的模板语法来声明式的将数据渲染为 DOM:

<div id="app">
  {{ message }}
</div>

var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!'
  }
})

最终它会在页面上渲染出 Hello Vue,接下来,我们会从源码角度来分析 Vue 是如何实现的。

new Vue 发生了什么

首先来看一下Vue的函数类,源码在src/core/instance/index.js 中。

function Vue (options) {
  this._init(options)
}

可以看到 Vue 只能通过 new 关键字初始化,然后会调用 this._init 方法, 该方法在 src/core/instance/init.js 中定义。

Vue.prototype._init = function (options?: Object) {
  const vm: Component = this
  ...  
  // 合并配置,并在vm上挂载$options
  vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor),
      options || {},
      vm
  )
  ...
  // 进行一系列的初始化
  initLifecycle(vm)
  initEvents(vm)
  initRender(vm)
  callHook(vm, 'beforeCreate')
  initInjections(vm)
  // 把data变成响应式
  initState(vm) 
  initProvide(vm)
  callHook(vm, 'created')
  ...
  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }
}

这里重点分析下initStateinitRender做了什么事情:

initState

export function initState(vm: Component) {
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe((vm._data = {}), true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

initData代码如下:

function initData(vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function' ? getData(data, vm) : data || {}

  // 因为data,props和methods都会挂载在vm上,所以需要判断他们不能重名
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    ...
    // 代理,访问this.message 就是访问this._data.message
    proxy(vm, `_data`, key)
    ...
  }
  // 把data变为响应式data
  observe(data, true /* asRootData */)
}

export function proxy(target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter() {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter(val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

我们把 data 挂载在 vm._data 上,但是每次访问data里面的属性,就会这样这访问this._data.message,这样比较麻烦,而且下划线开头说明是一个私有属性,不应该被外面所访问,所以后面利用proxy做了一层代理,当你访问this.message其实就访问了this._data.message

initRender

export function initRender (vm: Component) {
  ...
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
  ...
}

这个方法的功能是在vm上挂载了_c$createElement两个方法,这两个方法的作用创建vnode

从上面可以看出,初始化就是往实例上挂载各种属性和方法。比如在vm上挂载了$options_data,以及方法$createElement等等。

Vue 的初始化逻辑写的非常清楚,把不同的功能逻辑拆成一些单独的函数执行,让主线逻辑一目了然,这样的编程思想是非常值得借鉴和学习的

在初始化的最后,检测到如果有 el 属性,则调用 vm.$mount 方法挂载 vm,挂载的目标就是把模板渲染成最终的 DOM,那么接下来我们来分析 Vue 的挂载过程。

Vue 实例挂载的实现

Vue 中我们是通过 $mount 实例方法去挂载 vm 的,$mount 方法在多个文件中都有定义,如 src/platform/web/entry-runtime-with-compiler.jssrc/platform/web/runtime/index.jssrc/platform/weex/runtime/index.js

因为 $mount 这个方法的实现是和平台、构建方式都相关的。接下来重点分析带 compiler 版本的 $mount 实现,因为抛开 webpack 的 vue-loader,我们在纯前端浏览器环境分析 Vue 的工作原理,有助于我们对原理理解的深入。

compiler 版本的 $mount 实现非常有意思,先来看一下 src/platform/web/entry-runtime-with-compiler.js 文件中定义:

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 传入一个id,通过id获取的dom对象,记住是真实dom query:document.querySelector(el)
  el = el && query(el)
  
  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
  // 如果options中没有render函数
  if (!options.render) {
    let template = options.template
    if (template) {
      ...
      // 获取模板字符串
      template = template.innerHTML
      ...
    } else if (el) {
      // 如果没有template属性,那么就从el中获取dom的字符串  
      template = getOuterHTML(el)
    }
    if (template) {
      // 把模板字符串转化为render函数
      const { render, staticRenderFns } = compileToFunctions(template...)
      // 把render函数放在options对象上
      options.render = render
      options.staticRenderFns = staticRenderFns
    }
  }
  return mount.call(this, el, hydrating)
}

这段代码首先缓存了原型上的 $mount 方法,再重新定义该方法,我们先来分析这段代码。

  • 首先,它对 el 做了限制,Vue 不能挂载在 bodyhtml 这样的根节点上。

  • 接下来的是很关键的逻辑,如果没有定义 render 方法,则会把 el 或者 template 字符串转换成 render 方法。这里我们要牢记,在 Vue 2.0 版本中,所有 Vue 的组件的渲染最终都需要 render 方法,无论我们是用单文件 .vue 方式开发组件,还是写了 el 或者 template 属性,最终都会转换成 render 方法,那么这个过程是 Vue 的一个在线编译的过程,它是调用 compileToFunctions 方法实现的,编译过程我们之后会介绍。

  • 最后,调用原先原型上的 $mount 方法挂载。

原先原型上的 $mount 方法在 src/platform/web/runtime/index.js 中定义,之所以这么设计完全是为了复用,因为它是可以被 runtime only 版本的 Vue 直接使用的。

Vue.prototype.$mount = function (el?: string | Element, hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

$mount 方法实际上会去调用 mountComponent 方法,这个方法定义在 src/core/instance/lifecycle.js 文件中:

export function mountComponent (vm: Component, el: ?Element): Component {
  vm.$el = el
  ...
  callHook(vm, 'beforeMount')
  ...
  let updateComponent
  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 核心就是先实例化一个渲染Watcher,在它的实例化中会调用 updateComponent 方法,在此方法中调用 vm._render 方法先生成虚拟 Node,最终调用 vm._update 更新 DOM。

Watcher 在这里起到两个作用,一个是初始化的时候会执行回调函数,另一个是当 vm 实例中的监测的数据发生变化的时候执行回调函数,这块儿会在之后的章节中介绍。

函数最后判断为根节点的时候设置 vm._isMounted 为 true, 表示这个实例已经挂载了,同时执行 mounted 钩子函数。 这里注意 vm.$vnode 表示 Vue 实例的父虚拟 Node,所以它为 Null 则表示当前是根 Vue 的实例。

render 生成虚拟dom

Virtual DOM

Virtual DOM 这个概念相信大部分人都不会陌生,它产生的前提是浏览器中的 DOM 是很"昂贵"的,为了更直观的感受,我们可以简单的把一个简单的 div 元素的属性都打印出来,如图所示:

image.png

可以看到,真正的 DOM 元素是非常庞大的,因为浏览器的标准就把 DOM 设计的非常复杂。当我们频繁的去做 DOM 更新,会产生一定的性能问题。

而 Virtual DOM 就是用一个原生的 JS 对象去描述一个 DOM 节点,所以它比创建一个 DOM 的代价要小很多。在 Vue.js 中,Virtual DOM 是用 VNode 这么一个 Class 去描述,它是定义在 src/core/vdom/vnode.js 中的。

export default class VNode {
  constructor (tag?: string, data?: VNodeData, children?: ?Array<VNode>, text?: string, elm?: Node, context?: Component ...
  ) {
    this.tag = tag
    this.data = data // 标签属性
    this.children = children
    this.text = text
    this.elm = elm // 虚拟dom所对应的真实dom节点
    this.ns = undefined
    this.context = context
    this.fnContext = undefined
    ...
  }
  get child (): Component | void {
    return this.componentInstance
  }
}

其实 VNode 是对真实 DOM 的一种抽象描述,它的核心定义无非就几个关键属性,标签名、数据、子节点、键值等,其它属性都是用来扩展 VNode 的灵活性以及实现一些特殊 feature 的。由于 VNode 只是用来映射到真实 DOM 的渲染,不需要包含操作 DOM 的方法,因此它是非常轻量和简单的

Virtual DOM 除了它的数据结构的定义,映射到真实的 DOM 实际上要经历 VNode 的 create、diff、patch 等过程。那么在 Vue.js 中,VNode 的 create 是通过之前提到的 vm._render 方法创建的,我们接下来分析这部分的实现。

render

Vue 的 _render 方法是实例的一个私有方法,它用来把实例渲染成一个虚拟 Node。它的定义在 src/core/instance/render.js 文件中:

Vue.prototype._render = function (): VNode {
  const vm: Component = this
  const { render, _parentVnode } = vm.$options
  ...
  let vnode
  try {
    vnode = render.call(vm._renderProxy, vm.$createElement)
  } catch (e) {
    ...
  }
  ...
  return vnode
}

这段代码最关键的是 render 方法的调用,我们在平时的开发工作中手写 render 方法的场景比较少,而写的比较多的是 template 模板,在之前的 mounted 方法的实现中,会把 template 编译成 render 方法。

下面是我们手写的一个render方法,在render方法中传入了一个参数函数createElement,这个参数就是对应上面的vm.$createElement函数。

new Vue({
  el: '#app',
  data() {
    return {
      message: 'hello vue'
    }
  },
  render(createElement) {
    return createElement(
      'div',
      {
        attrs: {
          id: 'app1'
        }
      },
      this.message
    )
  }
})

vm.$createElement函数代码如下:

vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };

Vue.js 利用 createElement 方法创建 VNode,它定义在 src/core/vdom/create-element.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 方法的封装,它允许传入的参数更加灵活,在处理这些参数后,调用真正创建 VNode 的函数 _createElement

因为我们可以不传createElementdata(标签属性,一个对象),所以需要对参数做一个处理,比如如果data不传,那么data就是children。

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  ...
  // 如果没有传tag,则创建一个空vnode
  if (!tag) {
    return createEmptyVNode();
  }
  // `normalizationType` 表示子节点规范的类型,类型不同规范的方法也就不一样,它主要是参考 `render` 函数是编译生成的还是用户手写的。
  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 = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    // 如果是平台保留的标签名,比如浏览器环境下的div标签
    if (config.isReservedTag(tag)) {
      // 生成vnode
      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 {
    // 如果tag不是字符串,说明是一个组件
    vnode = createComponent(tag, data, context, children)
  }
  if (Array.isArray(vnode)) {
    return vnode
  } else if (isDef(vnode)) {
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    return createEmptyVNode()
  }
}

我们以如下代码来分析_createElement的运行过程,下面代码中的children有两个子节点,一个是文本节点,一个是继续调用_createElement生成一个vnode

new Vue({
  el: '#app',
  data() {
    return {
      message: 'hello vue'
    }
  },
  render(createElement) {
    return createElement(
      'div',
      {
        attrs: {
          id: 'app1'
        }
      },
      [
          createElement('div', 'pengchangjun'), 
          this.message
      ]
    )
  }
})

当执行这个代码的时候,首先调用children里面的createElement,然后执行normalizeChildren函数:

export function normalizeChildren (children: any): ?Array<VNode> {
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}

如果children是一个原始类型(字符串),则生成一个文本vnode的数组,此时的children为:

{
    tag: undefined,
    data: undefined,
    children: undefined,
    text: 'pengchangjun',
    ...
  }

如果children是一个数组,那么调用normalizeArrayChildren,此时children为:

[
  {
    // 这是一个VNode对象
    tag: 'div',
    data: undefined,
    text: undefined,
    children: [
      {
        // 这也是一个vnode对象
        tag: undefined,
        data: undefined,
        children: undefined,
        text: 'pengchangjun',
        ...
      }
    ]
  },
  // 这是一个文本
  'hello vue'
]

把这个children传入到normalizeArrayChildren执行

function normalizeArrayChildren (children, nestedIndex) {
  var res = [];
  var i, c, lastIndex, last;
  for (i = 0; i < children.length; i++) {
    c = children[i];
    ...
    //  nested 嵌套数组
    if (Array.isArray(c)) {
      if (c.length > 0) {
        c = normalizeArrayChildren(c, ((nestedIndex || '') + "_" + i));
        ...
        res.push.apply(res, c);
      }
    } else if (isPrimitive(c)) {
        // 如果是原始类型则生成一个文本vnode
        res.push(createTextVNode(c));
    } else {
      // 如果已经是vnode类型,则直接push
      ...
      res.push(c);
    }
  }
  return res
}

返回的res为一个vnode的数组:

[
  {
    // 这是一个VNode对象
    tag: 'div',
    data: undefined,
    text: undefined,
    children: [
      {
        // 这是一个vnode对象
        tag: undefined,
        data: undefined,
        children: undefined,
        text: 'pengchangjun',
        ...
      }
    ]
  },
  // 这是一个文本vnode
  {
    tag: undefined,
    data: undefined,
    children: undefined,
    text: 'hello vue',
    ...
  }
]

生成 vnode 的 children 之后,继续执行如下逻辑:

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

这里先对 tag 做判断,如果是 string 类型,则接着判断如果是内置的一些节点,则直接创建一个普通 VNode,如果是为已注册的组件名,则通过 createComponent 创建一个组件类型的 VNode,否则创建一个未知的标签的 VNode。 如果是 tag 一个 Component 类型,则直接调用 createComponent 创建一个组件类型的 VNode 节点。对于 createComponent 创建组件类型的 VNode 的过程,之后会去介绍,本质上它还是返回了一个 VNode。

通过以上逻辑就生成了一个vnode的树状结构,结构如下:


{
  tag: 'div',
  data: {
    attrs: {
      id: 'app1'
    }
  },
  children: [
    {
      // 这是一个VNode对象
      tag: 'div',
      data: undefined,
      text: undefined,
      children: [
        {
          // 这是一个vnode对象
          tag: undefined,
          data: undefined,
          children: undefined,
          text: 'pengchangjun',
          ...
        }
      ]
    },
    // 这是一个文本vnode
    {
      tag: undefined,
      data: undefined,
      children: undefined,
      text: 'hello vue',
      ...
    }
  ]
  text: undefined,
  elm: undefined
}

那么至此,我们大致了解了 createElement 创建 VNode 的过程,每个 VNode 有 childrenchildren 每个元素也是一个 VNode,这样就形成了一个 VNode Tree,它很好的描述了我们的 DOM Tree。

回到 mountComponent 函数的过程,我们已经知道 vm._render 是如何创建了一个 VNode,接下来就是要把这个 VNode 渲染成一个真实的 DOM 并渲染出来,这个过程是通过 vm._update 完成的,接下来的文章中分析一下这个过程。