likes
comments
collection
share

Vue DOM挂载

作者站长头像
站长
· 阅读数 16

Vue如何渲染DOM (第二章)

当我们new Vue时发生了什么?

接下来我们就来探究Vue是如何将该页面进行渲染的。在研究之前,我们必须了解Vue构造函数。但是在执行Vue函数前,当我们import Vue from 'vue'的时候,该框架做了这样的几个初始化:

initMixin(Vue)
//传入Vue,向其原型上添加_init函数
stateMixin(Vue)
//传入Vue,向其原型上添加 $set,$delete,$watch函数 并做其他初始化定义
eventsMixin(Vue)
//传入Vue,向其原型上添加 $on,$once,$off,$emit函数,并做其他初始化定义
lifecycleMixin(Vue)
//传入Vue,向其原型上添加 _update,$forceUpdate,$destroy函数,并做其他初始化定义
renderMixin(Vue)
//renderMixin函数向Vue原型添加 $nextTick,_render,在此之前执行了installRenderHelpers(Vue.prototype) 这个函数的执行也向Vue,.prototype上添加了很多内置的函数
//以上的5个函数的作用是向Vue.prototype上添加属性和方法
//对Vue进行包装
export default Vue
//当我们new Vue({})时就进入了上面定义的Vue函数了

其实上面的函数是在我们导入Vue时,该框架本身做的一些初始化,这和我们执行Vue构造函数时内部的初始化不一样。这些函数主要是在我们使用Vue前,向Vue构造函数的原型上添加一些函数。而当我们执行Vue构造函数时,也会在内部进行一些初始化,例如响应式的完成和数据代理。所以我们需要区分这两种初始化

在我们import Vue from 'vue'的时候,主要做的是向Vue构造函数的原型上添加一些函数或属性。

然后我们就可以执行Vue构造函数了。当我们执行Vue构造函数的时候,本质上其实就是执行Vue.prototype._init()方法。

function Vue (options) {
  //options是我们传入的配置项
  //这个就是大名鼎鼎的vue构造函数,所有的vue项目的开始的地方
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
    //这里的this 其实使 vm实例,看是否用了new Vue
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  //这里的this是我们执行new Vue 时构造函数内部生成的实例对象,也可以理解为vm也就是组件的实例对象,这里是根组件的实例对象
  this._init(options)
  //从这个函数进入我们用initMixin(Vue)初始化添加的_init函数
}

这个this._init()是在initMixin()函数中声明的。this._init()函数中做了很多事情,比如合并options,执行一些函数:

    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

不过这些暂时不是我们关注的重点,重点是后面的代码,即关于渲染的代码。不过需要注意的是,这里的渲染指的是Vue已经完成了绝大部分事情,只剩下将浏览器上的视图重新渲染。

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

如果我们在vue构造函数的配置项中传入了el参数,那么将会执行vm.$mount函数,而传入的参数是vm,$options.el。也即是我们的#app字符串。

vm.$mount

vm.$mount的挂载其实是在./src/platforms/web/entry-runtime-with-complier.js当中,如果我们只在./src/core/的文件中其实是找不到的。vue的编译一共有两个版本:第一是runtime-only版本,另一个就是runtime-complier。在这里我们需要说明的是,什么是运行时+编译,其实当我们写如下代码:

var vm = new Vue({
    el:"#app",
    data(){
        return{
            message:123
        }
    }
})

Vue在进行模板渲染的时候其实是有一个对#app进行编译的一个过程,会将模板编译成一个render函数。从某种意义上来讲其实是对性能的一种消耗。假如我们写这样的一段代码:

var vm = new Vue({
    el:'#app',
    data(){
        return{
            message:123
        }
    },
    render:h(createElement)=>{
        return createElement({
            tag:'div'
        })
    }
})

此时我们添加了一项render。这个函数的作用是返回一个由createElement函数生成的一个vdom。用来代替我们的el。这样有什么好处呢。好处就是Vue不需要把模板编译成render函数。因为我们直接就提供给Vuerender函数。这样就省去了模板 ---> render函数的编译时间。对于runtime + complier版本的根组件我们不论写的是render/template,我们必须有一个前提,那就是我们需要写el。原因在于Vue需知道要我们最后的模板渲染到文档的那个位置,所以必须有el,但是对于组件来说,可能就不需要了

接下来我们来详细的讲解vm.$mount这个方法。该方法代码如下:

Vue.prototype.$mount = function (
  el?: string | Element,//我们传入的el类型有两种,一种是字符串'#app',另一种是一个元素对象,例如document.getElementById('app')
  hydrating?: boolean
): Component {  
  el = el && query(el)//query函数主要是返回一个元素对象,如果我们传入的el存在,那么就返回该元素的对象形式(也就是真正的元素节点),如果不存在,那么就会默认是一个div元素对象
​
  /* istanbul ignore if */
  if (el === document.body || el === document.documentElement) {
    //这里告诉我们el不能是body和html。原因是它会发生覆盖,这样就会将原来的模板完全覆盖掉。
    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//这里的this指向的是vm实例对象
  // resolve template/el and convert to render function
  if (!options.render) {
    //如果我们没有render函数。那么会进入该区域代码。
    let template = options.template //获取模板
    if (template) {
      //如果存在template配置项,
      if (typeof template === 'string') {//如果配置项的类型为字符串。
        if (template.charAt(0) === '#') {//这里我们只处理template为#xxx的格式的模板,也就是类似于template:'#app'这种
          template = idToTemplate(template)//该函数返回的是template模板内部的节点的字符串形式。
          /* istanbul ignore if */
          //这里是template的错误处理
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) {
        //如果我们传入的template是一个节点对象,那么获取该节点对象中的innerHTML,然会的也是字符串形式
        template = template.innerHTML
      } else {
        //不是以上两种格式,那么抛出错误
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      //如果template配置项不存在,那么获取el.outerHTML当作我们的template。返回的也是字符串类型
      template = getOuterHTML(el)
    }
    
    if (template) {
      //这是处理好的template。这种template的来源有两种,第一种是我们自己设置的,另一种就是el.outerHTML来充当template
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }
​
​
      //接下来的代码是进行编译,将我们的模板编译成以js描述的对象,即虚拟DOM,然后将虚拟DOM转化为render(渲染函数)。
      const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
​
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  //如果我们没有render函数,其实通过对template进行编译,我们就会获得render函数,
  //然后调用mount.call()函数。
  //如果我们自己有render函数,那么我们就可以直接调用mount.call函数,不需要去进行编译。
  return mount.call(this, el, hydrating)
}

从上面的代码解析中可以看到,我们最终执行的其实就是render函数,只不过此时的渲染函数的来源有两种:其一是我们自定义的render函数。其二就是通过模板编译然后生成render函数

在我们通过模板编译的时候,Vue的编译顺序是这样的:

首先如果我们定义了template配置项,那么Vue就不会使用el指定的元素作为模板去编译,而是使用template中的模板去编译。如果我们没有指定template,那么显而易见,Vue只能去使用el指定的模板去编译进而生成render函数。使用template编译有一个前提,那么就是我们没自定义rendere函数。如果我们自义了render函数,那么Vue就用我们定义的,进而不去编译生成render函数了。

总结:Vue首先会看我们有没有自定义render函数,有的话就使用我们自定义的作为最后的render函数。如果没有那么查看我们有没有定义template配置项模板,有的话就使用template进行模板编译然后把生成的render函数作为我们最后使用的render函数。假如我们没有定义template,那么此时Vue只能使用el指定的元素模板去编译,然后把其编译生成的render函数作为我们之后的render函数。懂?在这里需要注意一下,render函数是一个函数,它的作用是生成虚拟DOM。将生成的render函数挂载到options.render上

我们会发现,我们最后一定会执行mount.call函数。那么我们来讲解该函数。

vm.$mount

该函数位于/web/runtime/index.js当中。其实当我们使用vm.$mount的时候,并不是直接调用/web/runtime/index.js中的mount函数的,而是调用的是上面Vue原型上定义的mount函数,但是最终函数调用了位于/web/runtime/index.js中的原始定义的mount函数。

首先我们来看参数:mount.call(this, el, hydrating)//this -> vm ; el -> 元素对象;hydrating -> 布尔值(和服务端渲染有关,我们可以认为是false)

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined//这里之所以对再一次的对el进行判断,是因为这里的$mount是runtime-only版本的,所以对你传入的el进行判断。
  return mountComponent(this, el, hydrating)
}

我们发现,vm.$mount函数需要调用mountComponent()函数。该函数位于./src/core/instance/lifecycle.js当中。接下来我们讲解mountComponent()函数。

mountComponent

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el//将el元素对象挂载到vm.$el上,也就是说,vm.$el是在执行$mount的时候挂载上去的。宏观的讲,它是在created钩子函数之后,beforeMount钩子函数之前被挂载的。
  if (!vm.$options.render) {//这里的vm.$options.render是处理之后的render函数,也就是说,如果我们如果不传入render函数或者编译后的虚拟DOM无法生成render函数,那么vm.$options.render都为false
    vm.$options.render = createEmptyVNode//如果在上述中为真,那么我们就给vm.$options.render赋值一个由空虚拟DOM组成的渲染函数。
    if (process.env.NODE_ENV !== 'production') {
      /* istanbul ignore if */
      //这里的警告就是你用了runtime-only,但是你写了template/el那么就会报错,它只能接收render函数。这是版本问题
      if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
        vm.$options.el || el) {
        warn(
          'You are using the runtime-only build of Vue where the template ' +
          'compiler is not available. Either pre-compile the templates into ' +
          'render functions, or use the compiler-included build.',
          vm
        )
      } else {
        warn(
          'Failed to mount component: template or render function not defined.',
          vm
        )
      }
    }
  }
  //触发berforeMount钩子函数
  callHook(vm, 'beforeMount')
​
  let updateComponent
  /* istanbul ignore if */
  //这和运行性能有关,暂时可以忽视
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        ......
  } else {
    //最终定义updateComponent函数
    updateComponent = () => {
      //vm._update是在lifecycleMixin(Vue)中定义的
      //vm._render是在renderMixin中定义的。
      //hydrating:false
      //该函数的执行其实是在new Watcher()中执行的,我们暂时只关注它的执行,不去关注在什么地方触发。
      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函数目前做了什么。

  • vm.$el进行赋值,并触发beforeMount钩子函数。
  • 定义updateComponent函数。
  • 执行new Watcher()构造函数,在该构造函数中执行updateComponent()函数。

这只是目前我们要探究的mountComponent函数做的事情,其后面的代码稍后讲解。

当执行到new Watcher的时候,它的内部首先会为我们的这个组件实例创建一个watcher实例对象。然后将这个实例对象通过内部的get函数挂载到全局变量Dep.target上。这一点很重要,因为它是依赖收集的关键。接着就是执行updateComponent函数。

vm._render

当我们执行updateComponent()函数的时候,会先执行vm._render函数,接下来我们具体讲解该函数。

该函数位于/instance/render.js中。因为vm._render函数内部的实现很复杂,所以我们就讲述和渲染有关的代码。

  Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, _parentVnode } = vm.$options//从vm.$options中拿到render函数。
​
    if (_parentVnode) {
      vm.$scopedSlots = normalizeScopedSlots(
        _parentVnode.data.scopedSlots,
        vm.$slots,
        vm.$scopedSlots
      )
    }
​
    // set parent vnode. this allows render functions to have access
    // to the data on the placeholder node.
    vm.$vnode = _parentVnode
    // render self
    let vnode
    try {
      // There's no need to maintain a stack because all render fns are called
      // separately from one another. Nested component's render fns are called
      // when parent component is patched.
      currentRenderingInstance = vm
      //vm._renderProxy在生产环境下其实就是vm。通过调用render函数来生产vnode。
      
        
        
        
        
      vnode = render.call(vm._renderProxy, vm.$createElement)
        
        
        
        
        
        
    } catch (e) {
      handleError(e, vm, `render`)
      // return error render result,
      // or previous vnode to prevent render error causing blank component
      /* istanbul ignore else */
      if (process.env.NODE_ENV !== 'production' && vm.$options.renderError) {
        try {
          vnode = vm.$options.renderError.call(vm._renderProxy, vm.$createElement, e)
        } catch (e) {
          handleError(e, vm, `renderError`)
          vnode = vm._vnode
        }
      } else {
        vnode = vm._vnode
      }
    } finally {
      currentRenderingInstance = null
    }
    // if the returned array contains only a single node, allow it
    if (Array.isArray(vnode) && vnode.length === 1) {
      vnode = vnode[0]
    }
    // return empty vnode in case the render function errored out
    if (!(vnode instanceof VNode)) {
      if (process.env.NODE_ENV !== 'production' && Array.isArray(vnode)) {
        warn(
          'Multiple root nodes returned from render function. Render function ' +
          'should return a single root node.',
          vm
        )
      }
      vnode = createEmptyVNode()
    }
    // set parent
    vnode.parent = _parentVnode
    return vnode
  }

首先const { render, _parentVnode } = vm.$options//从vm.$options中拿到render函数。。然后

vnode = render.call(vm._renderProxy, vm.$createElement)vm._renderProxy在生产环境下其实就是vm。通过调用render函数来生产vnode。接下来我们来看vm.$createElement函数。

vm.$createElement

该函数在/instance/render.js中的initRender函数当中。

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/vm.$createElement。这两个函数的作用是不同的,vm._c是编译后生成的render函数所执行的函数。而vm.$createElement是提供给我们手写render函数执行的函数。它们两个都会调用createELement函数。用该函数来生成虚拟DOM。

回到我们的render函数上来。也就是说,我们最后会通过render函数来获取到vDOM

createElement

我们会发现,无论是我们写的渲染函数,还是内部自己生成的渲染函数,最终底层的代码还是会调用同一个函数,那就是createElement。该代码如下:

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

代码中的两个if其实是对参数不一致的一种处理,但最终我们调用的是_createElement函数。

_createElement

该函数有很多的逻辑结构,我们只讲解目前对我们有用的部分。

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  //............
  if (normalizationType === ALWAYS_NORMALIZE) {
      //normalizeChildren函数作用如下:
      //其实该函数的最终目的也是将我们的多维嵌套数组变成以为数组。也就是说我们返回后的children一定是一个一维的包含子节点对象的一个数组。
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
      //simpleNormalizeChildren作用如下:
      //将我们的数组拍平,但是它默认你只有两层数组的嵌套,没有其他的嵌套。[[a],[b]] ----> [a,b]
    children = simpleNormalizeChildren(children)
  }
​
​
//从这里开始,创建vNode。
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // 假如这里是我们原生的标签,例如div,li,span等等,那么我们就执行后续代码
        ...........
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } 
    ...........
}

VNode

vNodeVue中单独有一个类进行实例化,该类位于/src/core/vdom/vnode.js。该类结构具体如下:

export default class VNode {
    .......
  constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
      /*当前节点的标签名*/
      this.tag = tag
      /*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息*/
      this.data = data
      /*当前节点的子节点,是一个数组*/
      this.children = children
      /*当前节点的文本*/
      this.text = text
      /*当前虚拟节点对应的真实dom节点*/
      this.elm = elm
      /*当前节点的名字空间*/
      this.ns = undefined
      /*编译作用域*/
      this.context = context
      /*函数化组件作用域*/
      this.functionalContext = undefined
      /*节点的key属性,被当作节点的标志,用以优化*/
      this.key = data && data.key
      /*组件的option选项*/
      this.componentOptions = componentOptions
      /*当前节点对应的组件的实例*/
      this.componentInstance = undefined
      /*当前节点的父节点*/
      this.parent = undefined
      /*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/
      this.raw = false
      /*静态节点标志*/
      this.isStatic = false
      /*是否作为跟节点插入*/
      this.isRootInsert = true
      /*是否为注释节点*/
      this.isComment = false
      /*是否为克隆节点*/
      this.isCloned = false
      /*是否有v-once指令*/
      this.isOnce = false
 
    this.asyncFactory = asyncFactory
    this.asyncMeta = undefined
    this.isAsyncPlaceholder = false
  }
}

这里有很多参数,其实我们真正用的上的不多。例如:tag/data/children/text/elm/context。他返回的是一个对象,该对象的结构类似于:

vNode = {
    tag:'div',
    children:[],
    ....
}

js对象描述的节点。可能会产生疑惑,为什么我们不直接创建一个节点,而去用js来描述进而创建一个虚拟节点,原因在于如果我们去创建一个节点,其实浏览器会为该节点添加很多很多的属性。这样开销是很大的。此时我们用虚拟节点来暂时代替,会节约很多资源。

其实_createElement()函数在目前主要做了两件事。第一:将我们的children进行拍平。第二就是创建我们的vnode节点。其实创建之后的节点大致是这样的:

vNode = {
    tag:'div',
    attrs:{
        id:'app'
    },
    children:undefined
}

这就是节点的javascript的对象描述形式。

回到正题,我们以runtime+complier为例,当内部调用vm._c()函数的时候,此时会返回我们的vDOM。此时VUE就会通过该vDOM来创建真正的节点进而挂载到真实的DOM上去。

总结:vnode生成历程

vm._render --> render.call() --> vm.$createElement/vm._c --> _createElement --> new VNode() ==> 生成Vnode

当我们回头看vnode的创建过程,我们会发现,除了render函数我们可以影响外,其他的函数都是Vue内部定义好的,我们无法调用它们或者修改它们。但正是因为我们可以去影响render函数,让它变得异常的重要。如果生成不了这个函数,我们就无法去生成vnode。有的同学可能会说,那没有后面的函数也生成不了啊,的确是这样的,但是我们要清楚,后面的这些函数是尤雨溪写好的,所以它们本身就存在。但是我们是可以影响render函数的,所以它的存在就依靠我们写的vue代码。

自定义render函数:

render(createElement){
    return createElement('div',{attrs:{id:'app'}})
}
vm.options.render = render

通过编译生成render函数:

const code = 'with(this){return _c("div",{attrs:{id:"app"}})}'
const render = new Function(code)
vm.options.render = render

具体编译的时候做了什么进而生成的渲染函数,此时就不是我们讨论的范围了。

通过以上两种方法去生成render函数后,Vue就可以通过上面的流程来创建出模板的vnode

vm._update

vm._update的调用有两个地方。第一是我们首次渲染的时候,需要用vm._update把生成的vDOM变成真正的DOM节点。第二个就是当我们的数据改变的时候,我们需要使用vm._update来进行视图的更新。其实调用vm._update的根本目的是创建节点渲染视图。我们在什么时候调用的vm._update?它的代码如下:vm._update(vm._render(), hydrating),代码是vm.$mount调用updateComponent函数的时候执行的。vm._render()返回的是我们的vnode。接下来我们具体分析vm._update方法:

  Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this//这里是vm实例
    const prevEl = vm.$el
    const prevVnode = vm._vnode//undefined
    const restoreActiveInstance = setActiveInstance(vm)
    vm._vnode = vnode
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    if (!prevVnode) {
      // initial render 首次渲染
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
        //首次渲染地时候vm.$el是真实地DOM节点对象 vnode是渲染后生成地虚拟DOM
    } else {
      // updates 数据更新渲染
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    restoreActiveInstance()
    // update __vue__ reference 
    if (prevEl) {
      prevEl.__vue__ = null
    }
    if (vm.$el) {
      vm.$el.__vue__ = vm
    }
    // if parent is an HOC, update its $el as well
    if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
      vm.$parent.$el = vm.$el
    }
    // updated hook is called by the scheduler to ensure that children are
    // updated in a parent's updated hook.
  }

因为vm._update函数在两种情况下去调用它,所以该函数内部其实是对两种情况的一种综合处理。

当第一次调用vm._update函数的时候,vm.vnodeundefined。原因是我们第一次调用时还没有挂载。所以prevVnodeundefined。然后执行vm._vnode = vnode。因为prevVnodeundefined。所以执行

vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
//vm.$el 是真实的DOM
//vnode 是虚拟DOM
//hydrating: false
//false

vm.patch

该函数位于./platforms/web/runtime/index.js中。

Vue.prototype.__patch__ = inBrowser ? patch : noop
patch()
export const patch: Function = createPatchFunction({ nodeOps, modules })
//nodeOps是操作DOM的原生API
//modules是class或者说是属性的钩子函数
createPatchFunction

该函数定义了很多辅助函数,不过,该函数最终返回的是它内部最后定义的patch函数。也就是说,当调用vm.__patch__函数的时候,最终我们调用的是createPatchFunction函数中最后定义的patch函数。

patch
return 
function patch (oldVnode, vnode, hydrating, removeOnly){
    ....
}
//oldVnode:是真实地DOM
//vnode:虚拟DOM(或是我们改变数据后重新生成地虚拟DOM)
//false

patch函数地调用也分为两种情况。第一种是当我们首次渲染DOM的时候。第二种情况就是当我们去修改数据的时候Vue需要更新我们的视图的时候去调用patch方法。

当我们第一次渲染DOM的时候,oldVnodevm.$el。是一个真实的DOMvnode是我们自定义或者通过编译生成的虚拟DOM。当我们首次执行patch函数的时候,它会执行如下代码:

oldVnode = emptyNodeAt(oldVnode)

emptyNodeAt函数的作用是将真实的DOM转化为虚拟DOM。其实为什么要这样做,理由是可以想明白的。如果我们修改了数据,然后重新进行渲染。在渲染前Vue会将每一个节点通过diff算法进行对比,目的是为了减少操作DOM的次数,但是在对比节点的时候不是通过真实DOM来进行比较的,而是通过原有的虚拟DOM和修改后的数据的虚拟DOM进行比较,此时我们就需要将我们的真实的vm.$el转化为我们的虚拟DOM。

当我们上述地数据准备好之后,开始执行接下来的代码:

createElm(
 vnode,
 insertedVnodeQueue,
 oldElm._leaveCb ? null : parentElm,
 nodeOps.nextSibling(oldElm)
  )

createElm是一个很神奇的函数,就是它将我们渲染后得到的虚拟DOM变成真实的节点然后挂载到我们文档的大DOM上的。

createElm
 function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {
    //...........
​
    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    if (isDef(tag)) {
      if (process.env.NODE_ENV !== 'production') {
        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
          )
        }
      }
​
      //nodeOps是一个原生DOM API的一个封装对象,nodeOps.createElement(tag)其实就是document.createElement(tag)。
      //vnode.elm此时就是通过nodeOps.createElement()函数创建的真实节点对象。
      vnode.elm = vnode.ns
        ? nodeOps.createElementNS(vnode.ns, tag)
        : nodeOps.createElement(tag, vnode)
      setScope(vnode)
​
      /* istanbul ignore if */
      if (__WEEX__) {
        //WEEX端的渲染
        //...........
      } else {
        //
        createChildren(vnode, children, insertedVnodeQueue)
        if (isDef(data)) {
          invokeCreateHooks(vnode, insertedVnodeQueue)
        }
        insert(parentElm, vnode.elm, refElm)
      }
​
      //.......
    } else if (isTrue(vnode.isComment)) {
     //.......
    } else {
      //.......
    }
  }
​

该函数传入的第一个参数是vnode。其实该参数准确的来书为父节点,为什么这么说呢。当我们第一次调用createElm函数的时候,那么此时我们的父节点其实就是根节点。但是后面我们还有子节点的创建。

当我们创建完成的时候,此时我们就应该考虑创建子节点了。于是代码就会执行到此处:

if(__WEEX__){
    //.......
}else {
        //
        createChildren(vnode, children, insertedVnodeQueue)
        if (isDef(data)) {
          invokeCreateHooks(vnode, insertedVnodeQueue)
        }
        insert(parentElm, vnode.elm, refElm)
   }

首先我们来看看createChildren()函数。

createChildren
function createChildren (vnode, children, insertedVnodeQueue) {
    if (Array.isArray(children)) {
      //判断我们的children是否是一个数组,如果不是,那么其子节点要么为空,要么就是文本节点
      if (process.env.NODE_ENV !== 'production') {
        checkDuplicateKeys(children)
      }
      for (let i = 0; i < children.length; ++i) {
        //这个就是深度遍历并创建节点。此时相当于把我们传入的children[i]当作了我们的根节点来处理。层层递进。它会为我们的每一个节点都调用createElm方法,进而进行一些其他属性的添加或者其他操作。
        createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
      }
    } else if (isPrimitive(vnode.text)) {
      //这种情况就是我们的子节点是一个文本,此时我们调用appendChildren()函数将文本添加到父节点下
      nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
    }
  }

该函数逻辑也比较容易理解。首先会判断我们的children是否是一个数组,如果是,说明该节点有子节点,那么我们就创建子节点。它是怎么创建的呢?它是深度遍历优先。通过循环,我们获取到children[i]这个子节点,然后调用createElm()函数来创建节点,当我们创建完了,内部又会调用createChildren()来常见子节点的子节点。然后将子节点的子节点appendChild到子节点上。就是这样每一层每一层遍历,然后创建出一个完整的基于vnode.elm的一个小的且真实的DOM树

当我们创建完成后。执行insert(parentElm, vnode.elm, refElm)语句。

insert
  function insert (parent, elm, ref) {
    if (isDef(parent)) {
      if (isDef(ref)) {
        if (nodeOps.parentNode(ref) === parent) {
          nodeOps.insertBefore(parent, elm, ref)
        }
      } else {
        nodeOps.appendChild(parent, elm)
      }
    }
  }

传入的三个参数:

  • parentElm:是我们根节点的父节点,例如:

    <ul>
        <div id="app">
           {{name}}
        </div>
    </ul>
    

    此时<ul>就是parentElm。因为毕竟我们要将创建好的DOM挂载到我们的文档上去的,所以要找到我们的根节点的文档父节点。

  • vnode.elm:是我们的根节点。但是要记住,该根节点包括它的子节点及其子节点的所有节点。

  • refel:参考节点。

其实就是将我们生成后的DOM挂载到我们的文档上来,实现视图的渲染。

其实有一点很值得我们注意,那就是调用insert函数的时候的节点插入,我们会发现我们是先调用createChildren()然后在调用insert()函数,那就说明我们是先从树的最末端进行插入,倒数第一层插入到倒数第二层,倒数第二层插入到倒数第三层。然后以此类推就生成了一个完整的真实的DOM树。最后才会将我们的DOM树添加到文档父节点中。

总结:执行过程

我们上述的执行过程其实可以简化为:

new Vue() --> init --> $mount --> complier --> render --> vnode --> patch --> DOM

我们其实可以把Vue渲染DOM划分为三个部分:数据处理生成虚拟DOM生成节点并挂载