一起学习vue源码 - Vue2.x的生命周期(初始化阶段)
作者:小土豆biubiubiu
博客园:https://www.cnblogs.com/HouJiao/
掘金:https://juejin.im/user/58c61b4361ff4b005d9e894d
简书:https://www.jianshu.com/u/cb1c3884e6d5
微信公众号:土豆妈的碎碎念(扫码关注,一起吸猫,一起听故事,一起学习前端技术)
欢迎大家扫描微信二维码进入群聊讨论(若二维码失效可添加微信JEmbrace拉你进群):
码字不易,点赞鼓励哟~
温馨提示
本篇文章内容过长,一次看完会有些乏味,建议大家可以先收藏,分多次进行阅读,这样更好理解。
前言
相信很多人和我一样,在刚开始了解和学习Vue
生命明周期的时候,会做下面一系列的总结和学习。
总结1
Vue
的实例在创建时会经过一系列的初始化:
设置数据监听、编译模板、将实例挂载到DOM并在数据变化时更新DOM等
总结2
在这个初始化的过程中会运行一些叫做"生命周期钩子"的函数:
beforeCreate:组件创建前
created:组件创建完毕
beforeMount:组件挂载前
mounted:组件挂载完毕
beforeUpdate:组件更新之前
updated:组件更新完毕
beforeDestroy:组件销毁前
destroyed:组件销毁完毕
示例1
关于每个钩子函数里组件的状态示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue的生命周期</title>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>
<body>
<div id="app">
<h3>{{info}}</h3>
<button v-on:click='updateInfo'>修改数据</button>
<button v-on:click='destoryComponent'>销毁组件</button>
</div>
<script>
var vm = new Vue({
el: '#app',
data: {
info: 'Vue的生命周期'
},
beforeCreate: function(){
console.log("beforeCreated-组件创建前");
console.log("el:");
console.log(this.$el);
console.log("data:");
console.log(this.$data);
},
created: function(){
console.log("created-组件创建完毕");
console.log("el:");
console.log(this.$el);
console.log("data:");
console.log(this.$data);
console.log("info:");
console.log(this.$data.info);
},
beforeMount: function(){
console.log("beforeMounted-组件挂载前");
console.log("el:");
console.log(this.$el);
console.log("data:");
console.log(this.$data);
console.log("info:");
console.log(this.$data.info);
},
mounted: function(){
console.log("mounted-组件挂载完毕");
console.log("el:");
console.log(this.$el);
console.log("data:");
console.log(this.$data);
console.log("info:");
console.log(this.$data.info);
},
beforeUpdate: function(){
console.log("beforeUpdate-组件更新前");
console.log("el:");
console.log(this.$el);
console.log("data:");
console.log(this.$data);
console.log("info:");
console.log(this.$data.info);
},
updated: function(){
console.log("updated-组件更新完毕");
console.log("el:");
console.log(this.$el);
console.log("data:");
console.log(this.$data);
console.log("info:");
console.log(this.$data.info);
},
beforeDestroy: function(){
console.log("beforeDestory-组件销毁前");
//在组件销毁前尝试修改data中的数据
this.info="组件销毁前";
console.log("el:");
console.log(this.$el);
console.log("data:");
console.log(this.$data);
console.log("info:");
console.log(this.$data.info);
},
destroyed: function(){
console.log("destoryed-组件销毁完毕");
//在组件销毁完毕后尝试修改data中的数据
this.info="组件已销毁";
console.log("el:");
console.log(this.$el);
console.log("data:");
console.log(this.$data);
console.log("info:");
console.log(this.$data.info);
},
methods: {
updateInfo: function(){
// 修改data数据
this.info = '我发生变化了'
},
destoryComponent: function(){
//手动调用销毁组件
this.$destroy();
}
}
});
</script>
</body>
</html>
总结3:
结合前面示例1的运行结果会有如下的总结。
组件创建前(beforeCreate)
组件创建前,组件需要挂载的DOM元素el和组件的数据data都未被创建。
组件创建完毕(created)
创建创建完毕后,组件的数据已经创建成功,但是DOM元素el还没被创建。
组件挂载前(beforeMount):
组件挂载前,DOM元素已经被创建,只是data中的数据还没有应用到DOM元素上。
组件挂载完毕(mounted)
组件挂载完毕后,data中的数据已经成功应用到DOM元素上。
组件更新前(beforeUpdate)
组件更新前,data数据已经更新,组件挂载的DOM元素的内容也已经同步更新。
组件更新完毕(updated)
组件更新完毕后,data数据已经更新,组件挂载的DOM元素的内容也已经同步更新。
(感觉和beforeUpdate的状态基本相同)
组件销毁前(beforeDestroy)
组件销毁前,组件已经不再受vue管理,我们可以继续更新数据,但是模板已经不再更新。
组件销毁完毕(destroyed)
组件销毁完毕,组件已经不再受vue管理,我们可以继续更新数据,但是模板已经不再更新。
组件生命周期图示
最后的总结,就是来自Vue
官网的生命周期图示。
那到这里,前期对Vue
生命周期的学习基本就足够了。那今天,我将带大家从Vue源码
了解Vue2.x的生命周期的初始化阶段
,开启Vue生命周期
的进阶学习。
Vue官网的这张生命周期图示非常关键和实用,后面我们的学习和总结都会基于这个图示。
创建组件实例
对于一个组件,Vue
框架要做的第一步就是创建一个Vue
实例:即new Vue()
。那new Vue()
都做了什么事情呢,我们来看一下Vue
构造函数的源码实现。
//源码位置备注:/vue/src/core/instance/index.js
import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'
function Vue (options) {
if (process.env.NODE_ENV !== 'production' &&
!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
export default Vue
从Vue构造函数
的源码可以看到有两个重要的内容:if条件判断逻辑
和_init方法的调用
。那下面我们就这两个点进行抽丝破茧,看一看它们的源码实现。
在这里需要说明的是
index.js
文件的引入会早于new Vue
代码的执行,因此在new Vue
之前会先执行initMixin
、stateMixin
、eventsMixin
、lifecycleMixin
、renderMixin
。这些方法内部大致就是在为组件实例定义一些属性和实例方法,并且会为属性赋初值。我不会详细去解读这几个方法内部的实现,因为本篇主要是分析学习
new Vue
的源码实现。那我在这里说明这个是想让大家大致了解一下和这部分相关的源码的执行顺序,因为在Vue
构造函数中调用的_init
方法内部有很多实例属性的访问、赋值以及很多实例方法的调用,那这些实例属性和实例方法就是在index.js
引入的时候通过执行initMixin
、stateMixin
、eventsMixin
、lifecycleMixin
、renderMixin
这几个方法定义的。
创建组件实例 - if条件判断逻辑
if条件判断逻辑如下:
if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue)) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
我们先看一下&&
前半段的逻辑。
process
是node
环境内置的一个全局变量
,它提供有关当前Node.js
进程的信息并对其进行控制。如果本机安装了node
环境,我们就可以直接在命令行输入一下这个全局变量。
这个全局变量包含的信息非常多,这里只截出了部分属性。
对于process的evn属性 它返回当前用户环境信息。但是这个信息不是直接访问就能获取到值,而是需要通过设置才能获取。
可以看到我没有设置这个属性,所以访问获得的结果是undefined
。
然后我们在看一下Vue
项目中的webpack
对process.evn.NODE_EVN
的设置说明:
执行
npm run dev
时会将process.env.NODE_MODE
设置为'development'
执行npm run build
时会将process.env.NODE_MODE
设置为'production'
该配置在Vue项目根目录下的package.json scripts
中设置
所以设置process.evn.NODE_EVN
的作用就是为了区分当前Vue
项目的运行环境是开发环境
还是生产环境
,针对不同的环境webpack
在打包时会启用不同的Plugin
。
&&
前半段的逻辑说完了,在看下&&
后半段的逻辑:this instanceof Vue
。
这个逻辑我决定用一个示例来解释一下,这样会非常容易理解。
我们先写一个function
。
function Person(name,age){
this.name = name;
this.age = age;
this.printThis = function(){
console.log(this);
}
//调用函数时,打印函数内部的this
this.printThis();
}
关于JavaScript
的函数有两种调用方式:以普通函数
方式调用和以构造函数
方式调用。我们分别以两种方式调用一下Person
函数,看看函数内部的this
是什么。
// 以普通函数方式调用
Person('小土豆biubiubiu',18);
// 以构造函数方式创建
var pIns = new Person('小土豆biubiubiu');
上面这段代码在浏览器的执行结果如下:
从结果我们可以总结:
以普通函数方式调用Person,Person内部的this对象指向的是浏览器全局的window对象
以构造函数方式调用Person,Person内部的this对象指向的是创建出来的实例对象
这里其实是JavaScript语言中this指向的知识点。
那我们可以得出这样的结论:当以构造函数
方式调用某个函数Fn
时,函数内部this instanceof Fn
逻辑的结果就是true
。
啰嗦了这么多,if条件判断的逻辑
已经很明了了:
如果当前是非生产环境且没有使用new Vue的方式来调用Vue方法,就会有一个警告:
Vue is a constructor and should be called with the `new`keyword
即Vue是一个构造函数应该使用关键字new来调用Vue
创建组件实例 - _init方法的调用
_init
方法是定义在Vue原型上的一个方法:
//源码位置备注:/vue/src/core/instance/init.js
export function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
// a uid
vm._uid = uid++
let startTag, endTag
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
startTag = `vue-perf-start:${vm._uid}`
endTag = `vue-perf-end:${vm._uid}`
mark(startTag)
}
// a flag to avoid this being observed
vm._isVue = true
// merge options
if (options && options._isComponent) {
// optimize internal component instantiation
// since dynamic options merging is pretty slow, and none of the
// internal component options needs special treatment.
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
initProxy(vm)
} else {
vm._renderProxy = vm
}
// expose real self
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')
/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
vm._name = formatComponentName(vm, false)
mark(endTag)
measure(`vue ${vm._name} init`, startTag, endTag)
}
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}
Vue
的构造函数所在的源文件路径为/vue/src/core/instance/index.js
,在该文件中有一行代码initMixin(Vue)
,该方法调用后就会将_init
方法添加到Vue的原型对象上。这个我在前面提说过index.js
和new Vue
的执行顺序,相信大家已经能理解。
那这个_init
方法中都干了写什么呢?
vm.$options
大致浏览一下_init
内部的代码实现,可以看到第一个就是为组件实例设置了一个$options
属性。
//源码位置备注:/vue/src/core/instance/init.js
// merge options
if (options && options._isComponent) {
// optimize internal component instantiation
// since dynamic options merging is pretty slow, and none of the
// internal component options needs special treatment.
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
首先if
分支的options
变量是new Vue
时传递的选项。
那满足if
分支的逻辑就是如果options
存在且是一个组件。那在new Vue
的时候显然不满足if
分支的逻辑,所以会执行else
分支的逻辑。
使用
Vue.extend
方法创建组件的时候会满足if
分支的逻辑。
在else分支中,resolveConstructorOptions
的作用就是通过组件实例的构造函数获取当前组件的选项和父组件的选项,在通过mergeOptions
方法将这两个选项进行合并。
这里的父组件不是指组件之间引用产生的父子关系,还是跟
Vue.extend
相关的父子关系。目前我也不太了解Vue.extend
的相关内容,所以就不多说了。
vm._renderProxy
接着就是为组件实例的_renderProxy
赋值。
//源码位置备注:/vue/src/core/instance/init.js
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
initProxy(vm)
} else {
vm._renderProxy = vm
}
如果是非生产环境,调用initProxy
方法,生成vm
的代理对象_renderProxy
;否则_renderProxy
的值就是当前组件的实例。
然后我们看一下非生产环境中调用的initProxy
方法是如何为vm._renderProxy
赋值的。
//源码位置备注:/vue/src/core/instance/proxy.js
const hasProxy = typeof Proxy !== 'undefined' && isNative(Proxy)
initProxy = function initProxy (vm) {
if (hasProxy) {
// determine which proxy handler to use
const options = vm.$options
const handlers = options.render && options.render._withStripped
? getHandler
: hasHandler
vm._renderProxy = new Proxy(vm, handlers)
} else {
vm._renderProxy = vm
}
}
在initProxy
方法内部实际上是利用ES6
中Proxy
对象为将组件实例vm进行包装,然后赋值给vm._renderProxy
。
关于Proxy
的用法如下:
那我们简单的写一个关于Proxy
的用法示例。
let obj = {
'name': '小土豆biubiubiu',
'age': 18
};
let handler = {
get: function(target, property){
if(target[property]){
return target[property];
}else{
console.log(property + "属性不存在,无法访问");
return null;
}
},
set: function(target, property, value){
if(target[property]){
target[property] = value;
}else{
console.log(property + "属性不存在,无法赋值");
}
}
}
obj._renderProxy = null;
obj._renderProxy = new Proxy(obj, handler);
这个写法呢,仿照源码给vm
设置Proxy
的写法,我们给obj
这个对象设置了Proxy
。
根据handler
函数的实现,当我们访问代理对象_renderProxy
的某个属性时,如果属性存在,则直接返回对应的值;如果属性不存在则打印'属性不存在,无法访问'
,并且返回null
。
当我们修改代理对象_renderProxy
的某个属性时,如果属性存在,则为其赋新值;如果不存在则打印'属性不存在,无法赋值'
。
接着我们把上面这段代码放入浏览器的控制台运行,然后访问代理对象的属性:
然后在修改代理对象的属性:
结果和我们前面描述一致。然后我们在说回initProxy
,它实际上也就是在访问vm
上的某个属性时做一些验证,比如该属性是否在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')
我们把最后这几个函数的调用顺序和Vue
官网的生命周期图示
对比一下:
可以发现代码和这个图示基本上是一一对应的,所以_init
方法被称为是Vue实例的初始化方法
。下面我们将逐个解读_init
内部按顺序调用的那些方法。
initLifecycle-初始化生命周期
//源码位置备注:/vue/src/core/instance/lifecycle.js
export function initLifecycle (vm: Component) {
const options = vm.$options
// locate first non-abstract parent
let parent = options.parent
if (parent && !options.abstract) {
while (parent.$options.abstract && parent.$parent) {
parent = parent.$parent
}
parent.$children.push(vm)
}
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
}
在初始化生命周期这个函数中,vm
是当前Vue
组件的实例对象。我们看到函数内部大多数都是给vm
这个实例对象的属性赋值。
以
$
开头的属性称为组件的实例属性
,在Vue
官网中都会有明确的解释。
$parent
属性表示的是当前组件的父组件,可以看到在while
循环中会一直递归寻找第一个非抽象的父级组件:parent.$options.abstract && parent.$parent
。
非抽象类型的父级组件这里不是很理解,有伙伴知道的可以在评论区指导一下。
$root
属性表示的是当前组件的跟组件
。如果当前组件存在父组件
,那当前组件的根组件
会继承父组件的$root
属性,因此直接访问parent.$root
就能获取到当前组件的根组件;如果当前组件实例不存在父组件,那当前组件的跟组件就是它自己。
$children
属性表示的是当前组件实例的直接子组件
。在前面$parent
属性赋值的时候有这样的操作:parent.$children.push(vm)
,即将当前组件的实例对象添加到到父组件的$children
属性中。所以$children
数据的添加规则为:当前组件为父组件的$children
属性赋值,那当前组件的$children
则由其子组件来负责添加。
$refs
属性表示的是模板中注册了ref
属性的DOM
元素或者组件实例。
initEvents-初始化事件
//源码位置备注:/vue/src/core/instance/events.js
export function initEvents (vm: Component) {
// Object.create(null):创建一个原型为null的空对象
vm._events = Object.create(null)
vm._hasHookEvent = false
// init parent attached events
const listeners = vm.$options._parentListeners
if (listeners) {
updateComponentListeners(vm, listeners)
}
}
vm._events
在初始化事件函数中,首先给vm
定义了一个_events
属性,并给其赋值一个空对象。那_events
表示的是什么呢?我们写一段代码验证一下。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue的生命周期</title>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
var ChildComponent = Vue.component('child', {
mounted() {
console.log(this);
},
methods: {
triggerSelf(){
console.log("triggerSelf");
},
triggerParent(){
this.$emit('updateinfo');
}
},
template: `<div id="child">
<h3>这里是子组件child</h3>
<p>
<button v-on:click="triggerSelf">触发本组件事件
</button>
</p>
<p>
<button v-on:click="triggerParent">触发父组件事件
</button>
</p>
</div>`
})
</script>
</head>
<body>
<div id="app">
<h3>这里是父组件App</h3>
<button v-on:click='destoryComponent'>销毁组件</button>
<child v-on:updateinfo='updateInfo'>
</child>
</div>
<script>
var vm = new Vue({
el: '#app',
mounted() {
console.log(this);
},
methods: {
updateInfo: function() {
},
destoryComponent: function(){
},
}
});
</script>
</body>
</html>
我们将这段代码的逻辑简单梳理一下。
首先是child
组件。
创建一个名为child组件的组件,在该组件中使用v-on声明了两个事件。
一个事件为triggerSelf,内部逻辑打印字符串'triggerSelf'。
另一个事件为triggetParent,内部逻辑是使用$emit触发父组件updateinfo事件。
我们还在组件的mounted钩子函数中打印了组件实例this的值。
接着是App
组件的逻辑。
App组件中定义了一个名为destoryComponent的事件。
同时App组件还引用了child组件,并且在子组件上绑定了一个为updateinfo的native DOM事件。
App组件的mounted钩子函数也打印了组件实例this的值。
因为在
App
组件中引用了child
组件,因此App
组件和child
组件构成了父子关系,且App
组件为父组件,child
组件为子组件。
逻辑梳理完成后,我们运行这份代码,查看一下两个组件实例中_events
属性的打印结果。
从打印的结果可以看到,当前组件实例的_events
属性保存的只是父组件绑定在当前组件上的事件,而不是组件中所有的事件。
vm._hasHookEvent
_hasHookEvent
属性表示的是父组件是否通过v-hook:钩子函数名称
把钩子函数绑定到当前组件上。
updateComponentListeners(vm, listeners)
对于这个函数,我们首先需要关注的是listeners
这个参数。我们看一下它是怎么来的。
// init parent attached events
const listeners = vm.$options._parentListeners
从注释翻译过来的意思就是初始化父组件添加的事件
。到这里不知道大家是否有和我相同的疑惑,我们前面说_events
属性保存的是父组件绑定在当前组件上的事件。这里又说_parentListeners
也是父组件添加的事件。这两个属性到底有什么区别呢?
我们将上面的示例稍作修改,添加一条打印信息(这里只将修改的部分贴出来)
。
<script>
// 修改子组件child的mounted方法:打印属性
var ChildComponent = Vue.component('child', {
mounted() {
console.log("this._events:");
console.log(this._events);
console.log("this.$options._parentListeners:");
console.log(this.$options._parentListeners);
},
})
</script>
<!--修改引用子组件的代码:增加两个事件绑定(并且带有事件修饰符) -->
<child v-on:updateinfo='updateInfo'
v-on:sayHello.once='sayHello'
v-on:SayBye.capture='SayBye'>
</child>
<script>
// 修改App组件的methods方法:增加两个方法sayHello和sayBye
var vm = new Vue({
methods: {
sayHello: function(){
},
SayBye: function(){
},
}
});
</script>
接着我们在浏览器中运行代码,查看结果。
从这个结果我们其实可以看到,_events
和_parentListeners
保存的内容实际上都是父组件绑定在当前组件上的事件。只是保存的键值稍微有一些区别:
区别一:
前者事件名称这个key直接是事件名称
后者事件名称这个key保存的是一个字符串和事件名称的拼接,这个字符串是对修饰符的一个转化(.once修饰符会转化为~;.capture修饰符会转化为!)
区别二:
前者事件名称对应的value是一个数组,数组里面才是对应的事件回调
后者事件名称对应的vaule直接就是回调函数
Ok,继续我们的分析。
接着就是判断这个listeners
:假如listeners
存在的话,就执行updateComponentListeners(vm, listeners)
方法。我们看一下这个方法内部实现。
//源码位置备注:/vue/src/core/instance/events.js
export function updateComponentListeners (
vm: Component,
listeners: Object,
oldListeners: ?Object
) {
target = vm
updateListeners(listeners, oldListeners || {}, add, remove, createOnceHandler, vm)
target = undefined
}
可以看到在该方法内部又调用到了updateListeners
,先看一下这个函数的参数吧。
listeners
:这个参数我们刚说过,是父组件中添加的事件。
oldListeners
:这参数根据变量名翻译就是旧的事件,具体是什么目前还不太清楚。但是在初始化事件的整个过程中,调用到updateComponentListeners
时传递的oldListeners
参数值是一个空值。所以这个值我们暂时不用关注。(在/vue/src/
目录下全局搜索updateComponentListeners
这个函数,会发现该函数在其他地方有调用,所以该参数应该是在别的地方有用到)。
add
: add是一个函数,函数内部逻辑代码为:
function add (event, fn) {
target.$on(event, fn)
}
remove
: remove也是一个函数,函数内部逻辑代码为:
function remove (event, fn) {
target.$off(event, fn)
}
createOnceHandler
:
vm
:这个参数就不用多说了,就是当前组件的实例。
这里我们主要说一下add函数和remove函数中的两个重要代码:target.$on
和target.$off
。
首先target
是在event.js
文件中定义的一个全局变量:
//源码位置备注:/vue/src/core/instance/events.js
let target: any
在updateComponentListeners
函数内部,我们能看到将组件实例赋值给了target
:
//源码位置备注:/vue/src/core/instance/events.js
target = vm
所以target
就是组件实例。当然熟悉Vue
的同学应该很快能反应上来$on
、$off
方法本身就是定义在组件实例上和事件相关的方法。那组件实例上有关事件的方法除了$on
和$off
方法之外,还有两个方法:$once
和$emit
。
在这里呢,我们暂时不详细去解读这四个事件方法的源码实现,只截图贴出Vue
官网对这个四个实例方法的用法描述。
vm.$on
vm.$once
vm.$emit
vm.$emit的用法在 Vue父子组件通信 一文中有详细的示例。
vm.$off
updateListeners
函数的参数基本解释完了,接着我们在回归到updateListeners
函数的内部实现。
//源码位置备注:/vue/src/vdom/helpers/update-listener.js
export function updateListeners (
on: Object,
oldOn: Object,
add: Function,
remove: Function,
createOnceHandler: Function,
vm: Component
) {
let name, def, cur, old, event
// 循环断当前组件的父组件上的事件
for (name in on) {
// 根据事件名称获取事件回调函数
def = cur = on[name]
// oldOn参数对应的是oldListeners,前面说过这个参数在初始化的过程中是一个空对象{},所以old的值为undefined
old = oldOn[name]
event = normalizeEvent(name)
if (isUndef(old)) {
if (isUndef(cur.fns)) {
cur = on[name] = createFnInvoker(cur, vm)
}
if (isTrue(event.once)) {
cur = on[name] = createOnceHandler(event.name, cur, event.capture)
}
// 将父级的事件添加到当前组件的实例中
add(event.name, cur, event.capture, event.passive, event.params)
}
}
}
首先是normalizeEvent
这个函数,该函数就是对事件名称进行一个分解。假如事件名称name='updateinfo.once'
,那经过该函数分解后返回的event
对象为:
{
name: 'updateinfo',
once: true,
capture: false,
passive: false
}
关于
normalizeEvent
函数内部的实现也非常简单,这里就直接将结论整理出来。感兴趣的同学可以去看下源码实现,源码所在位置:/vue/src/vdom/helpers/update-listener.js
。
接下来就是在循环父组件事件的时候做一些if/else
的条件判断,将父组件绑定在当前组件上的事件添加到当前组件实例的_events
属性中;或者从当前组件实例的_events
属性中移除对应的事件。
将父组件绑定在当前组件上的事件添加到当前组件的_events属性中
这个逻辑就是add
方法内部调用vm.$on
实现的。详细可以去看下vm.$on
的源码实现,这里不再多说。而且从vm.$on
函数的实现,也能看出_events
和_parentListener
之间的关联和差异。
initRender-初始化模板
//源码位置备注:/vue/src/core/instance/render.js
export function initRender (vm: Component) {
vm._vnode = null // the root of the child tree
vm._staticTrees = null // v-once cached trees
const options = vm.$options
const parentVnode = vm.$vnode = options._parentVnode // the placeholder node in parent tree
const renderContext = parentVnode && parentVnode.context
vm.$slots = resolveSlots(options._renderChildren, renderContext)
vm.$scopedSlots = emptyObject
//将createElement fn绑定到组件实例上
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
// normalization is always applied for the public version, used in
// user-written render functions.
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
// $attrs & $listeners are exposed for easier HOC creation.
// they need to be reactive so that HOCs using them are always updated
const parentData = parentVnode && parentVnode.data
/* 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)
}
}
initRender
函数中,基本上是在为组件实例vm上的属性赋值:$slots
、$scopeSlots
、$createElement
、$attrs
、$listeners
。
那接下来就一一分析一下这些属性就知道initRender
在执行的过程的逻辑了。
vm.$slots
这是来自官网对vm.$slots
的解释,那为了方便,我还是写一个示例。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue的生命周期</title>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
var ChildComponent = Vue.component('child', {
mounted() {
console.log("Clild组件,this.$slots:");
console.log(this.$slots);
},
template:'<div id="child">子组件Child</div>'
})
</script>
</head>
<body>
<div id="app">
<h1 slot='root'>App组件,slot='root'</h1>
<child>
<h3 slot='first'>这里是slot=first</h3>
<h3 slot='first'>这里是slot=first</h3>
<h3>这里没有设置slot</h3>
<h3 slot='last'>这里是slot=last</h3>
</child>
</div>
<script>
var vm = new Vue({
el: '#app',
mounted() {
console.log("App组件,this.$slots:");
console.log(this.$slots);
}
});
</script>
</body>
</html>
运行代码,看一下结果。
可以看到,child
组件的vm.$slots
打印结果是一个包含三个键值对的对象。其中key
为first
的值保存了两个VNode
对象,这两个Vnode
对象就是我们在引用child
组件时写的slot=first
的两个h3
元素。那key
为last
的值也是同样的道理。
key
为default
的值保存了四个Vnode
,其中有一个是引用child
组件时写没有设置slot
的那个h3
元素,另外三个Vnode
实际上是四个h3
元素之间的换行,假如把child
内部的h3
这样写:
<child>
<h3 slot='first'>这里是slot=first</h3><h3 slot='first'>这里是slot=first</h3><h3>这里没有设置slot</h3><h3 slot='last'>这里是slot=last</h3>
</child>
那最终打印
key
为default
对应的值就只包含我们没有设置slot
的h1
元素。
所以源代码中的resolveSlots
函数就是解析模板中父组件传递给当前组件的slot
元素,并且转化为Vnode
赋值给当前组件实例的$slots
对象。
vm.$scopeSlots
vm.$scopeSlots
是Vue
中作用域插槽的内容,和vm.$slot
查不多的原理,就不多说了。
在这里暂时给
vm.$scopeSlots
赋值了一个空对象,后续会在挂载组件调用vm.$mount
时为其赋值。
vm.$createElement
vm.$createElement
是一个函数,该函数可以接收两个参数:
第一个参数:HTML元素标签名
第二个参数:一个包含Vnode对象的数组
vm.$createElement
会将Vnode
对象数组中的Vnode
元素编译成为html
节点,并且放入第一个参数指定的HTML
元素中。
那前面我们讲过vm.$slots
会将父组件传递给当前组件的slot
节点保存起来,且对应的slot
保存的是包含多个Vnode
对象的数组,因此我们就借助vm.$slots
来写一个示例演示一下vm.$createElement
的用法。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue的生命周期</title>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script>
var ChildComponent = Vue.component('child', {
render:function(){
return this.$createElement('p',this.$slots.first);
}
})
</script>
</head>
<body>
<div id="app">
<h1 slot='root'>App组件,slot='root'</h1>
<child>
<h3 slot='first'>这里是slot=first</h3>
<h3 slot='first'>这里是slot=first</h3>
<h3>这里没有设置slot</h3>
<h3 slot='last'>这里是slot=last</h3>
</child>
</div>
<script>
var vm = new Vue({
el: '#app'
});
</script>
</body>
</html>
这个示例代码和前面介绍vm.$slots
的代码差不多,就是在创建子组件时编写了render
函数,并且使用了vm.$createElement
返回模板的内容。那我们浏览器中的结果。
可以看到,正如我们所说,vm.$createElement
将$slots
中frist
对应的 包含两个Vnode对象的数组
编译成为两个h3
元素,并且放入第一个参数指定的p
元素中,在经过子组件的render
函数将vm.$createElement
的返回值进行处理,就看到了浏览器中展示的效果。
vm.$createElement
内部实现暂时不深入探究,因为牵扯到Vue
中Vnode
的内容,后面了解Vnode
后在学习其内部实现。
vm.$attr和vm.$listener
这两个属性是有关组件通信的实例属性,赋值方式也非常简单,不在多说。
callHook(beforeCreate)-调用生命周期钩子函数
callhook
函数执行的目的就是调用Vue
的生命周期钩子函数,函数的第二个参数是一个字符串
,具体指定调用哪个钩子函数。那在初始化阶段,顺序执行完 initLifecycle
、initState
、initRender
后就会调用beforeCreate
钩子函数。
接下来看下源码实现。
//源码位置备注:/vue/src/core/instance/lifecycle.js
export function callHook (vm: Component, hook: string) {
// #7573 disable dep collection when invoking lifecycle hooks
pushTarget()
// 根据钩子函数的名称从组件实例中获取组件的钩子函数
const handlers = vm.$options[hook]
const info = `${hook} hook`
if (handlers) {
for (let i = 0, j = handlers.length; i < j; i++) {
invokeWithErrorHandling(handlers[i], vm, null, vm, info)
}
}
if (vm._hasHookEvent) {
vm.$emit('hook:' + hook)
}
popTarget()
}
首先根据钩子函数的名称从组件实例中获取组件的钩子函数,接着调用invokeWithErrorHandling
,invokeWithErrorHandling
函数的第三个参数为null,所以invokeWithErrorHandling
内部就是通过apply方法实现钩子函数的调用。
我们应该看到源码中是循环
handlers
然后调用invokeWithErrorHandling
函数。那实际上,我们在编写组件的时候是可以写多个名称相同的钩子
,但是实际上Vue
在处理的时候只会在实例上保留最后一个重名的钩子函数,那这个循环的意义何在呢?为了求证,我在
beforeCrated
这个钩子中打印了this.$options['before']
,然后发现这个结果是一个数组,而且只有一个元素。
这样想来就能理解这个循环的写法了。
initInjections-初始化注入
initInjections这个函数是个Vue中的inject相关的内容。所以我们先看一下官方文档度对inject的解释。
官方文档中说inject
和provide
通常是一起使用的,它的作用实际上也是父子组件之间的通信,但是会建议大家在开发高阶组件时使用。
provide
是下文中initProvide
的内容。
关于inject
和provide
的用法会有一个特点:只要父组件使用provide
注册了一个数据,那不管有多深的子组件嵌套,子组件中都能通过inject
获取到父组件上注册的数据。
大致了解inject
和provide
的用法后,就能猜想到initInjections
函数内部是如何处理inject
的了:解析获取当前组件中inject
的值,需要查找父组件中的provide
中是否注册了某个值,如果有就返回,如果没有则需要继续向上查找父组件。
下面看一下initInjections
函数的源码实现。
// 源码位置备注:/vue/src/core/instance/inject.js
export function initInjections (vm: Component) {
const result = resolveInject(vm.$options.inject, vm)
if (result) {
toggleObserving(false)
Object.keys(result).forEach(key => {
/* istanbul ignore else */
if (process.env.NODE_ENV !== 'production') {
defineReactive(vm, key, result[key], () => {
warn(
`Avoid mutating an injected value directly since the changes will be ` +
`overwritten whenever the provided component re-renders. ` +
`injection being mutated: "${key}"`,
vm
)
})
} else {
defineReactive(vm, key, result[key])
}
})
toggleObserving(true)
}
}
源码中第一行就调用了resolveInject
这个函数,并且传递了当前组件的inject配置和组件实例。那这个函数就是我们说的递归向上查找父组件的provide
,其核心代码如下:
// source为当前组件实例
let source = vm
while (source) {
if (source._provided && hasOwn(source._provided, provideKey)) {
result[key] = source._provided[provideKey]
break
}
// 继续向上查找父组件
source = source.$parent
}
需要说明的是当前组件的_provided
保存的是父组件使用provide
注册的数据,所以在while
循环里会先判断 source._provided
是否存在,如果该值为 true
,则表示父组件中包含使用provide
注册的数据,那么就需要进一步判断父组件provide
注册的数据是否存在当前组件中inject
中的属性。
递归查找的过程中,对弈查找成功的数据,resolveInject
函数会将inject中的元素对应的值放入一个字典中作为返回值返回。
例如当前组件中的inject
设置为:inject: ['name','age','height']
,那经过resolveInject
函数处理后会得到这样的返回结果:
{
'name': '小土豆biubiubiu',
'age': 18,
'height': '180'
}
最后在回到initInjections
函数,后面的代码就是在非生产环境下,将inject中的数据变成响应式的,利用的也是双向数据绑定的那一套原理。
initState-初始化状态
//源码位置备注:/vue/src/core/instance/state.js
export function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
} else {
observe(vm._data = {}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
初始化状态这个函数中主要会初始化Vue
组件定义的一些属性:props
、methods
、data
、computed
、Watch
。
我们主要看一下data
数据的初始化,即initData
函数的实现。
//源码位置备注:/vue/src/core/instance/state.js
function initData (vm: Component) {
let data = vm.$options.data
// 省略部分代码······
// observe data
observe(data, true /* asRootData */)
}
在initData
函数里面,我们看到了一行熟悉系的代码:observe(data)
。这个data
参数就是Vue
组件中定义的data
数据。正如注释所说,这行代码的作用就是将对象变得可观测
。
在往observe
函数内部追踪的话,就能追到之前 [1W字长文+多图,带你了解vue2.x的双向数据绑定源码实现] 里面的Observer
的实现和调用。
所以现在我们就知道将对象变得可观测就是在Vue
实例初始化阶段的initData
这一步中完成的。
initProvide-初始化
//源码位置备注:/vue/src/core/instance/inject.js
export function initProvide (vm: Component) {
const provide = vm.$options.provide
if (provide) {
vm._provided = typeof provide === 'function'
? provide.call(vm)
: provide
}
}
这个函数就是我们在总结initInjections
函数时提到的provide
。那该函数也非常简单,就是为当前组件实例设置_provide
。
callHook(created)-调用生命周期钩子函数
到这个阶段已经顺序执行完initLifecycle
、initState
、initRender
、callhook('beforeCreate')
、initInjections
、initProvide
这些方法,然后就会调用created
钩子函数。
callHook
内部实现在前面已经说过,这里也是一样的,所以不再重复说明。
总结
到这里,Vue2.x的生命周期的初始化阶段
就解读完毕了。这里我们将初始化阶段做一个简单的总结。
源码还是很强大的,学习的过程还是比较艰难枯燥的,但是会发现很多有意思的写法,还有我们经常看过的一些理论内容在源码中的真实实践,所以一定要坚持下去。期待下一篇文章[你还不知道Vue的生命周期吗?带你从Vue源码了解Vue2.x的生命周期(模板编译阶段)]
。
作者:小土豆biubiubiu
博客园:https://www.cnblogs.com/HouJiao/
掘金:https://juejin.im/user/58c61b4361ff4b005d9e894d
简书:https://www.jianshu.com/u/cb1c3884e6d5
微信公众号:土豆妈的碎碎念(扫码关注,一起吸猫,一起听故事,一起学习前端技术)
欢迎大家扫描微信二维码进入群聊讨论(若二维码失效可添加微信JEmbrace拉你进群):
码字不易,点赞鼓励哟~