在开发一般的业务来说,不需要知道 Vue 中钩子函数过多的执行细节。但是如果你想写出足够稳健的代码,或者想开发一些通用库,那么就少不了要深入了解各种钩子的执行时机了。
组件生命周期 hook 在组件树中的调用时机
先直接看一个例子:
import Vue from 'vue';
Vue.component('Test', {
props: {
name: String
},
template: `<div class="test">{{ name }}</div>`,
beforeCreate() {
console.log('Test beforeCreate');
},
created() {
console.log('Test created');
},
mounted() {
console.log('Test mounted');
},
beforeDestroy() {
console.log('Test beforeDestroy');
},
destroyed() {
console.log('Test destroyed');
},
beforeUpdate() {
console.log('Test beforeUpdate');
},
updated() {
console.log('Test updated');
}
});
Vue.component('Test1', {
props: {
name: String
},
template: '<div class="test1"><slot />{{ name }}</div>',
beforeCreate() {
console.log('Test1 beforeCreate');
},
created() {
console.log('Test1 created');
},
mounted() {
console.log('Test1 mounted');
},
beforeDestroy() {
console.log('Test1 beforeDestroy');
},
destroyed() {
console.log('Test1 destroyed');
},
beforeUpdate() {
console.log('Test1 beforeUpdate');
},
updated() {
console.log('Test1 updated');
}
});
new Vue({
el: '#app',
data() {
return {
a: true,
name: ''
};
},
mounted() {
setTimeout(() => {
console.log('-----------');
this.name = 'yibuyisheng1';
this.$nextTick(() => {
console.log('-----------');
});
}, 1000);
setTimeout(() => {
console.log('-----------');
this.a = false;
this.$nextTick(() => {
console.log('-----------');
});
}, 2000);
},
template: '<Test1 v-if="a" :name="name"><Test :name="name" /></Test1><span v-else></span>'
});
运行这个例子,会发现输出如下:
Test1 beforeCreate
Test1 created
Test beforeCreate
Test created
Test mounted
Test1 mounted
-----------
Test1 beforeUpdate
Test beforeUpdate
Test updated
Test1 updated
-----------
-----------
Test1 beforeDestroy
Test beforeDestroy
Test destroyed
Test1 destroyed
-----------
很清楚地可以看到,各个钩子函数在组件树中调用的先后顺序。
实际上,此处可以对照 DOM 事件的捕获和冒泡过程来看:
- beforeCreate 、 created 、 beforeUpdate 、 beforeDestroy 是在“捕获”过程中调用的;
- mounted 、 updated 、 destroyed 是在“冒泡”过程中调用的。
同时,可以看到,在初始化流程、 update 流程和销毁流程中,子级的相应声明周期方法都是在父级相应周期方法之间调用的。比如子级的初始化钩子函数( beforeCreate 、 created 、 mounted )都是在父级的 created 和 mounted 之间调用的,这实际上说明等到子级准备好了,父级才会将自己挂载到上一层 DOM 树中去,从而保证界面上不会闪现脏数据。
充分理解这个调用过程是很有必要的,比如有下面两个非常常见的场景:
实现对话框组件
在对话框组件的实现中,为了方便处理浮层遮盖问题,往往会将浮层根元素放置到 body 元素下面,而不是让其保持在书写对话框组件所在的位置。同时需要做一个浮层的层叠顺序管理,正确处理对话框相互之间的视觉覆盖关系。
为了达到这个效果,可以在对话框组件的 created 钩子函数中向全局层叠管理器注册自己,然后拿到自己的 z-index 值,然后在 mounted 的时候将浮层根元素插入到 body 元素下。
实现有依赖关系的父子组件
有很多这种类型的组件,比如 Select 和 Option 、 Tab 和 TabItem 、 Table 和 TableRow 等等。一般情况下,会采用子级组件向父级组件注册的方式来实现这种依赖关系,因为在子级的钩子函数中,可以明确地知道一定存在父级组件,所以往上查找起来会非常方便。
指令生命周期 hook 的调用时机
在 Vue 中,可以定义指令:
Vue.directive('mydirective', {
bind() {},
inserted() {},
update() {},
componentUpdated() {},
unbind() {}
});
指令中有五个钩子函数,要搞清楚这五个函数的具体执行时机,得结合 Vue 的 diff 过程来看。
在 diff 过程中,会对同级相同类型的节点进行对比更新,实际上就是对老的虚拟 DOM 节点( oldVnode )和新的虚拟 DOM 节点( newVnode )进行对比更新。
如果是第一次渲染,那么 oldVnode 会被设置成一个空节点( emptyVnode ),方便复用对比更新逻辑。
这个新老虚拟节点的比对过程,自然也包括虚拟节点上的指令的比对。在对指令进行对比的时候,会确保虚拟节点对应的真实 DOM 节点已经创建出来了。
创建流程
如果是创建流程,那么就是 oldEmptyVnode 和 newVnode 对比,其中 newVnode 上面已经关联好了相应的 DOM 节点,此时直接就调用 bind
钩子函数了。
然后在 DOM 节点插入父 DOM 节点之后,就调用 inserted
钩子函数。
bind
只会在指令和 DOM 节点绑定的时候才会被调用。
inserted
只会在 DOM 节点插入到父 DOM 节点时才会被调用。
更新流程
如果某个组件数据发生了变化,需要调用 render 方法重新渲染,那么这就会引起一个在组件范围内的更新流程,该组件下的虚拟节点树(直观感受就是组件模板里面写的那些节点)就会进行新老比对,走 diff 流程。
如果碰到带指令的 VNode ,就要进行指令 diff 了,在这个过程中就会调用 updated
钩子函数。
然后执行后续 VNode 比对,等都 diff 完了之后,就会立即调用之前带指令 VNode 的 componentUpdated
钩子函数了。
解绑销毁
在指令与 DOM 节点解除绑定的时候,会调用 unbind
钩子函数。
实例
流程理论描述总是苍白的,有时候很难让人快速理解,所以此处用一些简单的例子进行说明。
基本例子
import Vue from 'vue';
Vue.directive('dir', {
bind(el) {
console.log('dir bind');
console.log(!!el.parentNode);
},
inserted(el) {
console.log('dir inserted');
console.log(!!el.parentNode);
},
update(el) {
console.log('dir update');
console.log('-----', el.textContent);
},
componentUpdated(el) {
console.log('dir componentUpdated');
console.log('-----', el.textContent);
},
unbind(el) {
console.log('dir unbind');
console.log(!!el.parentNode);
}
});
Vue.component('Test', {
props: {
name: String,
shouldBind: Boolean
},
template: `<div><b>{{ name }}</b><span v-if="shouldBind" v-dir>{{ name }}</span></div>`
});
new Vue({
el: '#app',
data() {
return {
name: '',
shouldBind: true
};
},
mounted() {
setTimeout(() => {
this.name = 'yibuyisheng';
}, 1000);
setTimeout(() => {
this.shouldBind = false;
}, 2000);
},
template: '<Test :name="name" :should-bind="shouldBind" />'
});
在上述例子中,构造了一个自定义指令 dir
,然后在每个钩子函数里面都打印各自的一些内容。
在 Test 组件中,有一个 span 元素使用了 dir
指令,并且该元素受 shouldBind 变量控制,如果该变量为假值,那么指令和 DOM 元素就会解除绑定。组件模板中访问了 name ,方便通过改变 name 引起组件重新 render 。
执行上述代码,可以看到如下输出:
dir bind
false
dir inserted
true
dir update
-----
dir componentUpdated
----- yibuyisheng
dir unbind
false
在初始化 diff 的时候, name 为空字符串, shouldBind 为 true ,那么渲染出来的 DOM 树为:
<div><b></b><span></span></div>
在这个过程中, dir
指令要与 span 元素绑定,所以会调用 bind
钩子函数,输出 dir bind
。同时在 bind
的时候, span 元素还没有被插入父元素( div )中,因此输出了 false
。
在 span 元素插入父元素( div )之后,会马上调用 inserted
钩子函数,输出 dir inserted
和 true
。
过了一秒之后, name 值变为 yibuyisheng
,触发了 Test 组件调用 render ,触发 diff 流程。在做 span 元素对应的新老虚拟节点对比的时候,就会调用 dir
指令的 update
钩子函数,输出 dir update
,但是此时 name 数据还没有更新到 DOM 树中去,因此拿到的 span 的 textContent 还是 -----
,输出 -----
。
同步 diff 走完子孙虚拟节点之后, name 的值已经被更新到 DOM 树中去了,此时会调用 componentUpdated
钩子函数,输出 dir componentUpdated
和 ----- yibuyisheng
。
再过一秒之后, shouldBind 变为 false ,触发 Test 组件的 render ,继而走 diff 流程。在 span 元素的指令 diff 过程中,发现 span 元素应当被移除,因此会解绑 span 元素和指令,所以会调用 dir 的 unbind
钩子函数,输出 dir unbind
,同时因为 span 元素已经被移除了,所以也不存在父元素了,最终输出 false
。
DOM 节点复用
指令钩子函数的这种机制,结合 diff 算法中的 DOM 节点复用,会有一点意想不到的结果:
<template>
<section>
<div v-if="someCondition" a="1"></div>
<div v-else v-some-directive></div>
</section>
</template>
<script>
export default {
directives: {
'some-condition': {
bind() {
console.log('bind');
},
inserted() {
console.log('inserted');
},
unbind() {
console.log('unbind');
}
}
},
data() {
return {
someCondition: true
};
},
mounted() {
this.$el.firstElementChild.__id = 1;
setTimeout(() => {
this.someCondition = false;
console.log(this.$el.firstElementChild.__id);
}, 1000);
setTimeout(() => {
this.someCondition = true;
console.log(this.$el.firstElementChild.__id);
}, 2000);
setTimeout(() => {
this.someCondition = false;
console.log(this.$el.firstElementChild.__id);
}, 3000);
}
};
</script>
上述代码的输出为:
1
bind
inserted
1
unbind
1
bind
inserted
从输出结果中发现, this.$el.firstElementChild.__id
的值全部是 1 ,说明整个过程只有一个 div 元素, div 元素被复用了。
示例中,对第一个 div 元素加了一个 a="1"
属性,主要是为了保证两个 div 虚拟节点能被判定为同类型的虚拟节点。
在初始化的时候, someCondition 为 true ,对应模板中的 v-if 分支生效。
一秒后, someCondition 为 false ,对应模板中的 v-else 分支生效,此时因为两个 div 虚拟节点是同类型的,因此会复用之前生成的 div DOM 元素,同时将 v-some-directive 指令与该元素关联起来,因此输出了第一组 bind
、 inserted
。
再过一秒后, someCondition 为 true ,对应模板中 v-if 分支生效, v-else 分支生效,同样复用之前的 div DOM 元素,同时将 v-some-directive 与 div DOM 元素解绑,调用指令的 unbind 钩子函数,输出 unbind
。
再过一秒, someCondition 变为 true ,重复前述过程。
这里要注意,在官方文档中,关于 inserted 钩子函数的描述是这样的:
inserted
:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
从上面这个例子可以看出,这句描述是非常不严谨的,因为在第三秒的时候,并没有发生被绑定元素被插入父节点的过程,但是却调用了 inserted 钩子函数。