一起学习vue源码 - Vue2.x的生命周期(初始化阶段)

时间:2024-02-01 18:11:40

作者:小土豆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之前会先执行initMixinstateMixineventsMixinlifecycleMixinrenderMixin。这些方法内部大致就是在为组件实例定义一些属性和实例方法,并且会为属性赋初值。

我不会详细去解读这几个方法内部的实现,因为本篇主要是分析学习new Vue的源码实现。那我在这里说明这个是想让大家大致了解一下和这部分相关的源码的执行顺序,因为在Vue构造函数中调用的_init方法内部有很多实例属性的访问、赋值以及很多实例方法的调用,那这些实例属性和实例方法就是在index.js引入的时候通过执行initMixinstateMixineventsMixinlifecycleMixinrenderMixin这几个方法定义的。

创建组件实例 - if条件判断逻辑

if条件判断逻辑如下:

if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue)) {
    warn('Vue is a constructor and should be called with the `new` keyword')
}

我们先看一下&&前半段的逻辑。

processnode环境内置的一个全局变量,它提供有关当前Node.js进程的信息并对其进行控制。如果本机安装了node环境,我们就可以直接在命令行输入一下这个全局变量。

这个全局变量包含的信息非常多,这里只截出了部分属性。

对于process的evn属性 它返回当前用户环境信息。但是这个信息不是直接访问就能获取到值,而是需要通过设置才能获取。

可以看到我没有设置这个属性,所以访问获得的结果是undefined

然后我们在看一下Vue项目中的webpackprocess.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.jsnew 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方法内部实际上是利用ES6Proxy对象为将组件实例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.$ontarget.$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打印结果是一个包含三个键值对的对象。其中keyfirst的值保存了两个VNode对象,这两个Vnode对象就是我们在引用child组件时写的slot=first的两个h3元素。那keylast的值也是同样的道理。

keydefault的值保存了四个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>

那最终打印keydefault对应的值就只包含我们没有设置sloth1元素。

所以源代码中的resolveSlots函数就是解析模板中父组件传递给当前组件的slot元素,并且转化为Vnode赋值给当前组件实例的$slots对象。

vm.$scopeSlots

vm.$scopeSlotsVue中作用域插槽的内容,和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$slotsfrist对应的 包含两个Vnode对象的数组编译成为两个h3元素,并且放入第一个参数指定的p元素中,在经过子组件的render函数将vm.$createElement的返回值进行处理,就看到了浏览器中展示的效果。

vm.$createElement 内部实现暂时不深入探究,因为牵扯到VueVnode的内容,后面了解Vnode后在学习其内部实现。

vm.$attr和vm.$listener

这两个属性是有关组件通信的实例属性,赋值方式也非常简单,不在多说。

callHook(beforeCreate)-调用生命周期钩子函数

callhook函数执行的目的就是调用Vue的生命周期钩子函数,函数的第二个参数是一个字符串,具体指定调用哪个钩子函数。那在初始化阶段,顺序执行完 initLifecycleinitStateinitRender后就会调用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()
}

首先根据钩子函数的名称从组件实例中获取组件的钩子函数,接着调用invokeWithErrorHandlinginvokeWithErrorHandling函数的第三个参数为null,所以invokeWithErrorHandling内部就是通过apply方法实现钩子函数的调用。

我们应该看到源码中是循环handlers然后调用invokeWithErrorHandling函数。那实际上,我们在编写组件的时候是可以写多个名称相同的钩子,但是实际上Vue在处理的时候只会在实例上保留最后一个重名的钩子函数,那这个循环的意义何在呢?

为了求证,我在beforeCrated这个钩子中打印了this.$options['before'],然后发现这个结果是一个数组,而且只有一个元素。

这样想来就能理解这个循环的写法了。

initInjections-初始化注入

initInjections这个函数是个Vue中的inject相关的内容。所以我们先看一下官方文档度对inject的解释

官方文档中说injectprovide通常是一起使用的,它的作用实际上也是父子组件之间的通信,但是会建议大家在开发高阶组件时使用。

provide 是下文中initProvide的内容。

关于injectprovide的用法会有一个特点:只要父组件使用provide注册了一个数据,那不管有多深的子组件嵌套,子组件中都能通过inject获取到父组件上注册的数据。

大致了解injectprovide的用法后,就能猜想到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组件定义的一些属性:propsmethodsdatacomputedWatch

我们主要看一下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)-调用生命周期钩子函数

到这个阶段已经顺序执行完initLifecycleinitStateinitRendercallhook('beforeCreate')initInjectionsinitProvide这些方法,然后就会调用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拉你进群):


码字不易,点赞鼓励哟~