在 Vue 中,除函数式组件外,所有组件都是 Vue 实例。每个 Vue 实例在被创建时都要经过一系列的初始化过程:数据监听、编译模板、将实例挂载到 DOM 并在数据变化时更新 DOM 等。
在生成 Vue 实例的过程中会运行一些叫做生命周期钩子的函数,这给了用户在不同阶段添加自己的代码的机会。本文从源码的角度来详细阐述组件生命周期的相关内容。
一、钩子函数的调用
生命周期钩子函数调用是通过 callHook 函数完成的,callHook 函数主要包含三个方面的内容。
function callHook (vm, hook) {
pushTarget()
const handlers = vm.$options[hook]
const info = `${hook} hook`
if (handlers) {
for (let i = 0, j = handlers.length; i < j; i++) {
invokeWithErrorHandling(handlers[i], vm, null, vm, info)
}
}
if (vm._hasHookEvent) {
vm.$emit('hook:' + hook)
}
popTarget()
}
1、调用钩子函数
在生成 Vue 实例的过程中会调用 mergeOptions 函数对选项进行处理,生命周期钩子函数经过合并处理后会添加到实例对象的 $options 属性上,合并后各生命周期函数存储在对应的数组中。具体细节可参看文章《选项合并》。
callHook 函数调用的形式如下所示:
// 调用 created 生命周期钩子函数
callHook(vm, 'created')
此时 callHook 函数会循环遍历执行 vm.$options.created 数组中的函数,以完成 created 生命周期钩子函数的调用。
2、防止收集冗余依赖
在函数首尾有如下代码:
function callHook (vm, hook) {
pushTarget()
/* 省略... */
popTarget()
}
这两个函数的源码如下所示:
Dep.target = null
const targetStack = []
function pushTarget (target) {
targetStack.push(target)
Dep.target = target
}
function popTarget () {
targetStack.pop()
Dep.target = targetStack[targetStack.length - 1]
}
Vue 实例的当前观察者对象是唯一的,所谓当前观察者对象是指即将要收集依赖的目标,pushTarget 函数将观察者对象入栈而不是简单的赋值,是为了在当前观察者对象操作完成后恢复成之前的观察者对象。
在函数的首尾调用 pushTarget() 和 popTarget() 函数,是为了防止在生命周期钩子函数中使用 props 数据时收集冗余的依赖。具体详情可参看《响应式原理》。
3、hookEvent
在 callHook 函数中还有一部分代码:
if (vm._hasHookEvent) {
vm.$emit('hook:' + hook)
}
这行代码比较有意思,也就是说在执行生命周期钩子函数时,如果 vm._hasHookEvent 的值为 true,则会额外触发一个形如 hook:created 的事件。
那么什么时候实例的 _hasHookEvent 属性值为真呢?还记得在上篇文章讲解 $on 方式时有提过这点:
const hookRE = /^hook:/
Vue.prototype.$on = function(event, fn){
/* 省略... */
if (hookRE.test(event)) {
vm._hasHookEvent = true
}
/* 省略... */
}
上篇文章同时讲到,在组件上使用自定义指令最终会转化成调用 $on 的形式,也就是说按照以下使用就能命中这种情况:
<Child @hook:created = "doSomething"></Child>
这种形式的事件称为 hookEvent,在官方文档上没有找到 hookEvent 的说明,但是在 Vue 源码中有实现。所谓 hookEvent 就是特殊命名的事件—— hook: + 生命周期名称。这种事件会在子组件对应生命周期钩子函数调用时被调用。
那 hookEvent 有什么用呢?其实在使用第三方组件的时候能够用到,使用 hookEvent 可以在不破坏第三方组件代码的前提下,向其注入生命周期函数。
二、组件的生命周期
关于组件实例的生命周期,官网上面有一张很经典的图片:
这张图片包含的信息较多,下面我们通过拆解这张图片来逐步讲解组件实例的生命周期。
1、beforeCreate和created
Vue 的构造函数主要包含 _init 方法,在组件实例化的过程中会通过该函数完成一系列初始化操作。
function Vue (options) {
/* 省略警告信息 */
this._init(options)
}
_init 方法首先进行合并选项,然后初始化生命周期、事件等,最后挂载 DOM 元素。代码如下所示:
Vue.prototype._init = function (options) {
const vm = this
/*...*/
if (options && options._isComponent) {
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
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)
}
}
这里函数调用的顺序很重要,数据的处理都是在 beforeCreate 生命周期函数调用之后初始化的,也就是说在 beforeCreate 生命周期函数中,不能使用 props、methods、data、computed 和 watch 等数据,也不能使用 provide/inject 中的数据。一般从后端加载数据不用赋值给data中时,可以放在这个生命周期中。
在 beforeCreate 与 created 生命周期函数调用中间,调用初始化各个数据的函数。initState 函数代码如下所示:
function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
} else {
observe(vm._data = {}, true)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
注意 initState 函数中函数的调用顺序:initProps——initData——initComputed——initWatch。这样初始化顺序的结果是在 data 选项中可以使用 props;在 computed 选项中可以使用 data、props 中的数据;watch 选项可以监听 data、props、computed 数据的变化。methods 选项的组成是函数,在函数调用时这些初始化工作已经完成,所以可以使用全部的数据。
初始化 inject 的 initInjections 函数在 initState 之前调用,最后调用初始化 provide 的 initProvide 函数。这样就决定了在 data、props、computed 等选项中可以使用 inject 中的数据,provide 选项中可以使用 data、props、computed、inject 等的数据。
调用 created 生命周期函数之前,数据初始化已经完成,在函数中可以操作这些数据。向后端请求的数据需要赋值给 data 时,可以放在 created 生命周期函数中。
2、beforeMount和mounted
在 _init 函数的最后执行 $mount 方法来完成DOM挂载,下面以运行时+编译器的版本来阐述具体挂载过程。
编译器相关代码如下所示:
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (el,hydrating){
el = el && query(el)
/* 省略... */
if (!options.render) {
let template = options.template
if (template) {
if (typeof template === 'string') {
if (template.charAt(0) === '#') {
template = idToTemplate(template)
/* 省略... */
}
} else if (template.nodeType) {
template = template.innerHTML
} else {
/* 省略... */
return this
}
} else if (el) {
template = getOuterHTML(el)
}
if (template) {
/* 省略... */
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
/* 省略... */
}
}
return mount.call(this, el, hydrating)
}
该函数的作用是将 template/el 转化成渲染函数,具体的转化过程可参看《模板编译》一文。
根据渲染函数完成挂载的代码如下所示:
Vue.prototype.$mount = function (el,hydrating){
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
function mountComponent (vm,el,hydrating){
vm.$el = el
/* 省略渲染函数不存在的警告信息 */
callHook(vm, 'beforeMount')
let updateComponent
/* 删除性能埋点相关 */
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)
hydrating = false
if (vm.$vnode == null) {
vm._isMounted = true
callHook(vm, 'mounted')
}
return vm
}
可以看到 beforeMount 生命周期是在渲染函数生成之后、开始执行挂载之前调用的。beforeMount 钩子函数执行后,实例化一个渲染函数观察者对象,关于 Watcher 相关内容可以参看《响应式原理》。
从渲染函数到生成真实DOM的过程由 updateComponent 函数来完成,其中 _render 函数的作用是根据渲染函数生成 VNode,_update 函数的作用是根据 VNode 生成真实DOM并插入到对应位置中。
在挂载完成后,会调用 mounted 生命周期钩子函数,在该生命周期内可以对DOM进行操作。
这里有个判断条件:vm.$vnode == null,组件初始化的时候 $vnode 不为空,当条件成立时,说明是通过 new Vue() 来进行初始化的。换而言之,组件初始化时,不会在此处执行 mounted 生命周期钩子函数,那么组件 mounted 生命周期函数在何处调用呢?
_update 函数本质上是通过调用 patch 函数来完成真实DOM元素的生成与插入,在 patch 函数的最后有如下代码:
function patch (oldVnode,vnode,hydrating,removeOnly){
/* 省略... */
invokeInsertHook(vnode,insertedVnodeQueue,isInitialPatch)
return vnode.elm
}
function invokeInsertHook (vnode, queue, initial) {
/* 省略... */
for (let i = 0; i < queue.length; ++i) {
queue[i].data.hook.insert(queue[i])
}
}
function insert (vnode) {
const { context, componentInstance } = vnode
if (!componentInstance._isMounted) {
componentInstance._isMounted = true
callHook(componentInstance, 'mounted')
}
/* 省略 keep-alive 相关...*/
}
可以看到组件 mounted 生命周期钩子函数的调用是在 patch 的最后阶段进行的,另外 insertedVnodeQueue 是一个 VNode 数组,数组中 VNode 的顺序是子 VNode 在前,父 VNode 在后,因此 mounted 钩子函数的执行顺序也是子组件先执行,父组件后执行。
3、beforeUpdate和updated
beforeUpdate 与 updated 两个生命周期跟数据更新有关,数据响应式原理在本系列文章的第二篇已经详细阐述过,这里只说跟生命周期有关的部分。
上一小节提到,在实例挂载过程中有如下代码:
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'beforeUpdate')
}
}
}, true)
Watcher 构造函数代码如下所示:
class Watcher {
constructor (vm,expOrFn,cb,options,isRenderWatcher) {
this.vm = vm
if (isRenderWatcher) {
vm._watcher = this
}
vm._watchers.push(this)
if (options) {
/* 省略... */
this.before = options.before
}
}
/* 省略... */
update () {
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
/* 省略... */
}
可以看到在实例化渲染函数观察者对象时,会将传入的 before 函数添加到观察者对象上。在数据更新时会执行 update 方法,在没有添加强制要求时,默认执行 queueWatcher 函数完成数据更新。
export function queueWatcher (watcher: Watcher) {
/* 省略... */
flushSchedulerQueue()
/* 省略... */
}
function flushSchedulerQueue () {
/* 省略... */
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null
watcher.run()
/* 省略... */
}
callUpdatedHooks(updatedQueue)
/* 省略... */
}
function callUpdatedHooks (queue) {
let i = queue.length
while (i--) {
const watcher = queue[i]
const vm = watcher.vm
if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
callHook(vm, 'updated')
}
}
}
数据更新是通过观察者对象的实例方法 run 完成的,从上代码可以看到:在数据更新前会调用实例对象上的 before 方法,从而执行 beforeUpdate 生命周期钩子函数;在数据更新完成后,通过执行 callUpdatedHooks 函数完成 updated 生命周期函数的调用。
4、beforeDestroy和destroyed
调用实例方法 $destroy() 完全销毁一个实例。清理它与其它实例的连接,解绑它的全部指令及事件监听器。beforeDestroy 与 destroyed 生命周期函数就是在这期间被调用的。
Vue.prototype.$destroy = function () {
const vm = this
if (vm._isBeingDestroyed) { return }
callHook(vm, 'beforeDestroy')
vm._isBeingDestroyed = true
const parent = vm.$parent
if (parent && !parent._isBeingDestroyed && !vm.$options.abstract){
remove(parent.$children, vm)
}
if (vm._watcher){
vm._watcher.teardown()
}
let i = vm._watchers.length
while (i--){
vm._watchers[i].teardown()
}
if (vm._data.__ob__){
vm._data.__ob__.vmCount--
}
vm._isDestroyed = true
vm.__patch__(vm._vnode, null)
callHook(vm, 'destroyed')
vm.$off()
if (vm.$el) {
vm.$el.__vue__ = null
}
if (vm.$vnode) {
vm.$vnode.parent = null
}
}
首先判断实例上 _isBeingDestroyed 是否为 true,这是实例正在被销毁的标识,为了防止重复销毁组件。当正式开始执行销毁逻辑之前,调用 beforeDestroy 生命周期钩子函数。
销毁组件的具体步骤有:
1、将实例从其父级实例中删除。
2、移除实例的依赖。
3、移除实例内响应式数据的引用。
4、删除子组件实例。
完成上述操作后调用 destroyed 生命周期钩子函数,然后移除实例上的全部事件监听器。
三、keep-alive组件
当组件被 keep-alive 内置组件包裹时,组件实例会被缓存起来。这些组件在首次渲染时各生命周期与普通组件一样,再次渲染时 created、mounted 等钩子函数就不再生效。
被 keep-alive 包裹的组件被缓存之后有两个独有的生命周期: activated 和 deactivated。activated 生命周期在组件激活时调用、deactivated 生命周期在组件停用时调用。
上一节讲 mounted 生命周期时说过,组件的 mounted 的生命周期钩子函数是在 insert 方法中调用的。当时将函数中对 keep-alive 的处理省略了,这里重点阐述。
insert (vnode) {
/* 省略... */
if (vnode.data.keepAlive) {
if (context._isMounted) {
queueActivatedComponent(componentInstance)
} else {
activateChildComponent(componentInstance, true)
}
}
}
queueActivatedComponent 函数的调用是为了修复 vue-router 中的一个问题:在更新过程中 keep-alive 的子组件可能会发生改变,直接遍历树结构可能会调用错误子组件实例的 activated 生命周期钩子函数,因此这里不做处理而是将组件实例放入队列中,等 patch 过程结束后再做处理。
queueActivatedComponent 最终也是调用 activateChildComponent 函数来执行 activated 生命周期钩子函数。
function activateChildComponent(vm,direct){
/* 省略... */
if (vm._inactive || vm._inactive === null) {
vm._inactive = false
for (let i = 0; i < vm.$children.length; i++) {
activateChildComponent(vm.$children[i])
}
callHook(vm, 'activated')
}
}
可以看到就是在这里调用的 activated 生命周期钩子函数,并且会递归调用全部子组件的 activated 生命周期钩子函数。
deactivated 生命周期是在 destroy 钩子函数中调用的:
destroy (vnode) {
const { componentInstance } = vnode
if (!componentInstance._isDestroyed) {
if (!vnode.data.keepAlive) {
componentInstance.$destroy()
} else {
deactivateChildComponent(componentInstance, true)
}
}
}
keep-alive 的子组件的子组件会走 else 分支,直接调用 deactivateChildComponent 函数。
function deactivateChildComponent(vm, direct){
/* 省略... */
if (!vm._inactive) {
vm._inactive = true
for (let i = 0; i < vm.$children.length; i++) {
deactivateChildComponent(vm.$children[i])
}
callHook(vm, 'deactivated')
}
}
在该函数中,会调用的 deactivated 生命周期钩子函数,并且会递归调用全部子组件的 deactivated 生命周期钩子函数。
四、总结
生命周期钩子函数的是通过 callHook 来调用的,该函数不仅遍历执行对应的生命周期函数,还能防止收集冗余依赖和触发 hookEvent 事件。hookEvent 能够非侵入的向一个组件注入生命周期函数。
通过 new Vue() 实例化 Vue 对象会调用 _init 方法完成一系列初始化操作,在初始化数据之前会调用 beforeCreate 钩子,在数据初始化后调用 created 钩子。在生成渲染函数之后,调用 beforeMount 钩子,接着根据渲染函数生成真实DOM并挂载,然后调用 mounted 钩子。数据更新时,在重新渲染之前调用 beforeUpdate 钩子,在完成渲染后调用 updated 钩子。在调用实例方法 $destroy() 销毁实例时首先调用 beforeDestroy 钩子,然后执行销毁操作,最后调用 destroyed 钩子。
被 keep-alive 缓存起来的组件被激活时会调用 activated 钩子,在 patch 最后阶段的 insert 钩子函数中执行。组件停用时调用 deactivated 钩子,在 patch 的 destroy 钩子函数中执行。
欢迎关注公众号:前端桃花源,互相交流学习!
版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: 白马笑西风 原文链接:https://juejin.im/post/6844904048215212045