Vue源码解析(二):Vue是如何挂载Dom的(render篇)

894 阅读8分钟

上一篇:Vue源码解析(一):Vue 初始化时到底做了什么事情

Vue是如何挂载Dom的

虚拟Dom

在挂载之前首先要了解虚拟Dom的概念。虚拟Dom,顾名思义并不是真实的Dom,而是使用JavaScript的对象来对真实Dom的一个描述。

那为什么要用虚拟Dom呢?

  • 因为浏览器中真实的DOM节点对象上的属性和方法比较多,如果每次都生成新的DOM对象,对性能是一种浪费。而如果用虚拟Dom,当数据更新时,会先比较相应的VNode的数据,然后对虚拟Dom进行创建节点,修改节点和删除节点等操作,最后才更新真实的Dom,可以大大的提升性能。
  • 可以跨平台。

一个真实的Dom也无非是有标签名,属性,子节点等这些来描述它,如页面中的真实Dom是这样的:

<div id='app' class='wrap'>
  <h2>
    hello
  </h2>
</div>

我们可以在render函数内这样描述它:

new Vue({
  render(h) {
    return h('div', {
      attrs: {
        id: 'app',
        class: 'wrap'
      }
    }, [
      h('h2', 'hello')
    ])
  }
})

render函数最终返回的是一个VNode类,也就是上面说的虚拟DOm,找到它定义的地方:

export default class VNode {
  constructor (
    tag
    data
    children
    text
    elm
    context
    componentOptions
    asyncFactory
  ) {
    this.tag = tag  // 标签名
    this.data = data  // 属性 如id/class
    this.children = children  // 子节点
    this.text = text  // 文本内容
    this.elm = elm  // 该VNode对应的真实节点
    this.ns = undefined  // 节点的namespace
    this.context = context  // 该VNode对应实例
    this.fnContext = undefined  // 函数组件的上下文
    this.fnOptions = undefined  // 函数组件的配置
    this.fnScopeId = undefined  // 函数组件的ScopeId
    this.key = data && data.key  // 节点绑定的key 如v-for
    this.componentOptions = componentOptions  //  组件VNode的options
    this.componentInstance = undefined  // 组件的实例
    this.parent = undefined  // vnode组件的占位符节点
    this.raw = false  // 是否为平台标签或文本
    this.isStatic = false  // 静态节点
    this.isRootInsert = true  // 是否作为根节点插入
    this.isComment = false  // 是否是注释节点
    this.isCloned = false  // 是否是克隆节点
    this.isOnce = false  // 是否是v-noce节点
    this.asyncFactory = asyncFactory  // 异步工厂方法
    this.asyncMeta = undefined  //  异步meta
    this.isAsyncPlaceholder = false  // 是否为异步占位符
  }

  get child () {  // 别名
    return this.componentInstance
  }
}

它支持接收8个参数,内置23个属性。很多,看着都吓人,大概知道这些属性是啥意思就行了。

开始挂载阶段

this._init() 方法的最后:

... 初始化

if (vm.$options.el) {
  vm.$mount(vm.$options.el)
}

vm.$mount

如果用户有传入el属性,就执行vm.mount方法并传入el开始挂载。这里的mount方法并传入el开始挂载。这里的mount方法在完整版和运行时版本又会有点不同,他们区别如下:

运行时版本:
Vue.prototype.$mount = function(el) { // 最初的定义
  return mountComponent(this, query(el));
}

完整版:
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function(el) {  // 拓展编译后的

  if(!this.$options.render) {            ---| 这一段主要作用就是把template转换成render函数
    if(this.$options.template) {         ---|
      ...经过编译器转换后得到render函数  ---|   编译阶段
    }                                    ---|
  }                                      ---|
  
  return mount.call(this, query(el))
}

-----------------------------------------------
这里返回的是一个真实的Dom
export function query(el) {  // 获取挂载的节点
  if(typeof el === 'string') {  // 比如#app
    const selected = document.querySelector(el)
    if(!selected) {
      return document.createElement('div')
    }
    return selected
  } else {
    return el
  }
}

tips:
vue.js : vue.js则是直接用在<script>标签中的,完整版本,直接就可以通过script引用。
vue.common.js :预编译调试时,CommonJS规范的格式,可以使用require("")引用的NODEJS格式。
vue.esm.js:预编译调试时, EcmaScript Module(ES MODULE),支持import from 最新标准的。
vue.runtime.js :生产的运行时,需要预编译,比完整版小30%左右,前端性能最优
vue.runtime.esm.js:生产运行时,esm标准。
vue.runtime.common.js:生产运行时,commonJS标准。

这里主要看完整版,有个小操作,首先将$mount方法缓存到mount变量上,然后使用函数劫持的手段重新定义$mount函数,并在其内部增加编译相关的代码,最后还是使用原来定义的$mount方法挂载。所以核心是要了解最初定义$mount方法时内的mountComponent方法:

初定义的$mount方法

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

初定义$mount方法里的mountComponent函数

mountComponent方法,主要看里面的updateComponent的方法。它是一个更新组件的方法,把它传给Watcher,之后数据变了之后再通知给它更新。

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el     // el 为一个真实的Dom
  if (!vm.$options.render) {    // 没有render的时候,设置createEmptyVNode为render方法
    vm.$options.render = createEmptyVNode
  }
  callHook(vm, 'beforeMount')
  let updateComponent = () => {
    // 这里的vm._render()返回的是一个vnode,下面会说
    vm._update(vm._render(), hydrating)
  }
  // we set this to vm._watcher inside the watcher's constructor
  // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  // component's mounted hook), which relies on vm._watcher being already defined
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

mountComponent方法里最重要的方法是updateComponent,它的内部首先会执行vm._render()方法,将返回的结果传入vm._update()内再执行,来看下它的定义:

_render

Vue.prototype._render = function() {
  const vm = this
  const { render } = vm.$options

  const vnode = render.call(vm, vm.$createElement)
  
  return vnode
}

_render里的$createElement

先获取到用户传进来的render方法,然后传入vm.$createElement这个方法(也就是上面例子内的h方法),将返回的vnode返回出去。这里要了解render是怎样转换成vnode的,跳到之前初始化initRender方法内挂载到vm实例的:

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)  // 手写

_c$createElement只有最后一个参数是true/false的区别。

我们平时其实很少写render函数,这是因为vue-loader帮我们把template编译成了render函数。

$createElement/_c里的createElement

再看createElement这个函数,这里现在我们只关注手写的

const SIMPLE_NORMALIZE = 1    // 编译render函数
const ALWAYS_NORMALIZE = 2    // 手写render函数

export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  if (Array.isArray(data) || isPrimitive(data)) {   // data是数组或基础类型
    // tag 相当于是 h 函数的第一个参数
    // data 是第二个
    // children 是第三个
    // normalizationType 是第四个
    // 参数移位
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {  // alwaysNormalize 等于true就是手写的render
    normalizationType = ALWAYS_NORMALIZE   // 代表手写render函数
  }
  return _createElement(context, tag, data, children, normalizationType)
}

createElement主要是对传入的参数做处理,实际操作都在_createElement这个函数里:

createElement里的_createElement

export function _createElement(
  context, tag, data, children, normalizationType
  ) {
  
  if (normalizationType === ALWAYS_NORMALIZE) { // 手写render函数
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) { //编译render函数
    children = simpleNormalizeChildren(children)    // 将children格式化为一维数组
  }
  
  if(typeof tag === 'string') {  // 标签
    let vnode, Ctor
    if(config.isReservedTag(tag)) {  // 如果是html标签
      vnode = new VNode(tag, data, children, undefined, undefined, context)
    }
    ...
  } else { // 就是组件了
    vnode = createComponent(tag, data, context, children)
  }
  ...
  return vnode
}

上面是简化的_createElement,先对传进来的children做处理,这里对编译的render函数处理相对简单,就是将children格式化为一维数组:

function simpleNormalizeChildren(children) {  // 编译render的处理函数
  for (let i = 0; i < children.length; i++) {
    if (Array.isArray(children[i])) {
      return Array.prototype.concat.apply([], children)   // 小操作,利用apply接收的第二个参数为数组实现拼接
    }
  }
  return children
}

再看对手写的renderchildren的处理:

假设render函数是这样的
render(h) {
  return h(
    "div",
    [
      [
        [h("h1", "title h1")],
        [h('h2', "title h2")]
      ],
      [
        h('h3', 'title h3')
      ]
    ]
  );
}
// 假设参数是上面这段
function normalizeChildren(children) {  // 手写`render`的处理函数
  return isPrimitive(children)  //原始类型 typeof为string/number/symbol/boolean之一
    ? [createTextVNode(children)]  // 转为数组的文本节点
    : Array.isArray(children)  // 如果是数组
      ? normalizeArrayChildren(children)
      : undefined
}

因为_createElement方法是对h方法的封装,所以h方法的第一个参数对应的就是_createElement方法内的tag,第二个参数对应的是data。又因为h方法是递归的,所以首先从h('h1', 'title h1')开始解析,经过参数上移之后children就是title h1这段文本了。 举个简单的例子理解上面这段话:

let fn = (a,b)=>{
  return a + b
}
fn(fn(1,2),3)    //  这段肯定是先执行 fn(1,2),然后再执行外层的对叭
如果再复杂一点 
fn(fn(fn(1,fn(3,4)),2),3)    // 那就是从 fn(3,4这里执行),因为这是递归的

普通的元素节点转化为VNode

接着会满足_createElement方法内的这个条件:

if(typeof tag === 'string'){       // tag为h1标签
  if(config.isReservedTag(tag)) {  // 是html标签
    vnode = new VNode(
      tag,  // h1
      data, // undefined
      children,  转为了 [{text: 'title h1'}]
      undefined,
      undefined,
      context
    )
  }
}
...
return vnode

返回的vnode结构为:
{
  tag: h1,
  children: [
    { text: title h1 }
  ]
}

然后依次处理h('h2', "title h2")h('h3', 'title h3')会得到三个VNode实例的节点。接着会执行最外层的h(div, [[VNode,VNode],[VNode]])方法,注意它的结构是二维数组,这个时候它就满足normalizeChildren方法内的Array.isArray(children)这个条件了,会执行normalizeArrayChildren这个方法:

function normalizeArrayChildren(children) {
  const res = []  // 存放结果
  
  for(let i = 0; i < children.length; i++) {  // 遍历每一项
    let c = children[i]
    if(isUndef(c) || typeof c === 'boolean') { // 如果是undefined 或 布尔值
      continue  // 跳过
    }
    
    if(Array.isArray(c)) {  // 如果某一项是数组
      if(c.length > 0) {
        c = normalizeArrayChildren(c) // 递归结果赋值给c,结果就是[VNode]
        ... 合并相邻的文本节点
        res.push.apply(res, c)  //小操作
      }
    } else {
      ...
      res.push(c)
    }
  }
  return res
}

组件转化为VNode

接下来我们来了解组件VNode的创建过程,常见示例如下:

// main.js
new Vue({
  render(h) {
    return h(App)
  }
})

// app.vue
import Child from '@/pages/child'
export default {
  name: 'app',
  components: {
    Child
  }
}

按照上面的流程,是组件的话走的就是下面这个条件:

export function _createElement(
  context, tag, data, children, normalizationType
  ) {
  ...
  if(typeof tag === 'string') {  // 标签
    ...
  } else { // 就是组件了
    vnode = createComponent(
      tag,  // 组件对象
      data,  // undefined
      context,  // 当前vm实例
      children  // undefined
    )
  }
  ...
  return vnode
}

createComponent

如果是创建组件节点,会调用createComponent()方法:

export function createComponent (  // 上
  Ctor, data = {}, context, children, tag
) {
  //  _base为在initGlobalAPI里定义的属性 Vue.options._base = Vue
  const baseCtor = context.$options._base   // Vue
  
  if (isObject(Ctor)) {  // 组件对象
    Ctor = baseCtor.extend(Ctor)  // 转为Vue的子类
  }
  ...
}

extend

_base属性和extend方法都是在定义全局API的时候定义的:

export function initGlobalAPI(Vue) {
  ...
  Vue.options._base = Vue
  Vue.extend = function(extendOptions){...}
}

经过初始化合并options之后当前实例就有了context.$options._base这个属性,然后执行它的extend这个方法,传入我们的组件对象,看下extend方法的定义:

Vue.cid = 0
let cid = 1
Vue.extend = function (extendOptions = {}) {
  const Super = this  // Vue基类构造函数
  const name = extendOptions.name || Super.options.name
  
  const Sub = function (options) {  // 定义构造函数
    this._init(options)  // _init继承而来
  }
  
  Sub.prototype = Object.create(Super.prototype)  // 继承基类Vue初始化定义的原型方法
  Sub.prototype.constructor = Sub  // 构造函数指向子类
  Sub.cid = cid++
  Sub.options = mergeOptions( // 子类合并options
    Super.options,  // components, directives, filters, _base
    extendOptions  // 传入的组件对象
  )
  Sub['super'] = Super // Vue基类

  // 将基类的静态方法赋值给子类
  Sub.extend = Super.extend
  Sub.mixin = Super.mixin
  Sub.use = Super.use

  ASSET_TYPES.forEach(function (type) { // ['component', 'directive', 'filter']
    Sub[type] = Super[type]
  })
  
  if (name) {                 // 让组件可以递归调用自己,所以一定要定义name属性
    Sub.options.components[name] = Sub  // 将子类挂载到自己的components属性下
  }

  Sub.superOptions = Super.options
  Sub.extendOptions = extendOptions

  return Sub
}

我们传入的组件对象相当于就是之前new Vue(options)里面的options,然后和vue之前就定义的原型方法以及全局API合并,然后返回一个新的构造函数,它拥有Vue完整的功能。 继续createComponent的逻辑:

export function createComponent (  // 中
  Ctor, data = {}, context, children, tag
) {
  ...
  // let asyncFactory
  // if (isUndef(Ctor.cid)) {    // 异步占位符相关
  //   asyncFactory = Ctor
  //   Ctor = resolveAsyncComponent(asyncFactory, baseCtor)
  //   if (Ctor === undefined) {
  //     // return a placeholder node for async component, which is rendered
  //     // as a comment node but preserves all the raw information for the node.
  //     // the information will be used for async server-rendering and hydration.
  //     return createAsyncPlaceholder(
  //       asyncFactory,
  //       data,
  //       context,
  //       children,
  //       tag
  //     )
  //   }
  // }
  // data = data || {}

  // // resolve constructor options in case global mixins are applied after
  // // component constructor creation
  // resolveConstructorOptions(Ctor)

  // // transform component v-model data into props & events
  // if (isDef(data.model)) {
  //   transformModel(Ctor.options, data)
  // }
  ...
  // extract props
  const propsData = extractPropsFromVNodeData(data, Ctor, tag)  // 获取组件中props的数据
  
  const listeners = data.on  // 父组件v-on传递的事件对象格式
  data.on = data.nativeOn  // 组件的原生事件

  ---- 举个例子
    <comp :list="list" @comfirm="comfirm" @click.native="testTap"></comp>
    Vue.component('comp', {
      template: '<h1>自定义组件!</h1>',
      props:{
        list:{
          type:Array,
        }
      }
    })
    new Vue({
      el: '#app',
      data () {
        return {
          list:[1,2,3]
        }
      },
      methods: {
        testTap(){
          console.log('testTap')
        },
        comfirm(){
          console.log('comfirm')
        }
      },
    })


    这里通过解析出来的 
    propsData = {list: Array(3)}
    data = {attrs: {}, on: {comfirm: ƒ}, nativeOn: {click: ƒ}}
    listeners = {comfirm: ƒ}
    data.nativeOn = {click: ƒ}
    最后 data.on = data.nativeOn 走完后
    data.on = {click: ƒ}
  ----

  /*
    * 重点:installComponentHooks
    * 它的作用是往组件的data属性下挂载hook这个对象,
    * 里面有init,prepatch,insert,destroy四个方法,这四个方法会在之后的将VNode转为真实Dom的patch阶段会用到
  */ 
  installComponentHooks(data)  
  ...
}

之前说明初始化事件initEvents时,这里的data.on就是父组件传递给子组件的事件对象,赋值给变量listenersdata.nativeOn是绑定在组件上有native修饰符的事件。接着会执行一个组件比较重要的方法installComponentHooks,它的作用是往组件的data属性下挂载hook这个对象,里面有initprepatchinsertdestroy四个方法,这四个方法会在之后的将VNode转为真实Dompatch阶段会用到,当我们使用到时再来看它们的定义是什么。我们继续createComponent的其他逻辑:

export function createComponent (  // 下
  Ctor, data = {}, context, children, tag
) {
  ...
  const name = Ctor.options.name || tag  // 拼接组件tag用
  
  const vnode = new VNode(  // 创建组件VNode
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,  // 对应tag属性
    data, // 有父组件传递自定义事件和挂载的hook对象
    undefined,  // 对应children属性
    undefined,   // 对应text属性
    undefined,   // 对应elm属性
    context,  // 当前实例
    {  // 对应componentOptions属性
      Ctor,  // 子类构造函数
      propsData, // props具体值的对象集合
      listeners,   // 父组件传递自定义事件对象集合
      tag,  // 使用组件时的名称
      children // 插槽内的内容,也是VNode格式
    },
    asyncFactory
  )
  
  return vnode
}

组件生成的VNode如下:

{
  tag: 'vue-component-1-app',
  context: {...},
  componentOptions: {
    Ctor: function(){...},
    propsData: undefined,
    children: undefined,
    tag: undefined,
    children: undefined
  },
  data: {
    on: undefined,  // 为原生事件
    hook: {
      init: function(){...},
      insert: function(){...},
      prepatch: function(){...},
      destroy: function(){...}
    }
  }
}

这就是组件的VNode,如果看到tag属性是vue-component开头就是组件了,虽然组件VNodechildrentexteleundefined,但它的独有属性componentOptions保存了组件需要的相关信息。

最后总结一下挂载流程:

  1. Vue初始化的最后一步,vm.$mount(vm.$options.el)

  2. const mount = Vue.prototype.$mount 获取最初定义的$mount

  3. 拓展编译后的$mount,完整版会增加一个编译函数,主要是判断是否有render函数,有就直接用上一步获取到的初定义的$mount进行挂载,没有就把用户传的template转换成render函数,再通过初定义的$mount进行挂载。mount.call(this, query(el))

  4. 其实无论是运行时版还是完整版,都是用的最初定义的$mount方法,里面是通过 mountComponent 方法挂载。

  5. mountComponent 方法里先调用 beforeMount 钩子函数,再定义了一个 updateComponent 函数,

let updateComponent = () => {
  // 这里的vm._render()返回的是一个vnode,下面会说
  vm._update(vm._render(), hydrating)    // patch 发生在_update阶段
}

这里会先执行vm._render()方法,然后把返回结果传到_update方法里。

  • 5.1 vm._render()
    _render主要作用是执行vm.$options里的render方法(可能是用户传的也可能是编译后得到的),会把h方法传进去,render方法最终返回的是一个VNode

  • 5.2 h 函数
    h函数里首先会对满足条件的参数做移位处理,然后会分别对手写的render和编译的renderchildren参数的处理,处理完后会分别对html标签和组件做创建VNode的操作,最后把VNode返回

    • 5.2.1 html标签生成VNode
    • 5.2.2 组件对象生成VNode
  1. 再把这个函数放到Watcher
new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)
  1. 最后调用 mounted 钩子函数

下一篇:Vue源码解析(三):Vue是如何挂载Dom的(patch篇)

参考:

Vue原理解析