了解Vue实例的创建过程,有助于帮助我们理解Vue内部的实现机制,更加深刻的理解钩子函数的作用和使用场景。本篇博文基于Vue2.X,3.0的一些特殊机制和语法暂不做考虑。阅读全篇时间约12分钟
,仅了解钩子函数使用及使用场景,需约4分钟
。
我们都知道,创建一个Vue实例,需要通过new Vue()来实现。那一个Vue实例创建到销毁的过程中,会存在哪些变化和会调用哪些功能呢?又会再什么场景下去触发钩子函数的调用呢,下面将通过讲述Vue实例的创建过程,来说明Vue的钩子函数的调用时机和场景。
一个Vue实例的生命周期,总体而言,可以划分为三个阶段:初始化阶段、数据响应阶段以及销毁阶段,我将通过多篇博文来介绍这几个阶段以及生命周期钩子函数的使用。
一个正常简单的Vue实例化语句是下面这样的:
var vm = new Vue({
el:#app',
data:{
msg:'xxxx'
}
})
我们通过调用Vue的构造函数来创建Vue实例,其中我们会传入一个对象,该对象包含了实例化Vue所需要的el、data等。在实例初始化阶段,Vue会根据传入的这个对象,去内部调用各种init()函数。期间Vue的四个生命周期的钩子函数也会被调用到,他们分别是:beforeCreate、created、beforeMount、Mounted
,初始化的过程大致如下:
在我们正常的开发过程,只需要了解各个钩子函数的使用场景和前提就可以了,并不需要太去关注里面内部的实现机制,所以这里先说一下每个钩子函数的使用场景:
beforeCreate
beforeCreate钩子函数,如其名,是在Vue实例还未创建data、methods等之前,会触发调用的一个钩子函数,在触发这个函数的调用之前,Vue主要是做了一些简单的事件初始化等工作,比如emit,on,once,off
等,平常开发中使用几率少。
created
created钩子函数,是在已经初始化好props、methods、data、computed、watch
等之后,会去调用的钩子函数,但是此时属性el还没有被创建的,只是创建了实例的一些基本属性。created钩子函数中可以操作已经创建好的数据和方法,同时也可以调用异步的功能获取页面初始化所需的数据。
beforeMount
在调用这个beforeMount之前,Vue已经编译好了模板并转化成了虚拟DOM,el也已经完成了对元素节点的挂载。在这个钩子函数里,我们可以修改data的数据并且不会触发updated和beforeUpdate钩子函数的调用,使用频率也较低。
mounted
mounted钩子函数是在虚拟DOM通过$el
已经替换成为真实DOM树的之后被调用,此时页面中的html已经渲染完成,相对于created
而言,如果需要去操作DOM树,那么可以放在mounted
中去完成。此时也可以通过this.$ref去拿到ref绑定的元素节点。同时此时变更data的数据,会对应的触发updated和beforeUpdate钩子函数的调用。
说完了初始化阶段钩子函数的调用,现在我们来结合源码一起来解读下这个过程。下面这部分的内容涉及对源码的解读,没时间的小伙伴可以先收藏,后续再翻出来看看,加以理解。
先来展示一段简单的实例化代码:
// 传入了一个包含el、data属性的对象来构建一个Vue实例
var vm = new Vue({
el:'#app',
data:{
msg:''
}
})
Vue实例的构建是通过new Vue()而来的,所以想要了解Vue实例化的过程,要先从它的构造函数Vue开始。
//代码路径:src\core\instance\index.js
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)
}
这里调用了一个_init函数,init函数主要如下:
// src\core\instance\init.js
// 省略部分代码
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
...
// Vue内部调用,正常不会走到这个分支
if (options && options._isComponent) {
initInternalComponent(vm, options)
} else {
// 使用策略对象合并参数选项合并父子组件的一些同名属性,包括指令、过滤器、钩子函数等,不同的合并对象采用不同的策略,这里不做深入
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
...
//初始化了一些内部属性和外部属性,比如外部属性:$children,$parent,$refs,$root等,内部属性_watcher等
initLifecycle(vm)
//初始化父组件在模板中使用v-on或@注册的监听子组件内触发的事件。
initEvents(vm)
initRender(vm)
//依照传入的钩子函数名,去调用上面合并后的钩子函数数组,这里是调用beforeCreate
callHook(vm, 'beforeCreate')
//初始化inject选项
initInjections(vm) // resolve injections before data/props
//初始化实例状态,如props、methods、data、computed、watch
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')
...
//如果存在el,则自动调用$mount()进入模板编译和挂载阶段,如果没有,则需要等待用户手动vm.$mount()来触发
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
可以看到,在这个init函数中完成了对beforeCreate、created
钩子函数的调用,并通过判断是否存在el来决定是否调用$mount()来进入模板编译阶段。在判断之前,它做了一系列的初始化工作。这里我们仅对关键的代码initState
做拆解,其他的细枝末节的,上面已经做了注释,了解的太细也没有必要用途不多。
//代码路径:src\core\instance\state.js
export function initState (vm: Component) {
//注册一个内部属性_watchers用来保存所注册的watcher实例
vm._watchers = []
const opts = vm.$options
//在initProps中会对所有的prop做规范化处理,转换为统一格式,并对prop的值做类型校验
if (opts.props) initProps(vm, opts.props)
//初始化methods,并对methods中的方法名做校验,判断是否与props或者_、$开头的内部名称重名
if (opts.methods) initMethods(vm, opts.methods)
//初始化data,校验data属性名是否合法,将data中的属性通过代理绑定到vm上,方便后续可以直接使用this.的方法调用
if (opts.data) {
initData(vm)
} else {
observe(vm._data = {}, true /* asRootData */)
}
//初始化computed,创建computed单独的watcher监听
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
在上面我们可以看到,首先initState是在beforeCreate之后调用的,在initState中分别对props、methods、data、computed、watch
等进行了初始化,这里也可以发现,props和methods是在data之前初始化的,所以在data、watch、computed里面,我们可以访问到props和methods,在执行完initState之后,后面再去执行created的钩子函数时,就能够正常的访问我们的data和methods了。
执行完created之后,
在执行完created之后,我们从最开始执行_init()的地方上面可以看到有这样的一段代码:
// 代码路径:src\core\instance\init.js
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
这里其实就对应了我们生命周期图示,通过判断el是否存在,如果存在则取触发相关的调用。但是我们得首先看看这个$mount的实现。
// 代码路径:src\platforms\web\runtime\index.js
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
// 这个query(el)获取el绑定的dom元素,如果没有,则创建一个div元素返回
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
可以看到,$mount
里面,会去对el做判断以及是否是浏览器环境,如果是,则对el进行了遍历获取对应的dom元素,然后去执行下面的mountComponent方法,这里先不对mountComponent具体讲解,后面会说道。这个地方只是声明了$mount
的核心功能,真正被调用的地方还是在下面:
// src\platforms\web\entry-runtime-with-compiler.js
//这里将$mount赋值给了mount,然后给$mount覆盖了一个新的方法,在新方法的末尾,
//通过mount.call(this, el, hydrating)去触发核心代码的调用。
//这种处理方式被称之为函数劫持,即在原始功能上新增一些其他功能。
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el)
// el元素不建议绑定html、body标签
if (el === document.body || el === document.documentElement) {
process.env.NODE_ENV !== 'production' && warn(
`Do not mount Vue to or - mount to normal elements instead.`
)
return this
}
const options = this.$options
// 判断是否存在render,如果不存在,则判断将template是否存在
if (!options.render) {
let template = options.template
if (template) {
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
template = idToTemplate(template)
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && !template) {
warn(
`Template element not found or is empty: ${options.template}`,
this
)
}
}
} else if (template.nodeType) { //判断template是否一个DOM元素
template = template.innerHTML
} else {
if (process.env.NODE_ENV !== 'production') {
warn('invalid template option:' + template, this)
}
return this
}
} else if (el) {
// 如果不存在则根据el绑定的元素节点,将对应的DOM转换为template
template = getOuterHTML(el)
}
// 存在则将模板渲染通过compileToFunctions转化为render函数
if (template) {
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
mark('compile')
}
//将template编译成render函数,这里会有render以及staticRenderFns两个返回
//这是vue的编译时优化,static静态不需要在VNode更新时进行patch,优化性能
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')
}
}
}
//触发钩子函数的调用
return mount.call(this, el, hydrating)
}
上面这块代码,主要是实现将模板转化为对应的渲染函数,如果存在template
,则正常转化,否则就根据el绑定的DOM模板,将其转化为template之后,再通过compileToFunctions
的方式进行转化。处理完这些,再去调用之前我们暂存的$mount
的核心功能,也就是钩子函数的调用。需要注意,$mount
的编译方式是存在多种的,因此在上面$mount
返回的mountComponent()
方法中还对render做了判断,如果没有对应的render(),会作相应的提醒警告,而上面功能实现的为完整版的编译功能(runtime + compiler)。具体详参Vue官方文档:运行时 + 编译器 vs. 只包含运行时
讲到这里,我们可以知道,在钩子函数beforeMount调用之前,其实就是做了模板编译的工作,返回了render函数。下一步就是真正的挂载阶段了,也就是mountComponent()
所做的事情。具体的实现代码如下:
// 代码路径:src\core\instance\lifecycle.js
export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
if (!vm.$options.render) {
// 如果render不存在,会创建一个空的VNode节点
vm.$options.render = createEmptyVNode
... //省略部分不相关代码
}
// 触发生命周期钩子函数beforeMount
callHook(vm, 'beforeMount')
let updateComponent
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
... // 省略次要处理代码
} else {
// updateComponent赋值,入参为vm._render()将会为我们得到一份最新的VNode节点树
// vm._update对最新的VNode节点树与上一次渲染的旧VNode节点树进行对比并更新DOM节点(即patch操作),完成一次渲染
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
}
//这里对该vm注册一个Watcher实例,Watcher的getter为updateComponent函数,
//用于触发所有渲染所需要用到的数据的getter,进行依赖收集,该Watcher实例会存在所有渲染所需数据的闭包Dep中
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
// 调用mounted钩子
callHook(vm, 'mounted')
}
return vm
}
总结下上面代码,就是先调用了beforeMount钩子函数,然后调用渲染函数将虚拟DOM树进行更新和渲染,然后开启数据监听的机制。当数据发生变更的时候,我们在创建watcher对象时的第四个参数,就是会在数据发生变更的时候触发的回调函数,这其中就会调用对应的beforeUpdate方法。当执行到这里时,整个Vue实例就已经创建完成并且开启了数据监听了。
new Watcher(vm,
updateComponent, //
noop,
{
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
到这里,我们就知道Vue初始化的过程及整个钩子函数的调用场景了,当然,篇幅有限,不能介绍所有的细节点,比如mounted之前,$el是如何挂载并将我们的虚拟DOM替换为真实的DOM的(可参考我文末的参考链接去探究),这些都没有细讲,但是Vue实例化阶段的钩子函数调用,应该还是较为清晰的。希望大家在读完之后,能够对Vue的生命周期中Vue实例化这一块有更深的了解。
码字不易,欢迎一健三连给点激励哟~
同时,文中有讲述错误的地方,欢迎评论指正,最好指出的同时还请提供相关依据,欢迎大家共同来完善。
参考文献:
Vue gitHub
Vue 源码学习
Vue源码解析-详细篇
Vue生命周期源码探究
Vue源码解析-中文社区