3

在开发一般的业务来说,不需要知道 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 insertedtrue

过了一秒之后, 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 指令与该元素关联起来,因此输出了第一组 bindinserted

再过一秒后, someCondition 为 true ,对应模板中 v-if 分支生效, v-else 分支生效,同样复用之前的 div DOM 元素,同时将 v-some-directive 与 div DOM 元素解绑,调用指令的 unbind 钩子函数,输出 unbind

再过一秒, someCondition 变为 true ,重复前述过程。

这里要注意,在官方文档中,关于 inserted 钩子函数的描述是这样的:

inserted:被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。

从上面这个例子可以看出,这句描述是非常不严谨的,因为在第三秒的时候,并没有发生被绑定元素被插入父节点的过程,但是却调用了 inserted 钩子函数。


yibuyisheng
480 声望3 粉丝

前端工程师