August 11, 2019

vue组件的生命周期与hook执行顺序

这篇文章会先从最基础的vue组件的生命周期开始阐述,后续结合keep-alivevue-router来梳理一下平常用到的生命周期hook,加强印象。

vue 组件

这是一个老生常谈的问题,有时候回顾一下,会有另外的收获;先引用官方的图:

vue lifecycle

图片引用地址: https://cn.vuejs.org

vue的生命周期分几类:

  • create
  • mount
  • update
  • destroy
  • ...

整体初始化过程

图中简单描述了生命周期过程,我们从代码上面看一下初始化的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// https://github.com/vuejs/vue/blob/dev/src/core/instance/init.js
// 截一段相对关键的代码,加上简单的注释
// @function initMixin
vm._self = vm
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')

// ...
if (vm.$options.el) {
  vm.$mount(vm.$options.el)
}

beforeCreate 之前

beforeCreate之前,主要做了三个动作:initLifeCycle, initEvents, initRender;这三个动作完成之后再执行beforeCreate的hook函数,这三个函数分别做的事情:

TL;DR

  1. initLifecycle: 建立组件的父子关系,赋值部分标识数据到组件实例,vm.$parent, vm.$refs, vm.$root
  2. initEvents: 初始化组件监听的事件
  3. initRender:初始化 $slot$attr$listener
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 1. initLifecycle (src/core/instance/lifecycle.js)
// @function initLifecycle
export function initLifecycle (vm: Component) {
  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)
  }

  // balabala 初始化很多数据
  vm.$parent = parent
  vm.$root = parent ? parent.$root : vm

  vm.$children = []
  vm.$refs = {}

  vm._watcher = null
  vm._inactive = null
  vm._directInactive = false
  vm._isMounted = false
  vm._isDestroyed = false
  vm._isBeingDestroyed = false
}

// 2. initEvents (src/core/instance/events.js)
// @function initEvents
export function initEvents (vm: Component) {
  vm._events = Object.create(null)
  vm._hasHookEvent = false
  // init parent attached events
  // 初始化组件监听的事件
  const listeners = vm.$options._parentListeners
  if (listeners) {
    updateComponentListeners(vm, listeners)
  }
}


// 3. initRender (src/core/instance/render.js)
// @function initRender
// 中间去掉一些声明变量,主要保留一些赋值到vm的数据
export function initRender (vm: Component) {
  vm._vnode = null // the root of the child tree
  vm._staticTrees = null // v-once cached trees

  //...
  // 赋值 slot 的值与对应的 slot 对应的数据
  vm.$slots = resolveSlots(options._renderChildren, renderContext)
  vm.$scopedSlots = emptyObject

  //...
  // 赋值从组件传过来的属性值与没有显式被组件监听的事件,分别赋值到$attr与$listener
  /* istanbul ignore else */
  if (process.env.NODE_ENV !== 'production') {
    defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, () => {
      !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)
    }, true)
    defineReactive(vm, '$listeners', options._parentListeners || emptyObject, () => {
      !isUpdatingChildComponent && warn(`$listeners is readonly.`, vm)
    }, true)
  } else {
    defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, null, true)
    defineReactive(vm, '$listeners', options._parentListeners || emptyObject, null, true)
  }
}

beforeCreate 到 created

执行完这三个初始化函数,就可以触发beforeCreate的hook函数,可以看到还没有初始化$data的相关数据;在beforeCreatecreated之间,执行的函数有:initInjections, initState, initProvide;

TL;DR

  1. initInjections: 把组件注入的数据,挂载到当前组件实例
  2. initState:初始化data, computed, methods, watcher
  3. initProvide:将provide的数据挂载到组件实例的_provided字段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 1. initInjections (src/core/instance/inject.js)
// @function initInjections
export function initInjections (vm: Component) {
  // 拿到注入的数据
  const result = resolveInject(vm.$options.inject, vm)
  if (result) {
    // 标识inject的属性与方法在当前组件不需要成为 observer,不用监听变化进行响应
    toggleObserving(false)
    Object.keys(result).forEach(key => {
        // ...
        // 绑定注入的数据到当前组件
        defineReactive(vm, key, result[key])
    })

    // 把 observer 的标识位置为 true
    toggleObserving(true)
  }
}

// 2. initState (src/core/instance/state.js)
// @function initState
export function initState (vm: Component) {
  // 初始化依赖的props,methods
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  // 初始化 data
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  // 初始化计算属性
  if (opts.computed) initComputed(vm, opts.computed)
  // 初始化 watch 的数据
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

// 3. initProvide (src/core/instance/inject.js)
// @function initProvide
export function initProvide (vm: Component) {
  const provide = vm.$options.provide
  if (provide) {
   // 对 provide 是函数的情况,执行函数赋值到 _provided;否则直接赋值
    vm._provided = typeof provide === 'function'
      ? provide.call(vm)
      : provide
  }
}

执行完这个三个函数之后,就会触发created的hook函数,这个时候就可以拿到datamethods等数据;现在再回去initMixin函数:

mount 与 update

1
2
3
4
5
6
// 忽略已分析代码
callHook(vm, 'created')
// ... 
if (vm.$options.el) {
  vm.$mount(vm.$options.el)
}

当有el的元素的时候,就触发$mount方法,否则到后面主动调用方法再触发;这个$mount方法在:src/core/instance/lifecycle.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 定义挂载组件的方法
// mountComponent (src/core/instance/lifecycle.js)
// @function mountComponent
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  // 没有 render 函数
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
    // ...
  }
  // 触发 beforeMount hook 函数
  callHook(vm, 'beforeMount')

  // ...
  // 定义数据发生变化的回调方法
  updateComponent = () => {
    // 调用该方法更新当前的组件,执行完毕之后,需要通过 scheduler 来触发 updated 的 hook,为什么不是马上触发hook,是因为需要保证子组件都更新了,才调用当前组件的 updated,详细可以看一下源码,位置如下
    // src/core/instance/lifecycle.js 
    // @function Vue.prototype._update
    vm._update(vm._render(), hydrating)
  }

  // we set this to vm._watcher inside the watcher's constructor
  // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  // component's mounted hook), which relies on vm._watcher being already defined
  // 新建一个 watcher,用来监听数据发生变化
  // 注意 beforeUpdate 的hook也是在这里进行监听调用
  new Watcher(vm, updateComponent, noop, {
    // 在执行 updateComponent 之前先执行 before 函数,也就是触发 beforeUpdate
    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
  // 挂载的对象如果不是为空,则触发 mounted 回调方法
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

destroy

TL;DR

  1. 触发 beforeDestroy hook
  2. 移除父组件与该组件的引用关系
  3. 实例标识_isBeingDestroyed更改
  4. 移除实例watcher
  5. 移除 vnode 节点
  6. 触发 destroy hook
  7. 移除$el$vnode引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// src/core/instance/lifecycle.js
// @function Vue.prototype.$destroy
Vue.prototype.$destroy = function () {
  const vm: Component = this
  if (vm._isBeingDestroyed) {
    return
  }
  // 触发 beforeDestroy 的 hook
  callHook(vm, 'beforeDestroy')
  vm._isBeingDestroyed = true
  // remove self from parent
  // 移除父组件与当前组件的关系
  const parent = vm.$parent
  if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
    remove(parent.$children, vm)
  }
  // teardown watchers
  // 移除所有watcher
  if (vm._watcher) {
    vm._watcher.teardown()
  }
  let i = vm._watchers.length
  while (i--) {
    vm._watchers[i].teardown()
  }
  // remove reference from data ob
  // frozen object may not have observer.
  if (vm._data.__ob__) {
    vm._data.__ob__.vmCount--
  }
  // call the last hook...
  vm._isDestroyed = true
  // invoke destroy hooks on current rendered tree
  // 移除 vnode 内容
  vm.__patch__(vm._vnode, null)
  // fire destroyed hook
  callHook(vm, 'destroyed')
  // turn off all instance listeners.
  vm.$off()
  // remove __vue__ reference
  if (vm.$el) {
    vm.$el.__vue__ = null
  }
  // release circular reference (#6759)
  if (vm.$vnode) {
    vm.$vnode.parent = null
  }
}

至此,基本的声明周期就差不多了,后续的内容主要是对hook的触发顺序进行一个巩固记忆

基础切换组件

当从一个组件a切换到组件b的时候,执行的顺序是:

  1. b-component beforeCreate
  2. b-component created
  3. b-component beforeMount
  4. a-component beforeDestroy
  5. a-component destroyed
  6. b-component mounted

注意从a切换到b的时候,并不是a的destroy的相关方法马上执行,而是等到b组件的beforeMount函数执行后再调用之前的destroy的相关方法;当旧的组件被销毁之后,再执行新的组件的mounted的挂载方法,因为挂载完毕之后就会显示组件对应的内容

keep-alive

当使用 keep-alive 来缓存组件的时候,keep-alive里面的生命周期会有点不一样;

1
2
3
4
<keep-alive>
    <component-a v-if="componentName === 'component-a'"></component-a>
    <component-b v-if="componentName === 'component-b'"></component-b>
</keep-alive>

当切换不同的componentName变量的时候,在没有使用keep-alive的时候,触发周期如前面所说的;但使用keep-alive之后,一个组件进入的周期就变成了:

  1. beforeCreate
  2. created
  3. beforeMount
  4. mounted
  5. activated

注意这里多了一个activated的hook调用,这一个hook是仅在keep-alive中使用的,表示当前组件被激活;对应这另外一个hook就是,deactivated,表示当前组件被停用,那么从component-a切换到component-b的过程中,生命周期hook调用顺序就变成了:

component-a初始化:

  1. beforeCreate
  2. created
  3. beforeMount
  4. mounted
  5. activated

component-a切换到:component-b

  1. component-b beforeCreate
  2. component-b created
  3. component-b beforeMount
  4. component-a deactivated
  5. component-b mounted
  6. component-b activated

可以注意到,这个时候没有了之前看到的destroy类的触发,而是deactivatedmounted之后,也是跟之前类似,也会调用activated方法

然后从component-b再切换到: component-a:

  1. component-b deactivated
  2. component-a activated

因为这个时候component-a已经初始化了,所以没有触发createmount类的hook,而是先component-b停用,再component-a激活;后续不断切换也是只反复调用这两个hook...

keep-alive的最大缓存数量 max

keep-alive可以设置一个最大缓存的数量,当超出设置的最大缓存的数量,则最久没有被访问到的实例会被销毁:

1
2
3
4
5
<keep-alive :max="2">
    <component-a v-if="componentName === 'component-a'"></component-a>
    <component-b v-if="componentName === 'component-b'"></component-b>
    <component-c v-if="componentName === 'component-c'"></component-c>
</keep-alive>

component-a切换到component-b的hook调用顺序与没有设置max类似;再从component-b切换到component-c的时候,调用顺序为:

  1. component-a beforeDestroy
  2. component-a destroyed
  3. component-c beforeCreate
  4. component-c created
  5. component-c beforeMount
  6. component-b deactivated
  7. component-c mounted
  8. component-c activated

由于设置了最大的缓存数量为2,当切换到component-c的时候,首先触发的是component-adestroy的相关方法;再执行初始化component-c,然后component-b失活

  1. component-a => component-b: 与没有max一致
  2. component-b => component-c:首先component-a的destroy相关hook被调用,后续的调用顺序是先初始化component-c,再让component-b失活
  3. component-c => component-b: 仅执行deactivatedactivated的方法
  4. component-b => component-a;首先componet-c的destroy相关hook被调用,后续hook调用顺序是先初始化component-a,再让component-b失活

router

在vue-router当中,定义了好多hook,称之为导航守卫,现在简单结合一下组件的生命周期梳理一下:

实验例子:

1
2
3
4
5
6
7
8
9
<ul>
  <li>
    <router-link :to="{name: 'foo'}">jump to foo</router-link>
  </li>
  <li>
    <router-link :to="{name: 'bar'}">jump to bar</router-link>
  </li>
</ul>
<router-view></router-view>

当点击跳转到/foo的时候,foo组件的生命周期与路由钩子触发顺序为:

  1. beforeRouteEnter 优先触发路由的导航守卫 hook
  2. beforeCreate
  3. created
  4. beforeMount
  5. mounted

需要注意的是,有时候我们在beforeRouteEnter的钩子做一些处理,例如判断用户是否有权限进入该组件,没有权限就跳转去别的页面,有权限则进入页面,伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import router from 'router' // vue-router object
export default {
    beforeRouteEnter (to, from, next) {
        console.log('beforeRouteEnter')
        requestPermission().then(allowAccessed => {
            if (allowAccessed) {
                next(vm => {
                    console.log('beforeRouteEnter next')
                    vm.allow = true
                })
            } else {
                router.push({ name: 'homepage' })
            }
        })
    }
}

那么这个时候触发的顺序为:

  1. beforeRouteEnter
  2. beforeCreate
  3. created
  4. beforeMount
  5. mounted
  6. beforeRouteEnter next

next回调函数是最后才执行;因为在next所传的函数里面,已经可以拿到当前组件的实例

ok,回到之前的例子,然后点击从foo跳转到/barfoobar组件的生命周期与路由钩子触发顺序为:

  1. foo component beforeRouteLeave
  2. bar component beforeRouteEnter
  3. bar component beforeCreate
  4. bar component created
  5. bar component beforeMount
  6. foo component beforeDestroy
  7. foo component destroyed
  8. bar component mounted

可以看到先触发foo beforeRouteLeave再到bar beforeRouteEnter;而后续3-8点,与之前组件切换类似

keep-alive 包含 router-view

实验代码更改为:

1
2
3
4
5
6
7
8
9
10
11
<ul>
  <li>
    <router-link :to="{name: 'foo'}">jump to foo</router-link>
  </li>
  <li>
    <router-link :to="{name: 'bar'}">jump to bar</router-link>
  </li>
</ul>
<keep-alive>
  <router-view></router-view>
</keep-alive>

首次进入/foo路由

  1. beforeRouteEnter
  2. beforeCreate
  3. created
  4. beforeMount
  5. mounted
  6. activated

进入的顺序没有特别,最后多了一个activated的调用,与之前使用keep-alive类似

然后从/foo进入/bar

  1. foo component beforeRouteLeave
  2. bar component beforeRouteEnter
  3. bar component beforeCreate
  4. bar component created
  5. bar component beforeMount
  6. foo component deactivated
  7. bar component mounted
  8. bar component activated

再从/bar进入/foo

  1. bar component beforeRouteLeave
  2. foo component beforeRouteEnter
  3. bar component deactivated
  4. foo component activated

路由的优先级始终是在最高级别,然后再到组件的初始化过程;若组件已经初始化且在缓存当中,则到keep-alive的activated的相关hook