一起学习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 之前会先执行 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拉你进群):

码字不易,点赞鼓励哟~

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章