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

前言

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

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

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

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

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

在这篇文章中,我将会带你们深刻Vue的源码,从源码中分析Vue的生命周期github

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

Vue版本:2.5.21vue-router

源码概览

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

合并配置项

从上面的图中能看到,在生命周期中第一件事就是合并配置项,而对于根实例和组件实例,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 技术揭秘

相关文章
相关标签/搜索