Vue的生命周期和钩子函数(一)——数据初始化

前言

了解Vue实例的创建过程,有助于帮助我们理解Vue内部的实现机制,更加深刻的理解钩子函数的作用和使用场景。本篇博文基于Vue2.X,3.0的一些特殊机制和语法暂不做考虑。阅读全篇时间约12分钟,仅了解钩子函数使用及使用场景,需约4分钟

正文

我们都知道,创建一个Vue实例,需要通过new Vue()来实现。那一个Vue实例创建到销毁的过程中,会存在哪些变化和会调用哪些功能呢?又会再什么场景下去触发钩子函数的调用呢,下面将通过讲述Vue实例的创建过程,来说明Vue的钩子函数的调用时机和场景。
一个Vue实例的生命周期,总体而言,可以划分为三个阶段:初始化阶段、数据响应阶段以及销毁阶段,我将通过多篇博文来介绍这几个阶段以及生命周期钩子函数的使用。

Vue实例初始化

一个正常简单的Vue实例化语句是下面这样的:

var vm  = new Vue({
	el:#app',
	data:{
		msg:'xxxx'
	}
})

我们通过调用Vue的构造函数来创建Vue实例,其中我们会传入一个对象,该对象包含了实例化Vue所需要的el、data等。在实例初始化阶段,Vue会根据传入的这个对象,去内部调用各种init()函数。期间Vue的四个生命周期的钩子函数也会被调用到,他们分别是:beforeCreate、created、beforeMount、Mounted,初始化的过程大致如下:
Vue的生命周期和钩子函数(一)——数据初始化_第1张图片
在我们正常的开发过程,只需要了解各个钩子函数的使用场景和前提就可以了,并不需要太去关注里面内部的实现机制,所以这里先说一下每个钩子函数的使用场景:

beforeCreate

beforeCreate钩子函数,如其名,是在Vue实例还未创建data、methods等之前,会触发调用的一个钩子函数,在触发这个函数的调用之前,Vue主要是做了一些简单的事件初始化等工作,比如emit,on,once,off等,平常开发中使用几率少。

created

created钩子函数,是在已经初始化好props、methods、data、computed、watch等之后,会去调用的钩子函数,但是此时属性el还没有被创建的,只是创建了实例的一些基本属性。created钩子函数中可以操作已经创建好的数据和方法,同时也可以调用异步的功能获取页面初始化所需的数据

beforeMount

在调用这个beforeMount之前,Vue已经编译好了模板并转化成了虚拟DOM,el也已经完成了对元素节点的挂载。在这个钩子函数里,我们可以修改data的数据并且不会触发updated和beforeUpdate钩子函数的调用,使用频率也较低。

mounted

mounted钩子函数是在虚拟DOM通过$el已经替换成为真实DOM树的之后被调用,此时页面中的html已经渲染完成,相对于created而言,如果需要去操作DOM树,那么可以放在mounted中去完成。此时也可以通过this.$ref去拿到ref绑定的元素节点。同时此时变更data的数据,会对应的触发updated和beforeUpdate钩子函数的调用。

说完了初始化阶段钩子函数的调用,现在我们来结合源码一起来解读下这个过程。下面这部分的内容涉及对源码的解读,没时间的小伙伴可以先收藏,后续再翻出来看看,加以理解
先来展示一段简单的实例化代码:

// 传入了一个包含el、data属性的对象来构建一个Vue实例
var vm = new Vue({
	el:'#app',
	data:{
		msg:''
	}
})

Vue实例的构建是通过new Vue()而来的,所以想要了解Vue实例化的过程,要先从它的构造函数Vue开始。

//代码路径:src\core\instance\index.js
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)
}

这里调用了一个_init函数,init函数主要如下:

// src\core\instance\init.js
// 省略部分代码
Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    ... 
    // Vue内部调用,正常不会走到这个分支
    if (options && options._isComponent) {
      initInternalComponent(vm, options)
    } else {
    // 使用策略对象合并参数选项合并父子组件的一些同名属性,包括指令、过滤器、钩子函数等,不同的合并对象采用不同的策略,这里不做深入
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
    ...
    //初始化了一些内部属性和外部属性,比如外部属性:$children,$parent,$refs,$root等,内部属性_watcher等
    initLifecycle(vm) 
    //初始化父组件在模板中使用v-on或@注册的监听子组件内触发的事件。
    initEvents(vm)
    initRender(vm)
    //依照传入的钩子函数名,去调用上面合并后的钩子函数数组,这里是调用beforeCreate
    callHook(vm, 'beforeCreate')
    //初始化inject选项
    initInjections(vm) // resolve injections before data/props
    //初始化实例状态,如props、methods、data、computed、watch
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')
    ...
    //如果存在el,则自动调用$mount()进入模板编译和挂载阶段,如果没有,则需要等待用户手动vm.$mount()来触发
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }

可以看到,在这个init函数中完成了对beforeCreate、created钩子函数的调用,并通过判断是否存在el来决定是否调用$mount()来进入模板编译阶段。在判断之前,它做了一系列的初始化工作。这里我们仅对关键的代码initState做拆解,其他的细枝末节的,上面已经做了注释,了解的太细也没有必要用途不多。

//代码路径:src\core\instance\state.js
export function initState (vm: Component) {
  //注册一个内部属性_watchers用来保存所注册的watcher实例
  vm._watchers = []
  const opts = vm.$options
  //在initProps中会对所有的prop做规范化处理,转换为统一格式,并对prop的值做类型校验
  if (opts.props) initProps(vm, opts.props)
  //初始化methods,并对methods中的方法名做校验,判断是否与props或者_、$开头的内部名称重名
  if (opts.methods) initMethods(vm, opts.methods)
  //初始化data,校验data属性名是否合法,将data中的属性通过代理绑定到vm上,方便后续可以直接使用this.的方法调用
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  //初始化computed,创建computed单独的watcher监听
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

在上面我们可以看到,首先initState是在beforeCreate之后调用的,在initState中分别对props、methods、data、computed、watch等进行了初始化,这里也可以发现,props和methods是在data之前初始化的,所以在data、watch、computed里面,我们可以访问到props和methods,在执行完initState之后,后面再去执行created的钩子函数时,就能够正常的访问我们的data和methods了。
执行完created之后,
在执行完created之后,我们从最开始执行_init()的地方上面可以看到有这样的一段代码:

// 代码路径:src\core\instance\init.js
if (vm.$options.el) {
  vm.$mount(vm.$options.el)
}

这里其实就对应了我们生命周期图示,通过判断el是否存在,如果存在则取触发相关的调用。但是我们得首先看看这个$mount的实现。

// 代码路径:src\platforms\web\runtime\index.js
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 这个query(el)获取el绑定的dom元素,如果没有,则创建一个div元素返回
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

可以看到,$mount里面,会去对el做判断以及是否是浏览器环境,如果是,则对el进行了遍历获取对应的dom元素,然后去执行下面的mountComponent方法,这里先不对mountComponent具体讲解,后面会说道。这个地方只是声明了$mount的核心功能,真正被调用的地方还是在下面:

// src\platforms\web\entry-runtime-with-compiler.js
//这里将$mount赋值给了mount,然后给$mount覆盖了一个新的方法,在新方法的末尾,
//通过mount.call(this, el, hydrating)去触发核心代码的调用。
//这种处理方式被称之为函数劫持,即在原始功能上新增一些其他功能。
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)
  // el元素不建议绑定html、body标签
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to  or  - mount to normal elements instead.`
    )
    return this
  }
  const options = this.$options
  // 判断是否存在render,如果不存在,则判断将template是否存在
  if (!options.render) {
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) { //判断template是否一个DOM元素
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      // 如果不存在则根据el绑定的元素节点,将对应的DOM转换为template
      template = getOuterHTML(el)
    }
    // 存在则将模板渲染通过compileToFunctions转化为render函数
    if (template) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }
      //将template编译成render函数,这里会有render以及staticRenderFns两个返回
      //这是vue的编译时优化,static静态不需要在VNode更新时进行patch,优化性能
      const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns

      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  //触发钩子函数的调用
  return mount.call(this, el, hydrating)
}

上面这块代码,主要是实现将模板转化为对应的渲染函数,如果存在template,则正常转化,否则就根据el绑定的DOM模板,将其转化为template之后,再通过compileToFunctions的方式进行转化。处理完这些,再去调用之前我们暂存的$mount的核心功能,也就是钩子函数的调用。需要注意,$mount的编译方式是存在多种的,因此在上面$mount返回的mountComponent()方法中还对render做了判断,如果没有对应的render(),会作相应的提醒警告,而上面功能实现的为完整版的编译功能(runtime + compiler)。具体详参Vue官方文档:运行时 + 编译器 vs. 只包含运行时

讲到这里,我们可以知道,在钩子函数beforeMount调用之前,其实就是做了模板编译的工作,返回了render函数。下一步就是真正的挂载阶段了,也就是mountComponent()所做的事情。具体的实现代码如下:

// 代码路径:src\core\instance\lifecycle.js
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  if (!vm.$options.render) {
    // 如果render不存在,会创建一个空的VNode节点
    vm.$options.render = createEmptyVNode
    ... //省略部分不相关代码
  }

  // 触发生命周期钩子函数beforeMount
  callHook(vm, 'beforeMount')  

  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    ... // 省略次要处理代码
  } else {
    // updateComponent赋值,入参为vm._render()将会为我们得到一份最新的VNode节点树
    // vm._update对最新的VNode节点树与上一次渲染的旧VNode节点树进行对比并更新DOM节点(即patch操作),完成一次渲染
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }

  //这里对该vm注册一个Watcher实例,Watcher的getter为updateComponent函数,
  //用于触发所有渲染所需要用到的数据的getter,进行依赖收集,该Watcher实例会存在所有渲染所需数据的闭包Dep中
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    // 标志位,代表该组件已经挂载
    vm._isMounted = true
    // 调用mounted钩子
    callHook(vm, 'mounted')
  }
  return vm
}

总结下上面代码,就是先调用了beforeMount钩子函数,然后调用渲染函数将虚拟DOM树进行更新和渲染,然后开启数据监听的机制。当数据发生变更的时候,我们在创建watcher对象时的第四个参数,就是会在数据发生变更的时候触发的回调函数,这其中就会调用对应的beforeUpdate方法。当执行到这里时,整个Vue实例就已经创建完成并且开启了数据监听了。

 new Watcher(vm, 
 	updateComponent, //
 	noop, 
 	{
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)

到这里,我们就知道Vue初始化的过程及整个钩子函数的调用场景了,当然,篇幅有限,不能介绍所有的细节点,比如mounted之前,$el是如何挂载并将我们的虚拟DOM替换为真实的DOM的(可参考我文末的参考链接去探究),这些都没有细讲,但是Vue实例化阶段的钩子函数调用,应该还是较为清晰的。希望大家在读完之后,能够对Vue的生命周期中Vue实例化这一块有更深的了解。

码字不易,欢迎一健三连给点激励哟~

同时,文中有讲述错误的地方,欢迎评论指正,最好指出的同时还请提供相关依据,欢迎大家共同来完善。Vue的生命周期和钩子函数(一)——数据初始化_第2张图片

参考文献:
Vue gitHub

Vue 源码学习

Vue源码解析-详细篇

Vue生命周期源码探究

Vue源码解析-中文社区

你可能感兴趣的:(Vue,vue,前端,源码)