组件化

回忆

        首先,render函数中手写h=>h(app),new Vue()实例初始化init()和原来一样。$mount执行到第一个$mount,判断有无render函数,没有就生成render函数,这里我们是有的。执行第二个$mount,调用mountComponent,到了vm._update(vm._render(), hydrating)。

        下面正式开始组件处理,先是vm._render()生成组件vnode.这个过程主要做了三件事,1、创建组件构造函数(继承于Vue)。2、安装组件钩子函数(在patch流程中触发)。3、组件vnode实例化

        其中细节,vm._render()会调用vm.$createElement. 其中会判断参数tag,若是普通html标签则实例化一个普通vnode节点,否则调用creatComponent创建组件vnode。1、通过Vue.extend得到一个继承于Vue的组件构造器(组件实例化时会执行_init())。2、遍历组件钩子函数(componentVNodeHooks中),若vnode相关data(即vNodeData)中当前hook存在,把组件hook,merge到当前hook上,不然直接赋值。3、通过new VNode()生成组件vnode并返回。

        我们通过 createComponent 创建了组件 VNode,接下来会走到 vm._update,执行 vm.__patch__ 去把 VNode 转换成真正的 DOM 节点。patch 的过程会调用 createElm 创建元素节点,而createElm中会判断 createComponent,true的话直接返回,只执行createComponent中逻辑。createComponent中会判断是否是组件vnode,是的话 先执行init(vnode,hydrating)

        init(vnode,hydrating),通过 createComponentInstanceForVnode(vnode, activeInstance)创建一个 继承于Vue 的实例,然后调用 $mount 方法挂载子组件。

        createComponentInstanceForVnode(vnode, activeInstance)执行new vnode.componentOptions.Ctor(options)(这里的 vnode.componentOptions.Ctor 对应的就是继承于 Vue的子组件的构造函数)。其中options参数,_isComponent 为 true 表示它是一个组件,_parentVnode(new Vue的vnode,其实为$el挂载点的vnode。如果为子组件,为子组件在父组件中的占位符),parent(activeInstance) 表示当前激活的组件实例(即new Vue实例,我们现在 在处理的是new Vue实例中render中的app对象)(注意,这里比较有意思的是如何拿到组件实例,后面会介绍。所以子组件的实例化实际上就是在这个时机执行的,并且它会执行实例的 _init 方法(在extend时定义的)。

       _init(),和new Vue初始化时有些不同,这里首先是合并 options 的过程有变化,_isComponent 为 true,所以走到了 initInternalComponent 过程。

        initInternalComponent(), opt即是当前组件app实例的vm.$options。这个过程我们重点记住以下几个点即可:opts.parent = options.parent、opts._parentVnode = parentVnode。它们其实是把之前我们通过 createComponentInstanceForVnode 函数传入的几个参数(vnode,activeInstance)合并到内部的选项 $options 里了。

        _init()最后会执行$mount,由于组件初始化的时候是不传 el 的,因此组件是自己接管了 $mount 的过程,这个过程的主要流程在上一章介绍过了。回到组件 init 的过程,componentVNodeHooks 的 init 钩子函数,在完成实例化的 _init 后,接着会执行 child.$mount(hydrating ? vnode.elm : undefined, hydrating)。这里 hydrating 为 true 一般是服务端渲染的情况,我们只考虑客户端渲染,所以这里 $mount 相当于执行 child.$mount(undefined, false),它最终会调用 mountComponent 方法,进而执行 vm._render() 方法。

       vm._render() 中取到了vm.$options的render和_parentVnode(new Vue的vnode,其实为$el挂载点的vnode),把_parentVnode赋值给vm.$vnode(当前vm实例是app组件实例),通过vm.$creatElement取得组件的vnode(这个app组件的vnode)。通过vnode.parent=_parentVnode建立父子关系

       我们知道在执行完 vm._render 生成 VNode 后,接下来就要执行 vm._update 去渲染 VNode 了

        _update() 过程中有几个关键的代码,首先 vm._vnode = vnode 的逻辑,这个 vnode 是通过 vm._render() 返回的组件渲染 VNode,vm._vnode(组件app实际渲染vnode) 和 vm.$vnode(new Vue的vnode,其实为$el挂载点的vnode) 的关系就是一种父子关系,用代码表达就是 vm._vnode.parent === vm.$vnode。另外,_update()中这个 activeInstance(vm) 作用就是保持当前上下文的 Vue 实例(在处理app组件时,它是new Vue实例,在处理app组件进行_update时它又赋值成当前app组件, 若下面又有组件,把它作为parent传下去),它是在 lifecycle 模块的全局变量( export let activeInstance: any = null),并且在之前我们调用 createComponentInstanceForVnode 方法的时候从 lifecycle 模块获取,并且作为参数parent传入的。因为实际上 JavaScript 是一个单线程,Vue 整个初始化是一个深度遍历的过程,在实例化子组件的过程中,它需要知道当前上下文的 Vue 实例是什么,并把它作为子组件的父 Vue 实例。之前我们提到过对子组件的实例化过程先会调用 initInternalComponent(vm, options) 合并 options,把 parent 存储在 vm.$options 中,在 _init()中会调用 initLifecycle(vm) 

         initLifecycle(vm)中可以看到 vm.$parent 就是用来保留当前 vm 的父实例,并且通过 parent.$children.push(vm) 来把当前的 vm 存储到父实例的 $children 中。

        在 vm._update 的过程中,把当前的 vm 赋值给 activeInstance,同时通过 const prevActiveInstance=activeInstance用 prevActiveInstance 保留上一次的 activeInstance。实际上,prevActiveInstance 和当前的 vm 是一个父子关系,当一个 vm 实例完成它的所有子树的 patch 或者 update 过程后,activeInstance 会回到它的父实例,这样就完美地保证了 createComponentInstanceForVnode 整个深度遍历过程中,我们在实例化子组件的时候能传入当前子组件的父 Vue 实例,并在 _init 的过程中,通过 vm.$parent 把这个父子关系保留.

        那么回到 _update,最后就是调用 __patch__ 渲染 VNode 了。这里又回到了本节开始的过程,之前分析过负责渲染成 DOM 的函数是 createElm(),注意这里我们只传了 2 个参数,所以对应的 parentElm 是 undefined。

        createElm(),这里我们传入的 vnode 是组件渲染的 vnode,也就是我们之前说的 vm._vnode,如果组件的根节点是个普通元素,那么 vm._vnode 也是普通的 vnode,这里 createComponent(vnode, insertedVnodeQueue, parentElm, refElm) 的返回值是 false。       

        接下来的过程就和我们上一章一样了,先创建一个父节点占位符,然后再遍历所有子 VNode 递归调用 createElm,在遍历的过程中,如果遇到子 VNode 是一个组件的 VNode,则重复本节开始的过程,这样通过一个递归的方式就可以完整地构建了整个组件树。

        由于我们这个时候传入的 parentElm 是空,所以对组件的插入,在 createComponent 中,在完成组件的整个 patch 过程后,最后执行 insert(parentElm, vnode.elm, refElm) 完成组件的 DOM 插入,如果组件 patch 过程中又创建了子组件,那么DOM 的插入顺序是先子后父

概述:

        Vue.js 另一个核心思想是组件化。所谓组件化,就是把页面拆分成多个组件 (component),每个组件依赖的 CSS、JavaScript、模板、图片等资源放在一起开发和维护。组件是资源独立的,组件在系统内部可复用,组件和组件之间可以嵌套。

        我们在用 Vue.js 开发实际项目的时候,就是像搭积木一样,编写一堆组件拼装生成页面。在 Vue.js 的官网中,也是花了大篇幅来介绍什么是组件,如何编写组件以及组件拥有的属性和特性。

        在这一章节,我们将从源码的角度来分析 Vue 的组件内部是如何工作的,只有了解了内部的工作原理,才能让我们使用它的时候更加得心应手。

        接下来我们会用 Vue-cli 初始化的代码为例,来分析一下 Vue 组件初始化的一个过程。

vue-cli初始化代码

        这段代码相信很多同学都很熟悉,它也是通过 render 函数去渲染的,不同的这次通过 createElement 传的参数是一个组件而不是一个原生的标签,那么接下来我们就开始分析这一过程。

createComponent

        首先生成vnode ,会先执行render函数,render对于自己写render函数的会执行vm.$createElement ,而它最终会调用 _createElement 方法,其中有一段逻辑是对参数 tag 的判断,如果是一个普通的 html 标签,像上一章的例子那样是一个普通的 div,则会实例化一个普通 VNode 节点,否则通过 createComponent 方法创建一个组件 VNode

 createElement 

        在组件render函数中传入的是一个 App 对象,它本质上是一个 Component 类型,那么它会走到上述代码的 else 逻辑,直接通过 createComponent 方法来创建 vnode。所以接下来我们来看一下 createComponent 方法的实现,它定义在 src/core/vdom/create-component.js 文件中:

        createComponent 的逻辑也会有一些复杂,但是分析源码比较推荐的是只分析核心流程,分支流程可以之后针对性的看,所以这里针对组件渲染这个 case 主要就 3 个关键步骤:构造子类构造函数,安装组件钩子函数和实例化 vnode

构造子类构造函数

构造子类构造函数

        我们在编写一个组件的时候,通常都是创建一个普通对象,还是以我们的 App.vue 为例,代码如下:

例子

        这里 export 的是一个对象,所以 createComponent 里的代码逻辑会执行到 baseCtor.extend(Ctor),在这里 baseCtor 实际上就是 Vue,这个的定义是在最开始初始化 Vue 的阶段,在 src/core/global-api/index.js 中的 initGlobalAPI 函数有这么一段逻辑:

baseCtor 实际上就是 Vue

        这里定义的是 Vue.option,而我们的 createComponent 取的是 context.$options,实际上在 src/core/instance/init.js 里 Vue 原型上的 _init 函数中有这么一段逻辑:

 Vue 上的一些 option 扩展到了 vm.$option 

        这样就把 Vue 上的一些 option 扩展到了 vm.$option 上,所以我们也就能通过 vm.$options._base 拿到 Vue 这个构造函数了。mergeOptions 的实现我们会在后续章节中具体分析,现在只需要理解它的功能是把 Vue 构造函数的 options 和用户传入的 options 做一层合并,到 vm.$options 上。

        在了解了 baseCtor 指向了 Vue 之后,我们来看一下 Vue.extend 函数的定义,在 src/core/global-api/extend.js 中。

extend

        Vue.extend 的作用就是构造一个 Vue 的子类,它使用一种非常经典的原型继承的方式把一个纯对象转换一个继承于 Vue 的构造器 Sub 并返回,然后对 Sub 这个对象本身扩展了一些属性,如扩展 options、添加全局 API 等;并且对配置中的 props 和 computed 做了初始化工作;最后对于这个 Sub 构造函数做了缓存,避免多次执行 Vue.extend 的时候对同一个子组件重复构造。

        这样当我们去实例化 Sub 的时候,就会执行 this._init 逻辑再次走到了 Vue 实例的初始化逻辑,实例化子组件的逻辑在之后的章节会介绍。

在extend中定义,实例化sub时会执行this._init

安装组件钩子函数

        我们之前提到 Vue.js 使用的 Virtual DOM 参考的是开源库 snabbdom,它的一个特点是在 VNode 的 patch 流程中对外暴露了各种时机的钩子函数方便我们做一些额外的事情,Vue.js 也是充分利用这一点,在初始化一个 Component 类型的 VNode 的过程中实现了几个钩子函数:

安装组件钩子
componentVNodeHooks 的钩子函数
安装过程

        整个 mergeHooks 的过程就是把 componentVNodeHooks 的钩子函数合并到 data.hook 中,在 VNode 执行 patch 的过程中执行相关的钩子函数,具体的执行我们稍后在介绍 patch 过程中会详细介绍。这里要注意的是合并策略,在合并过程中,如果某个时机的钩子已经存在 data.hook 中,那么通过执行 mergeHook 函数做合并,这个逻辑很简单,就是在最终执行的时候,依次执行这两个钩子函数即可。

实例化 VNode

通过 new VNode 实例化一个 vnode 并返回

        最后一步非常简单,通过 new VNode 实例化一个 vnode 并返回。需要注意的是和普通元素节点的 vnode 不同,组件的 vnode 是没有 children 的,这点很关键,在之后的 patch 过程中我们会再提。

patch

        当我们通过 createComponent 创建了组件 VNode,接下来会走到 vm._update,执行 vm.__patch__ 去把 VNode 转换成真正的 DOM 节点。这个过程我们在前一章已经分析过了,但是针对一个普通的 VNode 节点,接下来我们来看看组件的 VNode 会有哪些不一样的地方。

        patch 的过程会调用 createElm 创建元素节点,回顾一下 createElm 的实现,它的定义在 src/core/vdom/patch.js 中:

createElm

        我们删掉多余的代码,只保留关键的逻辑,这里会判断 createComponent(vnode, insertedVnodeQueue, parentElm, refElm) 的返回值,如果为 true 则直接结束,那么接下来看一下 createComponent 方法的实现:

createComponent

createComponent

首先对 vnode.data 做了一些判断:

createComponent首先对 vnode.data 做了一些判断

        如果 vnode 是一个组件 VNode,那么条件会满足,并且得到 i 就是 init 钩子函数,回顾上节我们在创建组件 VNode 的时候合并钩子函数中就包含 init 钩子函数,定义在 src/core/vdom/create-component.js 中:

 init 钩子函数

        init 钩子函数执行也很简单,我们先不考虑 keepAlive 的情况,它是通过 createComponentInstanceForVnode 创建一个 继承于Vue 的实例,然后调用 $mount 方法挂载子组件,先来看一下 createComponentInstanceForVnode 的实现:

 createComponentInstanceForVnode 

        createComponentInstanceForVnode 函数构造的一个内部组件的参数,然后执行 new vnode.componentOptions.Ctor(options)。这里的 vnode.componentOptions.Ctor 对应的就是子组件的构造函数,我们上一节分析了它实际上是继承于 Vue 的一个构造器 Sub,相当于 new Sub(options) 这里有几个关键参数要注意几个点,_isComponent 为 true 表示它是一个组件,parent(activeInstance) 表示当前激活的组件实例(即new Vue实例,我们现在 在处理的是new Vue实例中render中的app对象)(注意,这里比较有意思的是如何拿到组件实例,后面会介绍。

        所以子组件的实例化实际上就是在这个时机执行的,并且它会执行实例的 _init 方法(在extend时定义的),这个过程有一些和之前不同的地方需要挑出来说,代码在 src/core/instance/init.js 中:

组件实例初始化执行_init.js

        这里首先是合并 options 的过程有变化,_isComponent 为 true,所以走到了 initInternalComponent 过程,这个函数的实现也简单看一下:

initInternalComponent

        opt即是当前处理实例app的vm.$options。这个过程我们重点记住以下几个点即可:opts.parent = options.parent、opts._parentVnode = parentVnode,它们是把之前我们通过 createComponentInstanceForVnode 函数传入的几个参数(vnode,activeInstance)合并到内部的选项 $options 里了。

        _init最后会进行挂载。

_init挂载

        由于组件初始化的时候是不传 el 的,因此组件是自己接管了 $mount 的过程,这个过程的主要流程在上一章介绍过了,回到组件 init 的过程,componentVNodeHooks 的 init 钩子函数,在完成实例化的 _init 后,接着会执行 child.$mount(hydrating ? vnode.elm : undefined, hydrating)。这里 hydrating 为 true 一般是服务端渲染的情况,我们只考虑客户端渲染,所以这里 $mount 相当于执行 child.$mount(undefined, false),它最终会调用 mountComponent 方法,进而执行 vm._render() 方法

执行 child.$mount,最终会调用 mountComponent 方法,进而执行 vm._render() 方法
取到了vm.$options的render和_parentVnode,把_parentVnode赋值给vm.$vnode,通过vm.$creatElement取得组件的vnode。通过vnode.parent=_parentVnode建立父子关系

        我们知道在执行完 vm._render 生成 VNode 后,接下来就要执行 vm._update 去渲染 VNode 了。来看一下组件渲染的过程中有哪些需要注意的,vm._update的定义在 src/core/instance/lifecycle.js 中:

_update

        _update 过程中有几个关键的代码,首先 vm._vnode = vnode 的逻辑,这个 vnode 是通过 vm._render() 返回的组件渲染 VNode,vm._vnode 和 vm.$vnode 的关系就是一种父子关系,用代码表达就是 vm._vnode.parent === vm.$vnode。还有一段比较有意思的代码:

_update中的activeInstance即为父实例

        这个 activeInstance 作用就是保持当前上下文的 Vue 实例,它是在 lifecycle 模块的全局变量,定义是 export let activeInstance: any = null,并且在之前我们调用 createComponentInstanceForVnode 方法的时候从 lifecycle 模块获取,并且作为参数传入的。因为实际上 JavaScript 是一个单线程,Vue 整个初始化是一个深度遍历的过程,在实例化子组件的过程中,它需要知道当前上下文的 Vue 实例是什么,并把它作为子组件的父 Vue 实例。之前我们提到过对子组件的实例化过程先会调用 initInternalComponent(vm, options) 合并 options,把 parent 存储在 vm.$options 中,在 $mount 之前会调用 initLifecycle(vm) 方法:

initLifecycle

        可以看到 vm.$parent 就是用来保留当前 vm 的父实例,并且通过 parent.$children.push(vm) 来把当前的 vm 存储到父实例的 $children 中。

        在 vm._update 的过程中,把当前的 vm 赋值给 activeInstance,同时通过 const prevActiveInstance=activeInstance 用 prevActiveInstance 保留上一次的 activeInstance。实际上,prevActiveInstance 和当前的 vm 是一个父子关系,当一个 vm 实例完成它的所有子树的 patch 或者 update 过程后,activeInstance 会回到它的父实例,这样就完美地保证了 createComponentInstanceForVnode 整个深度遍历过程中,我们在实例化子组件的时候能传入当前子组件的父 Vue 实例,并在 _init 的过程中,通过 vm.$parent 把这个父子关系保留.

那么回到 _update,最后就是调用 __patch__ 渲染 VNode 了。

这里又回到了本节开始的过程,之前分析过负责渲染成 DOM 的函数是 createElm,注意这里我们只传了 2 个参数,所以对应的 parentElm 是 undefined。

我们再来看看它的定义:

回顾creatElm

        注意,这里我们传入的 vnode 是组件渲染的 vnode,也就是我们之前说的 vm._vnode,如果组件的根节点是个普通元素,那么 vm._vnode 也是普通的 vnode,这里 createComponent(vnode, insertedVnodeQueue, parentElm, refElm) 的返回值是 false。

        接下来的过程就和我们上一章一样了,先创建一个父节点占位符,然后再遍历所有子 VNode 递归调用 createElm,在遍历的过程中,如果遇到子 VNode 是一个组件的 VNode,则重复本节开始的过程,这样通过一个递归的方式就可以完整地构建了整个组件树。

        由于我们这个时“候传入的 parentElm 是空,所以对组件的插入,在 createComponent 有这么一段逻辑:

在完成组件的整个 patch 过程后,最后执行 insert(parentElm, vnode.elm, refElm) 完成组件的 DOM 插入,如果组件 patch 过程中又创建了子组件,那么DOM 的插入顺序是先子后父。

总结

        那么到此,一个组件的 VNode 是如何创建、初始化、渲染的过程也就介绍完毕了。在对组件化的实现有一个大概了解后,接下来我们来介绍一下这其中的一些细节。我们知道编写一个组件实际上是编写一个 JavaScript 对象,对象的描述就是各种配置,之前我们提到在 _init 的最初阶段执行的就是 merge options 的逻辑,那么下一节我们从源码角度来分析合并配置的过程。

推荐阅读更多精彩内容

  • 1.组件化的目的是什么?最近一两年很多人都想在项目里面搞组件化,觉得搞组件化好,却鲜有人知道组件化好在哪里?组件化...
    LeiLv阅读 6,905评论 2 44
  • 本文章是我最近在公司的一场内部分享的内容。我有个习惯就是每次分享都会先将要分享的内容写成文章。所以这个文集也是用来...
    Awey阅读 5,890评论 4 66
  • 什么是组件? 组件 (Component) 是 Vue.js 最强大的功能之一。组件可以扩展 HTML 元素,封装...
    IMUKL阅读 4,877评论 0 12
  • 这篇笔记主要包含 Vue 2 不同于 Vue 1 或者特有的内容,还有我对于 Vue 1.0 印象不深的内容。关于...
    云之外阅读 2,585评论 0 30
  • [文/原创]阿九的树屋 今天我接到一位来树屋的客人。他同我讲已经高三了,离高考只剩107天,压力很大。不得不说我也...
    阿九的树屋阅读 534评论 13 26