Vue原理解析(二):快速搞懂new 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
属性,这是为每个组件每一次初始化时做的一个唯一的私有属性标识,有时候会有些作用。
有一个使用它的小例子,找到一个组件所有的兄弟组件并剔除自己:
<div>
...
<child-components />
<child-components /> // 找到它的兄弟组件
... 其他组件
<child-components />
</div>
首先要找的组件需要定义name
属性,当然定义name
属性也是一个好的书写习惯。首先通过自己的父组件($parent)
的所有子组件($children)
过滤出相同name
集合的组件,这个时候他们就是同一个组件了,虽然它们name
相同,但是_uid
不同,最后在集合内根据_uid
剔除掉自己即可。
合并options配置
回到主线任务,接着会合并options
并在实例上挂载一个$options
属性。合并什么东西了?这里是分两种情况的:
- 初始化new Vue
在执行new Vue
构造函数时,参数就是一个对象,也就是用户的自定义配置;会将它和vue
之前定义的原型方法,全局API
属性;还有全局的Vue.mixin
内的参数,将这些都合并成为一个新的options
,最后赋值给一个的新的属性$options
。
- 子组件初始化
如果是子组件初始化,除了合并以上那些外,还会将父组件的参数进行合并,如有父组件定义在子组件上的event
、props
等等。
经过合并之后就可以通过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
读取到父组件传过来的自定义事件:
<child-components @select='handleSelect' />
传过来的事件数据格式是{select:function(){}}
这样的,在initEvents
方法内定义vm._events
用来存储传过来的事件集合。
内部执行的方法updateComponentListeners(vm, listeners)
主要是执行updateListeners
方法。这个方法有两个执行时机,首先是现在的初始化阶段,还一个就是最后patch
时的原生事件也会用到。它的作用是比较新旧事件的列表来确定事件的添加和移除以及事件修饰符的处理,现在主要看自定义事件的添加,它的作用是借助之前定义的$on
,$emit
方法,完成父子组件事件的通信,(详细的原理说明会在之后的全局API
章节统一说明)。首先使用$on
往vm.events
事件中心下创建一个自定义事件名的数组集合项,数组内的每一项都是对应事件名的回调函数,例如:
vm._events.select = [function handleSelect(){}, ...] // 可以有多个
注册完成之后,使用$emit
方法执行事件:
this.$emit('select')
首先会读取到事件中心内$emit
方法第一个参数select
的对象的数组集合,然后将数组内每个回调函数顺序执行一遍即完成了$emit
做的事情。
不知道大家有没有注意到this.$emit
这个方法是在当前组件实例触发的,所以事件的原理可能跟大部分人理解的不一样,并不是父组件监听,子组件往父组件去派发事件。
而是子组件往自身的实例上派发事件,只是因为回调函数是在父组件的作用域下定义的,所以执行了父组件内定义的方法,就造成了父子之间事件通信的假象。知道这个原理特性后,我们可以做一些更cool
的事情,例如:
<div>
<parent-component> // $on添加事件
<child-component-1>
<child-component-2>
<child-component-3 /> // $emit触发事件
</child-component-2>
</child-components-1>
</parent-component>
</div>
我们可不可以在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._c
和vm.$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
上,这个时候访问值会是undefined
。beforeCreate
这个钩子在平时业务开发中用的比较少,而像插件内部的instanll
方法通过Vue.use
方法安装时一般会选在beforeCreate
这个钩子内执行,vue-router
和vuex
就是这么干的。
顺手点个赞或关注呗,找起来也方便~
分享一个笔者自己写的组件库,哪天可能会用的上了 ~ ↓