Vue.js源码学习 —— createComponent

如果您是刚开始准备阅读Vue.js的源码,建议先看一下本系列的Vue.js源码学习 —— 起步,相信会对您后面的阅读有很大帮助。

如果您才开始准备看Vue.js中虚拟DOM相关的内容,建议先看一下本系列的Vue.js源码学习 —— VNode/createElement,再来看今天这篇会轻松很多。

今天这篇文章会主要看Vue.js中组件是如何创建的,以及异步组件是如何实现的。要看的代码主要位于文件core/vdom/create-component.js中和文件core/vdom/resolve-async-component.js中。

我没有在文章中大量的贴源码,一定要将源码clone到本地哦。

createComponent

Vue.js源码学习 —— VNode/createElement中讲createElement的实现时,当tag是自定义的组件名称时,或是其他情况时会调用createComponent创建一个组件返回。比如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// render vnode with component
Vue.component('global-component', {
props: ['msg']
})

const vm = new Vue({
data: { message: 'hello world' },
components: {
'MyComponent': {
props: ['msg']
}
}
})

const h = vm.$createElement
const vnode = h('my-component', { props: { msg: vm.message } })
const vnode2 = h('global-component', { props: { msg: vm.message } })

这也是为什么在Vue中可以使用自定义的元素,像使用HTML内置的元素(divspan这些)一样,因为你在创建的节点是虚拟节点,Vue还会对其进行一次解析,最后通过patch才转换成最终的真实节点。

创建组件的过程必然要比创建一个简单的元素节点处理的事情多,因为还要管理组件的生命周期,Vue还支持异步创建组件等等。下面就来看看createElement的实现,在阅读代码时遇到不理解的地方,记得去看一下对它的测试用例,主要位于文件create-component.spec.js中。

参数

1
2
3
4
5
6
7
8
9
export function createComponent (
Ctor: Class<Component> | Function | Object | void,
data: ?VNodeData,
context: Component,
children: ?Array<VNode>,
tag?: string
): VNode | Array<VNode> | void {
// ...
}
  • Ctor:可以看成是组件的构造器,Ctor中存了与组件相关的信息。从它的类型注解上可以看出,该参数允许传Class<Component>FunctionObject,或者是空的。

    • Class<Component>:这是在使用Vue Class Component这个库时用到的。这个库允许开发者用Class的形式类定义组件,具体的时候可以查阅官方文档。

    • Function:这里又有两种情况,一种是直接传的Vue的子类构造器,一种是创建异步组件时传的异步函数。先看一下传Vue的子类构造器的情况:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      const VueChild = Vue.extend({
      name: 'my-component',
      props: ['msg'],
      render(h) {
      return h('p', `${this.msg}`)
      }
      }) // Ctor

      const vm = new Vue({
      el: '#app',
      data: { message: 'hello world' },
      render (h) {
      return h(VueChild, { props: { msg: this.message } }) // data
      }
      })

      还有一种情况是传异步函数,这个等说到异步函数那部分时再细说。

    • Object: 也可以直接传组件的选项对象。如下例子所示:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      const child = {
      name: 'child',
      props: ['msg'],
      render (h) {
      return h('p', `${this.msg}`)
      }
      }

      const vm = new Vue({
      el: '#app',
      data: { message: 'hello world' },
      render (h) {
      return h(child, { props: { msg: this.message } }) // data
      }
      })
  • data:节点的数据对象,这个在Vue.js源码学习 —— VNode/createElement中详细说过,这个对象就是描述当前节点的相关信息的。

  • context:节点所属的实例对象。

  • children:节点下的所有子节点。

  • tag:节点的标签名称。

返回

返回值是VNode类型的值,或是一个VNode数组。

返回VNode数组的情况是函数式组件时,暂时先不讨论这种情况。

创建这个要返回的vnode时的传参如下:

1
2
3
4
5
6
7
8
9
10
11
12
// return a placeholder vnode
const name = Ctor.options.name || tag
const vnode = new VNode(
`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`, // tag
data, // data
undefined, // children
undefined, // text
undefined, // elm
context, // context
{ Ctor, propsData, listeners, tag, children }, // componentOptions
asyncFactory // asyncFactory
)

可以看到createComponent返回的是组件的虚拟节点vnode,而并不是通过new Ctor()创建的组件的实例对象,但是将Ctor放入到了componentOptions中,被vnode携带着,真正执行new Ctor()其实是在将vnode转成真实DOM节点,也就是patch的过程中。

获取组件构造器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Ctor 是 undefined 或 null 时,直接返回
if (isUndef(Ctor)) {
return
}

// baseCtor 其实就是 Vue 构造器
const baseCtor = context.$options._base

// plain options object: turn it into a constructor
if (isObject(Ctor)) {
Ctor = baseCtor.extend(Ctor)
}

// if at this stage it's not a constructor or an async component factory,
// reject.
if (typeof Ctor !== 'function') {
if (process.env.NODE_ENV !== 'production') {
warn(`Invalid Component definition: ${String(Ctor)}`, context)
}
return
}

这块要注意的是,在Ctor是对象时,实际上相当于以Ctor为选项对象,用Vue.extend创建了一个Vue的子类构造器:

1
Ctor = Vue.extend(Ctor)

最后确保在后面的处理过程中使用的Ctor都是函数。

异步组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// async component
let asyncFactory
if (isUndef(Ctor.cid)) {
asyncFactory = Ctor
Ctor = resolveAsyncComponent(asyncFactory, baseCtor)
if (Ctor === undefined) {
// return a placeholder node for async component, which is rendered
// as a comment node but preserves all the raw information for the node.
// the information will be used for async server-rendering and hydration.
return createAsyncPlaceholder(
asyncFactory,
data,
context,
children,
tag
)
}
}

我们上面说了Ctor是函数的情况有两种,这里依据Ctor.cid是否有值来判断是哪一种情况。

为什么Ctor.cid有值时传的是构造器呢?

不论是传入的构造器还是上面才创建的,都是使用的Vue.extend,打开文件core/global-api/extend.js中的代码可以发现下面的代码:

1
2
3
4
5
6
7
/**
* Each instance constructor, including Vue, has a unique
* cid. This enables us to create wrapped "child
* constructors" for prototypal inheritance and cache them.
*/
Vue.cid = 0
let cid = 1

所以继承Vue的构造函数(包括Vue本身)都有cid属性。

接下来就是resolveAsyncComponent函数内部的实现了,这块涉及到了异步组件的实现过程,内容比较多但是却相对独立,我新开了一篇文章来写。

获取最新选项

调用resolveConstructorOptions函数,以便在Ctor的父类构造器的选项发生改变时重新获取选项。这个函数在Vue.js源码学习 —— 合并选项前的预处理中有详细说过。

transformModel

如果开发者在data中传了model字段,就调用transformModel函数将组件的v-model信息(valuecallback) 转成propsevents。这是什么意思呢?为什么要这么做呢?

还是通过例子来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const checkbox = {
name: 'checkbox',
model: {
prop: 'checked',
event: 'change'
},
render (h) {
let self = this
let vnodedata = {
attrs: {
type: 'checkbox'
},
on: {
click: function (event) {
self.$emit('change', event.target.checked)
}
}
}
return h('input', vnodedata)
}
}

上面的例子中通过model选项定义了checkbox组件的v-model指令,model.propv-model指令得到的值,model.eventv-model指令要触发的事件名称。on.clickinput元素被点击时会被触发,这个方法调用了$emit来触发model.event事件。这样就把值传出去了。

看一下如何使用checkbox组件的v-model指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
new Vue({
el: '#app',
data: { checked: false },
template: '<checkbox v-model="checked"></checkbox>',
components: {
'checkbox': checkbox
},
watch: {
// 这里可以测试到,每点击checkbox时都会收到值
checked (val) {
console.log(val)
}
}
})

如果上面的Vue实例中用render而不用template渲染组件时,该如何使用checkboxv-model指令呢?这时就需要用到data.model了,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
new Vue({
el: '#app',
data: { checked: false },
render (h) {
return h(checkbox, {
model: {
value: this.checked,
callback: function (val) {
this.checked = val
console.log(val)
}
}
})
}
})

上面Vue实例的checked的值可以传递给checkbox组件,在checkbox组件选中状态改变时,上面的callback也会被触发。

会有这样的效果就是因为在transformModel函数中,对data.model进行了如下转换:

1
2
3
4
5
6
7
8
9
10
11
12
data: {
model: {
value: this.checked,
callback: ƒ (val)
},
attrs: {
checked: data.model.value
},
on: {
change:ƒ (val)
}
}

如果原来的节点数据对象中就有on.change,会将callback与之合并为一个数组。

attrs中的各项是普通的 HTML attribute,所以这里相当于是给input元素的checked属性赋值。

on中是事件监听器,这里有一个change事件的监听,所以能够收到$emit('change', event.target.checked)发出的信息。

所以上面的例子可以直接改成使用attrson

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
new Vue({
el: '#app',
data: { checked: false },
render (h) {
return h(checkbox, {
attrs: {
checked: this.checked
},
on: {
change: function (val) {
console.log(val)
this.checked = val
}
}
})
}
})

上面分析的其实是如何自定义组件v-model行为,那如果在组件中没有明确给出model选项,默认使用的model选项如下,propvalueeventinput

1
2
3
4
model: {
prop: 'value',
event: 'input'
},

因为在transformModel函数中有这样一段代码:

1
2
const prop = (options.model && options.model.prop) || 'value'
const event = (options.model && options.model.event) || 'input'

对于checkbox组件只能明确给出model选项,如果是普通的输入框直接用默认的model选项就可以了,举个例子看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const inputText = {
name: 'inputText',
render (h) {
let self = this
return h('input', {
on: {
click: function (event) {
self.$emit('input', event.target.value)
}
}
})
}
}

new Vue({
el: '#app',
data: { text: 'Hi' },
render (h) {
return h(inputText, {
model: {
value: this.text,
callback: function (val) {
this.text = val
console.log(val)
}
}
})
}
})

extractPropsFromVNodeData

extractPropsFromVNodeData这个函数主要是用来从节点的数据对象(vnodedata)中取propsattrs中的值,然后赋值给组件的props选项中对应的属性。看个例子就明白了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const child = {
name: 'child',
props: [ 'href', 'myBlog', 'referrerpolicy' ],
render (h) {
return h('a', {
attrs: { href: this.href, referrerpolicy: this.referrerpolicy } },
`${this.myBlog}`)
}
}

const vm = new Vue({
el: '#app',
data: { site: 'http://shinancao.cn', title: '意林的小站' },
render (h) {
return h(child, {
// 这里的 props 和 attrs 实际上是给 child 中的 props 传值
props: { 'my-blog': this.title},
attrs: { href: this.site, referrerpolicy: 'no-referrer' }
})
}
})

经过extractPropsFromVNodeData函数后,得到propsData如下:

1
2
3
4
5
{
myBlog: '意林的小站'
href: 'http://shinancao.cn',
referrerpolicy: 'no-referrer'
}

checkProp函数用key找了一次,没找到又用altKey找了一次,目的就是如果组件中的propskey是驼峰形式,但是在给组件传值时可以用以-连接的形式,像例子中的my-blog一样。

这样就实现了给组件传值的目的。得到的propsData会作为componentOptions中的一项。

函数式组件

如果组件构造器选项中设置了functional = true,就会调用createFunctionalComponent函数创建一个函数式组件返回。

关于createFunctionalComponent函数的实现暂时先放下了,后面找时间再补上。

listeners

接着到下面这段代码:

1
2
3
4
5
6
// extract listeners, since these needs to be treated as
// child component listeners instead of DOM listeners
const listeners = data.on
// replace with listeners with .native modifier
// so it gets processed during parent component patch.
data.on = data.nativeOn

注释说的比较清晰了,listeners也会作为componentOptions中的一项。

抽象组件

官方文档中没有找到与抽象组件有关的介绍,但是从Ctor.options.abstract可以看出,abstract这个选项是可以公开给开发者使用的。在Vue内置的组件<transition><keep-alive>等等都会看到abstract: true,也就是说这些组件是抽象的。

抽象组件跟正常的组件很像,除了它们不会渲染内容到DOM中。抽象组件的作用是给已有组件添加一些行为。

所以下面这段代码先清空了data,如果有slot,再重新设置会data中:

1
2
3
4
5
6
7
8
9
10
11
if (isTrue(Ctor.options.abstract)) {
// abstract components do not keep anything
// other than props & listeners & slot

// work around flow
const slot = data.slot
data = {}
if (slot) {
data.slot = slot
}
}

但目前看到的是只保留了slotdata中,propslisteners可能就是最后放在componentOptions中的,后面看代码时看看能否找到答案。

installComponentHooks

installComponentHooks这个函数做的事情就是把开发者放在data中的hook与内部的hook进行合并,最后放回到data.hook中。合并操作主要是在mergeHook函数中完成的:

1
2
3
4
5
6
7
8
9
10
11
12
// f1 toMerge
// f2 existing
function mergeHook (f1: any, f2: any): Function {
const merged = (a, b) => {
// flow complains about extra args which is why we use any
// 先执行内部的,再执行用户传进来的。
f1(a, b)
f2(a, b)
}
merged._merged = true
return merged
}

可以看到,是返回了一个新函数,在这个函数中先调用了内部的钩子函数,再调用用户传进来的钩子函数。

这里的钩子函数是谁提供的呢?会在什么时候被调用呢?

componentVNodeHooks中给了一行注释,并且定义了所有钩子函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// inline hooks to be invoked on component VNodes during patch
const componentVNodeHooks = {
init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
// ...
},
prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
// ...
},
insert (vnode: MountedComponentVNode) {
// ...
},
destroy (vnode: MountedComponentVNode) {
// ...
}
}

从注释上可知,这些钩子函数在给组件的vnode打补丁(patch)期间会被调用,后面讲到patch.js时会看到的。

当然,开发者也有机会在打补丁的各个时期执行一些事情,通过在data中加上hook就可以,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
let child = {
name: 'child',
props: [ 'href', 'myBlog', 'referrerpolicy' ],
render (h) {
return h('a', {
attrs: { href: this.href, referrerpolicy: this.referrerpolicy },
},
`${this.myBlog}`)
}
}

new Vue({
el: '#app',
data: { site: 'http://shinancao.cn', title: '意林的小站' },
render (h) {
return h(child, {
props: { 'my-blog': this.title},
attrs: { href: this.site, 'referrerpolicy': 'no-referrer' },
hook: {
init (vnode, hydrating) {
console.log('init child')
},
prepatch(oldVnode, vnode) {
console.log('prepatch child')
},
insert (vnode) {
console.log('insert child')
},
destroy (vnode) {
console.log('destroy child')
}
}
})
}
})

在浏览器中跑一下会发现,init先被调用了,然后insert被调用了,其他钩子函数还没有得到执行。

接下来就看看内部的钩子函数中都做了什么。

init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
if (
vnode.componentInstance &&
!vnode.componentInstance._isDestroyed &&
vnode.data.keepAlive
) {
// kept-alive components, treat as a patch
const mountedNode: any = vnode // work around flow
componentVNodeHooks.prepatch(mountedNode, mountedNode)
} else {
const child = vnode.componentInstance = createComponentInstanceForVnode(
vnode,
activeInstance
)
child.$mount(hydrating ? vnode.elm : undefined, hydrating)
}
}

从上面的if判断可以出,如果组件实例已经存在并且没有被销毁,并且是<keep-alive>组件时,会调用prepatch函数更新页面。如果不是上面的情况会使用createComponentInstanceForVnode函数重新创建组件实例。

再看一下createComponentInstanceForVnode函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export function createComponentInstanceForVnode (
vnode: any, // we know it's MountedComponentVNode but flow doesn't
parent: any, // activeInstance in lifecycle state
): Component {
const options: InternalComponentOptions = {
_isComponent: true,
_parentVnode: vnode,
parent
}
// check inline-template render functions
const inlineTemplate = vnode.data.inlineTemplate
if (isDef(inlineTemplate)) {
options.render = inlineTemplate.render
options.staticRenderFns = inlineTemplate.staticRenderFns
}
return new vnode.componentOptions.Ctor(options)
}

这里用于创建实例对象的构造器Ctor,就是在createComponent函数中最后返回vnode时放在componentOptions中的。createComponentInstanceForVnode函数中收集了要传入Ctor的选项,同时_parentVnode.componentOptions中存放着propsDatalisteners等内容,最后使用new vnode.componentOptions.Ctor(options)创建了组件的实例对象,在Vue.prototype._init函数中就可以使用到propsDatalisteners等这些值了。

这里出现了inlineTemplate,这个是在实现内联模板时用到的。我们现在给上面例子中的Vue实例的render中加上inlineTemplate字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
new Vue({
el: '#app',
data: { site: 'http://shinancao.cn', title: '意林的小站' },
render (h) {
return h(child, {
props: { 'my-blog': this.title},
attrs: { href: this.site, 'referrerpolicy': 'no-referrer' },
hook: {
// ...
},
inlineTemplate: {
render (h) {
return h('p', 'Hello World')
},
staticRenderFns (h) {
return [
function (h) {
return h('header', 'I\'m a template!')
}
]
}
}
})
}
})

在浏览器中运行后,你会发现Hello World被渲染出来了,child中原来的内容没有渲染。这肯定是优先使用了inlineTemplate.render,而没有使用child中的render,这是怎么做到的呢?那child中用户传的这些选项现在又放在哪了呢?

答案就在这个组件实例的初始化过程中。你可能已经注意到了createComponentInstanceForVnode中出现了_isComponent: true了,我们在看选项合并那部分时也遇到了_isComponent,现在就能跟前面的接上了。

现在打开core/instance/init.js文件,还记得下面这段代码吗?

1
2
3
4
5
6
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)
}

在初始化内部创建的组件实例时,选项的处理有所不同,上面也看到了optionscreateComponentInstanceForVnode函数中收集到传过来的,不存在初始化一般实例对象
时的那些问题。

initInternalComponent函数中,有下面这样一行代码:

1
const opts = vm.$options = Object.create(vm.constructor.options)

child中用户传的选项一开始是与Vue.options进行了合并后,放在了Ctor.options中的(也就是vm.constructor.options)。现在通过上面这样代码把构造器中的选项直接放在了实例选项的原型上。然后把从createComponentInstanceForVnode函数中传过来的options直接放在了vm.$options中,同时componentOptions中的各项也会放到vm.$options中。这就解答了刚刚问题,vm.$options.render会比vm.$options.__proto__.render被优先使用。

至此,文件core/instance/init.js中的代码也全部看完了。

其他钩子函数

  • prepatch:主要用来更新组件的。

  • insert:主要是给组件实例对象比较上_isMountedtrue,同时去执行用户设置的mounted钩子选项。对于<keep-alive>组件会有特殊处理。

  • destroy:如果不是<keep-alive>组件就调用$destroy()进行销毁操作,否则就调用deactivateChildComponent函数将组件变为非活跃状态。

这几个钩子中用到的主要函数几乎都来自文件core/instance/lifecycle.js中,所以等到分析这个文件的时候再讲他们到底干了什么。这几个函数如下:

1
2
3
4
5
6
7
import {
callHook,
activeInstance,
updateChildComponent,
activateChildComponent,
deactivateChildComponent
} from '../instance/lifecycle'

还有一个queueActivatedComponent函数,来自core/observer/scheduler,等看到observer部分时再说。

创建组件的过程就说完了,相信你会对Vue公开给开发者使用的实例方法$createElement有更深的理解,尤其是对vnodedata的理解。最后在创建vnode时传了很多值,我们目前还没有看到这些参数到底有什么用,接一下看patch部分的代码应该会得到答案。

在创建组件的过程中,涉及到了两种特殊组件,一种是异步组件,对于异步组件实现的分析在Vue.js源码学习 —— 异步组件的实现这篇文章中。还有一种是函数式组件,这个找时间再看吧,跟我们后面要分析的内容关联不是很大。

本文作者:意林
本文链接:http://shinancao.cn/2020/01/15/Vue-Source-Code-04/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!