[Vue.js进阶]从源码角度剖析Vue的生命周期

5,949 阅读10分钟

前言

使用Vue在日常开发中会频繁接触和使用生命周期,在官方文档中是这么解释生命周期的:

每个 Vue 实例在被创建时都要经过一系列的初始化过程——例如,需要设置数据监听、编译模板、将实例挂载到 DOM 并在数据变化时更新 DOM 等。同时在这个过程中也会运行一些叫做生命周期钩子的函数,这给了用户在不同阶段添加自己的代码的机会。

好比人的生老病死的过程,Vue同样也有从组建初始化到组件挂载,组件更新,组件销毁的一系列过程,而生命周期钩子,是一个函数,可以让开发者在Vue到达某个时间段的时候做一些事情

最常见的就是在mounted钩子中发送ajax请求获取当前的页面组件所需要的数据

但是对于Vue.js进阶来说,只知道生命周期的拼写和对应的触发时机肯定是不够的,为什么钩子函数不能是一个箭头函数,为什么在data中有时候无法获取定义的数据,我们通过this获取data中的数据真的直接保存在this下了吗,Vue又是怎么做到无感知的事件监听/事件解绑

在这篇文章中,我将会带大家深入Vue的源码,从源码中分析Vue的生命周期

文中的源码截图只保留核心逻辑 完整源码地址

Vue版本:2.5.21

源码概览

当我们在main.js中实例化Vue的时候,会经过一些逻辑,然后进入到_init函数开始Vue的生命周期,其实从这些函数的命名方式中就能大致看出Vue是如何运行的了,接下来我们逐个分析每个函数具体做了什么

合并配置项

从上面的图中能看到,在生命周期中第一件事就是合并配置项,而对于根实例和组件实例,Vue的处理方式是不同的(在main.js中new Vue生成的是根实例,其余全部都是组件实例),根实例传入的options参数里不会有_isComponent属性,反之为true(实例化的时机不同,传入的参数也不同,感兴趣的朋友可以查看相关实例化的文章)

为了不必要的干涉,这里没有引入vue-router,vuex

根实例合并配置项

对于根实例会走false的逻辑,进入mergeOptions函数,合并Vue的各个配置项options,比如mixins,props,methods,watch,computed,生命周期钩子等等,这是整个项目中第一次的合并配置。Vue会将所有的合并策略都保存在一个strats对象中,然后依次遍历当前实例和parent的同一个属性,再去starts找那个属性对应的合并策略

通过断点可以看到strats保存了很多合并的策略

我们没有必要每个合并策略都去看一遍,尽量把精力放在整个流程中,不要捡了芝麻丢了西瓜。第一次的合并中,Vue会通过resolveConstructorOptions(vm.constructor)获取Vue构造器的静态属性options作为parent,这个options包含了一些预先设置好的配置项,而child就是我们给根实例实例化的时候传入的一些参数,对应例子中上图的render函数

Vue预先设置的配置项作为第一次的parent:

根实例实例化传入的参数:

根实例的合并策略其实很简单,主要就是把Vue框架内置的一些配置项和开发者在main.js中实例化Vue构造器传入的参数进行一次简单的合并,作为根实例的$options属性

组件实例合并配置项

组件实例合并配置项并不在_init函数中,因为组件实例和根实例不同,组件实例是由组件构造器实例化的,而根实例是由Vue构造器实例化的,而组件构造器又是继承自Vue的它需要通过Vue.extend方法去继承Vue构造函数,我画了张图方便理解

Vue这么做符合面向对象的设计模式,一个组件实质上是一个构造器函数(进一步可以认为是一个class),这样在一个页面中引入多个相同的组件只需要多次实例化组件构造器就可以了,并且可以做到实例之间互相独立

而面向对象另外一个好处就是可以实现继承,体现在Vue框架中则是将组件构造器继承Vue构造器,从而组件构造器能够获得Vue构造器内置的一些配置项

组件实例合并配置项在src/core/global-api/extend.js,同样会调用mergeOptions组件实例合并配置项会将Vue框架内置的配置项和当前组件配置项进行合并并赋值给组件构造器的静态属性options

再次回到mergeOptions中,这里就只例举一个生命周期的合并策略,直接贴上源码并附上流程图方便理解

这里我用了父级而不是父组件,因为Vue的组件一般继承自Vue构造函数而不是父组件,通过流程图可以发现,Vue会保证生命周期函数始终是一个数组,并且以父=>子的顺序排列的,Vue在执行某个生命周期的时候会遍历这个数组依次执行函数,所以当我们在Vue构造器和组件构造器中的同一个生命周期里都定义了生命周期函数,会先执行Vue构造器中的那个

继承了Vue构造器后才会实例化子组件生成组件实例,再进入到_init函数,这个时候_isComponent为true会执行initInternalComponent,它会给组件实例创建$options属性,指向子组件构造器的静态属性options,这样就能够通过组件实例的$options属性访问到当前组件的配置项以及Vue框架内置的配置项(包括全局组件,全局混入)

小结

  • 生命周期中第一件事就是合并配置项,对于根实例和组件实例合并的时机不同
  • 根实例是在new Vue的时候进行合并,将Vue内置的配置项和new Vue传入的配置项进行合并
  • 对于组件实例来说,先会创建子组件的构造器,并且调用Vue.extend继承Vue构造器,继承的时候将Vue内置的配置项和组件配置项进行合并,并将结果保存在构造器的options属性中,之后在创建组件实例的时候进入initInternalComponent方法会将组件实例的$options指向组件构造器的options属性
  • Vue框架会根据不同的配置执行不同的合并策略

代理开发环境的错误

非生产环境下会进入initProxy函数,通过ES6的Proxy给vm实例做一层拦截,主要作用是给开发环境下一些不合理的配置做出一些自定义的警告

上面的报错很多开发者都遇到过,其实就是在这个时候通过Proxy的has拦截器,当某个属性不在vm实例上却被模版引用的时候,Vue会给出一些友好的提示

初始化自定义事件

随后进入initLifecycle,这部分没什么好讲的,初始化实例的一些生命周期的状态和一些额外属性,接着会进入初始化组件的自定义事件

initEvents只会挂载自定义事件,即组件中使用v-on监听的非native的事件(原生的DOM事件并非在initEvents中挂载)。Vue会把这些父组件中声明的自定义的事件保存在子组件的_parentListeners属性中(vm是子组件的组件实例,_parentListeners是在initInternalComponent中定义的)

进入updateComponentListeners,发现Vue会调用add函数注册所有的自定义事件,而对于组件来说add函数就会调用$on来达到监听自定义事件的效果

//https://github.com/vuejs/vue/blob/dev/src/core/instance/events.js#L24
function add (event, fn) {
  target.$on(event, fn)
}

//https://github.com/vuejs/vue/blob/dev/src/core/vdom/helpers/update-listeners.js#L83
//调用add注册自定义事件(后面3个参数可忽略)
add(event.name, cur, event.capture, event.passive, event.params)

beforeCreate

添加完自定义事件后,进入initRender,定义插槽和给render函数的参数createElement,另外会将Vue的$attrs,$listeners变成响应式的属性

接着会执行callHook(vm, 'beforeCreate'),从字面上来看就能猜出Vue在这个时候会调用beforeCreate这个生命周期函数,在之前合并配置项的时候就提到,生命周期函数最终会被包裹成一个数组,所以事实上Vue也支持这么写

callHook函数会根据传入的参数拿到$options属性中对应的生命周期函数组成的数组,这里传入了beforeCreate,所以会获得beforeCreate中定义的所有生命周期函数,之后顺序遍历并且用call方法给每个生命周期函数绑定了this上下文,这就是为什么生命周期函数不能使用剪头函数书写的原因

初始化数据

接着执行initInjections,这部分是用来初始化inject这个api,由于日常开发使用频率较少就不详细解释了(其实是我懒得研究-.-)

随后会进入另外一个关键的函数initState,它会依次初始化props,methods,data,computed,watch,我们一个个来讲解

props

组件之间通信的时候,父组件给子组件传参,子组件需要定义props来接受父组件传过来的属性,而Vue规定,子组件是不能修改父组件传来的props,因为这违背了单项数据流,会导致组件之间非常难以管理,如果在子组件修改了props,Vue会发出一个警告

而Vue又是怎么知道开发者修改了props的属性呢?原因还是利用了访问器描述符setter

了解过响应式原理的朋友应该对这个有所熟悉,Vue会将props对象变成一个响应式对象,并且第四个参数是一个自定义的setter,当props被修改了会触发这个setter,一单违背了单项数据流时就会报出这个警告

methods

对于methods,Vue会定义一些开发过程中的不规范的警告,随后会将所有的method绑定vm实例,这样我们就可以直接通过this获取当前的vm实例

data

到了最关键的data,data中一般保存的是当前组件需要使用的数据,除了根实例之外,组件实例的data一般都是一个函数,因为JS引用类型的特点,如果使用对象,当存在多个相同的组件,其中一个组件修改了data数据,会反映到所有的组件。当data作为一个函数返回一个对象时,每次执行都会生成一个新的对象,可以有效的解决这个问题

初始化data会执行initData这个函数,内部会执行定义的data函数并且把当前实例作为this值,并且赋值给_data这个内部属性,值得注意的是,在执行data函数的过程中是获取不到computed中的数据,因为computed中的数据此时还没初始化

随后执行proxy函数,它的作用是将vm._data的属性映射到vm属性上,起到了"代理"的作用,这样做是为了在开发过程中直接书写this[key]的形式,其原理依旧是利用了getter/setter,当我们访问this[key]的时候会触发getter,直接指向this._data[key],setter同理

有人会问,那为啥不直接写在vm实例上呢?因为我们需要将数据放在一个统一的对象上进行管理,为的是下一步把_data通过observe变成一个响应式对象。而为了在开发的时候书写更加简洁,Vue采取了这种方法,非常的讨巧

computed

到了初始化computed,Vue会给每个计算属性生成一个computed watcher,只有当这个计算属性的依赖项改变了才会去通知computed watcher更新这个计算属性,从而既能达到实时更新数据,又不会浪费性能,也是Vue非常棒的功能

watch

初始化watch的时候最终会调用$watch方法,生成一个user watcher,当监听的属性发生改变就会立即通知user watcher执行回调

created

再调用initProvide初始化provide后就会执行callHook(vm, 'beforeCreate'),和beforeCreate一样,依次遍历定义在$options上的created数组,执行生命周期函数

至此整个组件创建完毕,其实这个时候就可以和后端进行交互获取数据了,但是对于真正的DOM节点还没有被渲染出来,一些需要和DOM的交互操作还无法在created钩子中执行,即无法在created钩子中有操作生成视图的DOM

挂载过程

回到_init函数,已经到了最后一行,会判断$options是否有el属性,在Vue-cli2的时候,cli会自动在new Vue的时候传入el参数,而对于Vue-cli3并没有这么做,而是生成根实例后主动调用$mount并传入了挂载的节点,其实两者都是一样的,也可以使用$mount来实现组件的手动挂载

Vue-cli2:

Vue-cli3:

$mount最终会执行mountComponent这个函数

刚刚从_init的长篇大论中逃出来,又要跳进mountComponent这个坑

组件挂载我这里不会展开详解,尽量把重心放在生命周期方面,有兴趣的朋友可以自行了解,或者看我底下的链接

beforeMount

当组件执行$mount并且拥有挂载点和渲染函数的时候,就会触发beforeMount的钩子,准备组件的挂载

渲染视图的函数updateComponent

之后Vue会定义一个updateComponent函数,这个函数是整个挂载的核心,它由2部分组成,_render函数和_update函数

  • render函数最终会执行之前在initRender定义的createElement函数,作用是创建vnode
  • update函数会将上面的render函数生成的vnode渲染成一个真实的DOM树,并挂载到挂载点上

第一次执行updateComponent会渲染出整个DOM树,这个时候页面就完整的被展现了

渲染watcher

然后会实例化一个"渲染watcher",将updateComponent作为回调函数传入,内部会立即执行一次updateComponet函数

watcher顾名思义是用来观察的,渲染watcher简而言之,就是会观察模版中依赖变量的是否变化来决定是否需要刷新页面,而updateComponet就是一个用来更新页面的函数,所以将这个函数作为回调传入。对于模版中的响应式变量(下图中的变量a)内部都会保存这个渲染watcher(因为这些变量都有可能修改视图),一旦变量被修改了就会触发setter,最后都会再次执行updateComponent函数来刷新视图

mounted

实例化渲染watcher渲染出页面后会进入一个判断,这里要注意的是,只有根实例才会为true并且触发mounted钩子,那组件实例什么时候触发mounted钩子呢?

这里先给出答案,在src/core/vdom/create-component.js的insert钩子(组件专属的vnode钩子),同时Vue会声明一个insertedVnodeQueue数组,保存所有的组件vnode,每当一个组件vnode被渲染成DOM节点就会往这个数组里添加一个vnode元素,当组件全部渲染完毕后,会以子=>父的顺序依次触发mounted钩子(最先触发最里层组件的mounted钩子)。随后再回到_init方法,最后触发根实例的mounted钩子,具体为什么会这么做有兴趣的同学可以再深入研究

至此所有的数据都被初始化,并且渲染出了DOM节点,接下来会介绍组件更新和组件销毁的过程

组件更新

回到mountComponent那张图,在实例化渲染watcher的时候,Vue会给渲染watcher传入一个对象,对象包含了一个before方法,执行before方法就会执行beforeUpdate钩子,那什么时候执行这个方法呢?

一旦模版的依赖的变量发生了变化,说明即将改变视图,会触发setter然后执行渲染watcher的回调,即updateComponent刷新视图,在执行这个回调前,Vue会查看是否有before这个方法,如果有则会优先执行before,然后再执行updateCompont刷新视图

Vue会将所有的watcher放入一个队列,flushSchedulerQueue会依次遍历这些watcer,而渲染watcher会有一个before方法,从而触发beforeUpdate钩子

然后当所有的watcher都遍历过之后,代表数据已经更新完毕,并且视图也刷新了,此时会调用callUpdatedHooks,执行updated钩子

组件销毁

组件销毁的前提是发生了视图更新,Vue会判断生成新视图的vnode和旧视图对应的vnode的区别,然后删除那些视图中不需要渲染的节点,这个过程最终会调用实例的$destroy方法,对应源代码的src/core/instance/lifecycle.js

依次按照顺序执行:

  1. 首先会直接执行beforeDestory的钩子,表示准备开始销毁节点,此时是可以和当前组件实例交互的最后时机
  2. 随后会找到当前组件的父节点,从父节点的children属性中删除当前的节点
  3. 对渲染watcher进行注销(vm._watcher存放的是每个组件唯一的渲染watcher)
  4. 对其他的watcher进行注销(user watcher,computed watcher)
  5. 清除这个实例渲染出的DOM节点
  6. 执行destroyed钩子
  7. 注销所有的监听事件($off不传参数会清空所有的监听事件)

总结

至此整个Vue的生命周期结束了,最后再总结一下每个生命周期主要都做了什么事情,严格按照Vue内部的执行顺序罗列

  • beforeCreate:将开发者定义的配置项和Vue内部的配置项进行合并,初始化组件的自定义事件,定义createElement函数/初始化插槽
  • created:初始化inject,初始化所有数据(props -> methods -> data -> computed -> watch),初始化provide
  • beforeMount:寻找是否有挂载的节点,根据render函数准备开始渲染页面/实例化渲染watcher
  • mounted:页面渲染完成
  • beforeUpdate:渲染watcher依赖的变量发生变化,准备更新视图
  • updated:视图和数据全部更新完毕
  • beforeDestroy:注销watcher,删除DOM节点
  • destroyed:注销所有监听事件

事实上要想完全了解Vue的生命周期,还需要了解其他方面的知识点,例如组件挂载,响应式原理,另外可能还需要了解一下Vue的编译原理,每个知识点又可以展开十几个小的知识点,但是当你能够真正理解Vue.js的核心原理,我相信对个人成长来说是一个不小的收获(终于写完了脖子都酸了_:(´°ω°`」 ∠):_)

砥砺前行 未来可期

参考资料

Vue.js 技术揭秘