Vue深入源码:new Vue的时候会做什么

您好,我是沧沧凉凉,是一名前端开发者,目前在掘金知乎以及个人博客上同步发表一些学习前端时遇到的趣事和知识,欢迎关注。


时隔2个多月,我又开始发文了,这两个多月我逐步开始研究React和Vue源码相关的东西,并且也深入阅读了JavaScript相关的资料,接下来我会逐渐整理我学到的知识,进一步加深对这些知识的理解。

在平时写业务的时候,可能阅读源码并不会对你书写业务的效率提升多少,也许你觉得你现在写业务已经非常的得心应手了,那么下面这些与Vue有关的问题你是否思考过:

  • 生命周期是如何实现的。
  • 组件是如何和data还有props进行绑定的。
  • 组件中的state更新时,为什么视图会发生变化。
  • vue-router是如何实现单页面模拟路由切换的。
  • vuex是如何实现的。

为什么是Vue2不是Vue3?

Vue3的源码相对于Vue2来说已经发生了翻天覆地的变化,用TypeScript进行了重构,所以Vue3和Vue2的源码存在着巨大的区别,那么Vue3已经出了两年左右了,我为什么还要阅读Vue2的源码?

那是因为Vue2的代码经过多年的沉淀,已经不会有太大的改动了,Vue3的代码可能今天你看到是这样,明天就进行了非常大的改动。

好吧,其实是因为公司目前依然使用的Vue2…

所以我就从Vue2开始研究,等到Vue2研究的差不多的时候,再去研究Vue3。

但是看了一部分Vue2的源码后,我突然醒悟我其实可以直接看Vue3…所以关于Vue2的文章可能就只有这一篇,因为这篇文章我一开始研究的时候就写了个大概。后面的文章几乎都是Vue3和React的。

为什么我开始读源码?

阅读源码不光光是为了应付面试,最重要的是它可以给你很多启发,你看源码的过程中可能会忍不住发出惊叹:还有这种操作?

随着你对这些框架的深入了解,你写业务代码的时候也会不自觉的站在另一个角度去思考代码,即:如何让代码更具复用性。

那么说回正题,Vue项目的入口文件通常会有下面的代码:

new Vue({
  render: h => h(App),
}).$mount('#app')

这小小的一段代码背后的故事,将分成两部分进行分析,分别是new Vue部分和.$mount部分。

关于Vue构造函数,它的代码可以在这里找到:

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

从代码中可以看到有5个Mixin函数:

  • initMixin:初始化相关。
  • stateMixin:state相关。
  • eventsMixin:事件相关。
  • lifecycleMixin:生命周期相关。
  • renderMixin:渲染相关。

initMixin是本篇文章主要讲解的函数,因为它涉及到初始化相关的内容。

initMixin

该函数为Vue的原型上添加一个_init方法。在这里可以看到源码:_init

export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    ...省略内容
  }
}

而该方法会在new Vue的时候会进行调用:

function Vue (options) {
  // 调用_init方法
  this._init(options)
}

所以我们要了解new Vue做了什么最主要的就是需要了解_init方法中做了什么。

关于_init方法,我删除了一些new Vue生成实例时并不会走的代码:

export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // a uid
    vm._uid = uid++

    // a flag to avoid this being observed
    vm._isVue = true
    // merge options
    if (options && options._isComponent) {
      // optimize internal component instantiation
      // since dynamic options merging is pretty slow, and none of the
      // internal component options needs special treatment.
      initInternalComponent(vm, options)
    } else {
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }
    // expose real self
    vm._self = vm
    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')

    // 如果有el参数,则调用.$mount方法
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

从源码中我们可以看出,下面两种写法是完全等价的:

new Vue({
  render: h => h(App),
}).$mount('#app')

new Vue({
  render: (h) => h(App),
  el: "#app",
});

_init方法中会经过一系列的初始化内容:

  • initLifecycle(vm)
  • initEvents(vm)
  • initRender(vm)
  • callHook(vm, 'beforeCreate')
  • initInjections(vm)
  • initState(vm)
  • initProvide(vm)
  • callHook(vm, 'created')

从上面的命名可以看得出来,这一系列的函数是初始化生命周期、事件、渲染、Inject、State等内容。

这些内容不是本篇文章需要关注的重点,后面会以Vue3的源码作为参考,去探讨Vue的生命周期,State以及props等等内容。

那么通过上面的探讨,总结一下new Vue中主要做了什么事情?

  1. 调用_init方法。
  2. 通过mergeOptions函数合并options并且赋值给Vue上的$options属性。
  3. 初始化生命周期、事件、渲染、Inject、State等内容。

$mount

接下来就要来谈一下.$mount中做了些什么操作,因为new Vue中只是进行了一大堆的初始化操作,但是并没有涉及到如何将VNode节点渲染到界面上。

这里可以找到它的源码:$mount源码

源码中的最后一句调用了一个方法:

return mount.call(this, el, hydrating)

即下面的这个方法:

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

它的源码地址

可以看到.$mount其实是调用mountComponent函数:

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
    if (process.env.NODE_ENV !== 'production') {
      /* istanbul ignore if */
      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
        )
      }
    }
  }
  callHook(vm, 'beforeMount')

  let updateComponent
  /* istanbul ignore if */
  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)
    }
  }

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

这里的源码是比较饶的,而且几乎没有注释,如果不通过debugger调试一下源码,可能根本就不知道方法之间的执行顺序,所以我个人推荐阅读源码,精髓就在于调试,如果你光看源码,可能看几行代码就放弃了。

代码中定义了一个updateComponent方法:

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

接下来将该方法作为参数传入了Watcher构造函数中:

new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

所以在mountComponent函数中,有3个点我们需要关注:

  • _update方法。
  • _render方法。
  • new Watcher

_update方法和_render方法是在new Watcher中进行调用的,所以我们这里优先看一下new Watcher中做了什么操作。

由于代码比较多,所以本篇文章只关注new Watcher中与.$mount关系紧密的部分。

get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    if (this.user) {
      handleError(e, vm, `getter for watcher "${this.expression}"`)
    } else {
      throw e
    }
  } finally {
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      traverse(value)
    }
    popTarget()
    this.cleanupDeps()
  }
  return value
}

关键代码就是这句:this.getter.call(vm, vm)

它调用了updateComponent函数,而该函数中又调用了_update方法和_render方法

执行顺序为先执行_render方法,再执行_update方法,所以我们先看_render方法中做了什么处理。

if (_parentVnode) {
  vm.$scopedSlots = normalizeScopedSlots(
    _parentVnode.data.scopedSlots,
    vm.$slots,
    vm.$scopedSlots
  )
}

如果在Vue中使用过jsx的朋友可能对于$scopedSlots比较熟悉,它是跟插槽相关的内容。

于是我们根据代码可以大胆的推测:在_render方法中主要是做了一些和插槽相关的工作,并且将$vnode属性指向它的父组件。

前文中我们提到了lifecycleMixin这个函数,而_update方法]就是在这个函数中被挂到原型上的,所以我们可以在这里找到它的源码:

_update中调用了一个非常重要的方法vm.__patch__这就是Vue2中的diff算法相关的内容。

而关于diff算法,在后面的文章中会以Vue3的代码单独进行探讨。

Vue2的diff算法所在的位置:patch

我们再次大胆的猜测一下:_update方法就是根据VNode节点,生成一个DOM元素。

比起上面的_update方法做了一大堆diff算法,衍生出了各种方法来讲_render方法所做的事情就简单的多。

最后

我们总结一下在new Vue().$mount()时Vue做了哪些事情:

  • 初始化生命周期、事件、渲染、Inject、State等内容。
  • 根据VNode节点创建真实DOM节点渲染到页面上。

虽然我们大致明白了new Vue时的到底做了什么,但本文开头提出的问题都没有得到解答。

不过别着急,一口吃不成一个胖子,这些内容会在后面的Vue3源码相关的文章中进行探讨。

由于源码系列的参考资料并不是太多,所以文章中可能有些地方的理解是错误的,主要目的还是提供一个阅读源码的思路,推荐还是按照本文提供的思路,自己对源码进行一下调试,可能会有更深层次或者不同的理解。