Vue原理解析(二):快速搞懂new Vue()时到底做了什么?(上)

上一篇:Vue原理解析(一):Vue到底是什么?

上一章节我们知道了在new Vue()时,内部会执行一个this._init()方法,这个方法是在initMixin(Vue)内定义的:

export function initMixin(Vue) {
  Vue.prototype._init = function(options) {
    ...
  }
}

当执行new Vue()执行后,触发的一系列初始化都在_init方法中启动,它的实现如下:

let uid = 0

Vue.prototype._init = function(options) {

  const vm = this
  vm._uid = uid++  // 唯一标识
  
  vm.$options = mergeOptions(  // 合并options
    resolveConstructorOptions(vm.constructor),
    options || {},
    vm
  )
  ...
  initLifecycle(vm) // 开始一系列的初始化
  initEvents(vm)
  initRender(vm)
  callHook(vm, 'beforeCreate')
  initInjections(vm)
  initState(vm)
  initProvide(vm)
  callHook(vm, 'created')
  ...
  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }
}

先需要交代下,每一个组件都是一个Vue构造函数的子类,这个之后会说明为何如此。从上往下我们一步步看,首先会定义_uid属性,这是为每个组件每一次初始化时做的一个唯一的私有属性标识,有时候会有些作用。

有一个使用它的小例子,找到一个组件所有的兄弟组件并剔除自己:

... // 找到它的兄弟组件 ... 其他组件

首先要找的组件需要定义name属性,当然定义name属性也是一个好的书写习惯。首先通过自己的父组件($parent)的所有子组件($children)过滤出相同name集合的组件,这个时候他们就是同一个组件了,虽然它们name相同,但是_uid不同,最后在集合内根据_uid剔除掉自己即可。

合并options配置

回到主线任务,接着会合并options并在实例上挂载一个$options属性。合并什么东西了?这里是分两种情况的:

  1. 初始化new Vue

在执行new Vue构造函数时,参数就是一个对象,也就是用户的自定义配置;会将它和vue之前定义的原型方法,全局API属性;还有全局的Vue.mixin内的参数,将这些都合并成为一个新的options,最后赋值给一个的新的属性$options

  1. 子组件初始化

如果是子组件初始化,除了合并以上那些外,还会将父组件的参数进行合并,如有父组件定义在子组件上的eventprops等等。

经过合并之后就可以通过this.$options.data访问到用户定义的data函数,this.$options.name访问到用户定义的组件名称,这个合并后的属性很重要,会被经常使用到。

接下里会顺序的执行一堆初始化方法,首先是这三个:

1. initLifecycle(vm)
2. initEvents(vm)
3. initRender(vm)

1. initLifecycle(vm): 主要作用是确认组件的父子关系和初始化某些实例属性。

export function initLifecycle(vm) {
  const options = vm.$options  // 之前合并的属性
  
  let parent = options.parent;
  if (parent && !options.abstract) { //  找到第一个非抽象父组件
    while (parent.$options.abstract && parent.$parent) {
      parent = parent.$parent
    }
    parent.$children.push(vm)
  }
  
  vm.$parent = parent  // 找到后赋值
  vm.$root = parent ? parent.$root : vm  // 让每一个子组件的$root属性都是根组件
  
  vm.$children = []
  vm.$refs = {}
  
  vm._watcher = null
  ...
  vm._isDestroyed = false
  vm._isBeingDestroyed = false
}

vue是组件式开发的,所以当前实例可能会是其他组件的子组件的同时也可能是其他组件的父组件。

首先会找到当前组件第一个非抽象类型的父组件,所以如果当前组件有父级且当前组件不是抽象组件就一直向上查找,直至找到后将找到的父级赋值给实例属性vm.$parent,然后将当前实例push到找到的父级的$children实例属性内,从而建立组件的父子关系。接下来的一些_开头是私有实例属性我们记住是在这里定义的即可,具体意思也是以后用到的时候再做说明。

2. initEvents(vm): 主要作用是将父组件在使用v-on@注册的自定义事件添加到子组件的事件中心中。

首先看下这个方法定义的地方:

export function initEvents (vm) {
  vm._events = Object.create(null)  // 事件中心
  ...
  const listeners = vm.$options._parentListeners  // 经过合并options得到的
  if (listeners) {
    updateComponentListeners(vm, listeners) 
  }
}

我们首先要知道在vue中事件分为两种,他们的处理方式也各有不同:

2.1 原生事件

在执行initEvents之前的模板编译阶段,会判断遇到的是html标签还是组件名,如果是html标签会在转为真实dom之后使用addEventListener注册浏览器原生事件。绑定事件是挂载dom的最后阶段,这里只是初始化阶段,这里主要是处理自定义事件相关,也就是另外一种,这里声明下,大家不要理会错了执行顺序。

2.2 自定义事件

在经历过合并options阶段后,子组件就可以从vm.$options._parentListeners读取到父组件传过来的自定义事件:


传过来的事件数据格式是{select:function(){}}这样的,在initEvents方法内定义vm._events用来存储传过来的事件集合。

内部执行的方法updateComponentListeners(vm, listeners)主要是执行updateListeners方法。这个方法有两个执行时机,首先是现在的初始化阶段,还一个就是最后patch时的原生事件也会用到。它的作用是比较新旧事件的列表来确定事件的添加和移除以及事件修饰符的处理,现在主要看自定义事件的添加,它的作用是借助之前定义的$on$emit方法,完成父子组件事件的通信,(详细的原理说明会在之后的全局API章节统一说明)。首先使用$onvm.events事件中心下创建一个自定义事件名的数组集合项,数组内的每一项都是对应事件名的回调函数,例如:

vm._events.select = [function handleSelect(){}, ...]  // 可以有多个

注册完成之后,使用$emit方法执行事件:

this.$emit('select')

首先会读取到事件中心内$emit方法第一个参数select的对象的数组集合,然后将数组内每个回调函数顺序执行一遍即完成了$emit做的事情。

不知道大家有没有注意到this.$emit这个方法是在当前组件实例触发的,所以事件的原理可能跟大部分人理解的不一样,并不是父组件监听,子组件往父组件去派发事件。

而是子组件往自身的实例上派发事件,只是因为回调函数是在父组件的作用域下定义的,所以执行了父组件内定义的方法,就造成了父子之间事件通信的假象。知道这个原理特性后,我们可以做一些更cool的事情,例如:

// $on添加事件 // $emit触发事件

我们可不可以在parent-component内使用$on添加事件到当前实例的事件中心,而在child-components-3内找到parent-component的组件实例并在它的事件中心触发对应的事件实现跨组件通信了,答案是可以了!这一原理发现再开发组件库时会有一定帮助。

3. initRender(vm): 主要作用是挂载可以将render函数转为vnode的方法。

export function initRender(vm) {
  vm._vnode = null
  ...
  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._cvm.$createElement两个方法,它们只是最后一个参数不同,这两个方法都可以将render函数转为vnode,从命名大家应该可以看出区别,vm._c转换的是通过编译器将template转换而来的render函数;而vm.$createElement转换的是用户自定义的render函数,比如:

new Vue({
  data: {
    msg: 'hello Vue!'
  },
  render(h) { // 这里的 h 就是vm.$createElement
    return h('span', this.msg);  
  }
}).$mount('#app');

render函数的参数h就是vm.$createElement方法,将内部定义的树形结构数据转为Vnode的实例。

4. callHook(vm, 'beforeCreate')

终于我们要执行实例的第一个生命周期钩子beforeCreate,这里callHook的原理是怎样的,我们之后的生命周期章节会说明,现在这里只需要知道它会执行用户自定义的生命周期方法,如果有mixin混入的也一并执行。

好吧,实例的第一个生命周期钩子阶段的初始化工作完成了,一句话来主要说明下他们做了什么事情:

  • initLifecycle(vm):确认组件(也是vue实例)的父子关系
  • initEvents(vm):将父组件的自定义事件传递给子组件
  • initRender(vm):提供将render函数转为vnode的方法
  • beforeCreate:执行组件的beforeCreate钩子函数

最后还是以一道vue容易被问道的面试题作为本章节的结束吧:

面试官微笑而又不失礼貌的问道:

  • 请问可以在beforeCreate钩子内通过this访问到data中定义的变量么,为什么以及请问这个钩子可以做什么?

怼回去:

  • 是不可以访问的,因为在vue初始化阶段,这个时候data中的变量还没有被挂载到this上,这个时候访问值会是undefinedbeforeCreate这个钩子在平时业务开发中用的比较少,而像插件内部的instanll方法通过Vue.use方法安装时一般会选在beforeCreate这个钩子内执行,vue-routervuex就是这么干的。

下一篇:Vue原理解析(三):快速搞懂new Vue()时到底做了什么?(下)

顺手点个赞或关注呗,找起来也方便~

分享一个组件库,你可能会用的上哦 ~ ↓

你可能会用的上的一个vue功能组件库,持续完善中...

你可能感兴趣的:(Vue原理解析(二):快速搞懂new Vue()时到底做了什么?(上))