vue中extend实践及分析
# 定义
引用一个官方的定义:
使用基础 Vue 构造器,创建一个“子类”。参数是一个包含组件选项的对象。
data 选项是特例,需要注意 - 在 Vue.extend() 中它必须是函数
<div id="mount-point"></div>
// 创建构造器
var Profile = Vue.extend({
template: '<p>{{firstName}} {{lastName}} aka {{alias}}</p>',
data: function() {
return {
firstName: 'Walter',
lastName: 'White',
alias: 'Heisenberg'
}
}
})
// 创建 Profile 实例,并挂载到一个元素上。
new Profile().$mount('#mount-point')
2
3
4
5
6
7
8
9
10
11
12
13
14
结果如下: <p>Walter White aka Heisenberg</p>
# 源码分析
你可以在源码目录src/core/global-api/extend.js
下找到这个函数的定义
export function initExtend(Vue: GlobalAPI) {
// 这个cid是一个全局唯一的递增的id
// 缓存的时候会用到它
Vue.cid = 0
let cid = 1
/**
* Class inheritance
*/
Vue.extend = function(extendOptions: Object): Function {
// extendOptions就是我我们传入的组件options
extendOptions = extendOptions || {}
const Super = this
const SuperId = Super.cid
// 每次创建完Sub构造函数后,都会把这个函数储存在extendOptions上的_Ctor中
// 下次如果用再同一个extendOptions创建Sub时
// 就会直接从_Ctor返回
const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
if (cachedCtors[SuperId]) {
return cachedCtors[SuperId]
}
const name = extendOptions.name || Super.options.name
if (process.env.NODE_ENV !== 'production' && name) {
validateComponentName(name)
}
// 创建Sub构造函数
const Sub = function VueComponent(options) {
this._init(options)
}
// 继承Super,如果使用Vue.extend,这里的Super就是Vue
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub
Sub.cid = cid++
// 将组件的options和Vue的options合并,得到一个完整的options
// 可以理解为将Vue的一些全局的属性,比如全局注册的组件和mixin,分给了Sub
Sub.options = mergeOptions(Super.options, extendOptions)
Sub['super'] = Super
// 下面两个设置了下代理,
// 将props和computed代理到了原型上
// 你可以不用关心这个
if (Sub.options.props) {
initProps(Sub)
}
if (Sub.options.computed) {
initComputed(Sub)
}
// 继承Vue的global-api
Sub.extend = Super.extend
Sub.mixin = Super.mixin
Sub.use = Super.use
// 继承assets的api,比如注册组件,指令,过滤器
ASSET_TYPES.forEach(function(type) {
Sub[type] = Super[type]
})
// 在components里添加一个自己
// 不是主要逻辑,可以先不管
if (name) {
Sub.options.components[name] = Sub
}
// 将这些options保存起来
// 一会创建实例的时候会用到
Sub.superOptions = Super.options
Sub.extendOptions = extendOptions
Sub.sealedOptions = extend({}, Sub.options)
// 设置缓存
// 就是上文的缓存
cachedCtors[SuperId] = Sub
return Sub
}
}
function initProps(Comp) {
const props = Comp.options.props
for (const key in props) {
proxy(Comp.prototype, `_props`, key)
}
}
function initComputed(Comp) {
const computed = Comp.options.computed
for (const key in computed) {
defineComputed(Comp.prototype, key, computed[key])
}
}
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
其实这个Vue.extend
做的事情很简单,就是继承 Vue,正如定义中说的那样,创建一个子类,最终返回的这个 Sub 是:
const Sub = function VueComponent(options) {
this._init(options)
}
2
3
那么上文的例子中的new Profile()
执行的就是这个方法了,因为继承了 Vue 的原型,这里的_init
就是 Vue 原型上的_init
方法,你可以在源码目录下src/core/instance/init.js
中找到它:
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)
}
vm._isVue = true
// merge options
if (options && options._isComponent) {
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 = vmnext
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)
}
}
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
这个函数里有很多逻辑,它主要做的事情就是初始化组件的事件,状态等,大多不是我们本次分析的重点,你目前只需要关心里面的这一段代码:
if (options && options._isComponent) {
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}
2
3
4
5
6
7
8
9
执行new Profile()
的时候没有传任何参数,所以这里的 options 是 undefined,会走到 else 分值,然后resolveConstructorOptions(vm.constructor)
其实就是拿到Sub.options
这个东西,你可以在上文的Vue.extend
源码中找到它,然后将Sub.options
和new Profile()
传入的options
合并,再赋值给实例的$options
,所以如果new Profile()
的时候传入了一个 options,这个 options 将会合并到vm.$options
上,然后在这个_init
函数的最后判断了下vm.$options.el
是否存在,存在的话就执行vm.$mount
将组件挂载到 el 上,因为我们没有传 options,所以这里的 el 肯定是不存在的,所以你才会看到例子中的new Profile().$mount('#mount-point')
手动执行了$mount
方法,其实经过这些分析你就会发现,我们直接执行new Profile({ el: '#mount-point' })
也是可以的,除了 el 也可以传其他参数,接着往下看就知道了。
$mount
方法会执行“挂载”,其实内部的整个过程是很复杂的,会执行 render、update、patch 等等,由于这些不是本次文章的重点,你只需要知道她会将组件的 dom 挂载到对应的 dom 节点上就行了,如$mount('#mount-point')
会把组件 dom 挂载到#mount-point
这个元素上。
# 实践使用
# 使用 props
比如我们有一个 MessageBox 组件:
<template>
<div class="message-box">
{{ message }}
</div>
</template>
<script>
export default {
props: {
message: {
type: String,
default: ''
}
}
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
它需要一个 props 来显示这个message
,在使用Vue.extend
时,要想给组件传参数,我们需要在实例化的时候传一个 propsData, 如:
const MessageBoxCtor = Vue.extend(MessageBox)
new MessageBoxCtor({
propsData: {
message: 'hello'
}
}).$mount('#target')
2
3
4
5
6
你可能会不明白为什么要传propsData
,没关系,接下来就来搞懂它,毕竟文章的目的就是彻底分析。
在上文的_init
函数中,在合并完$options
后,还执行了一个函数initState(vm)
,它的作用就是初始化组件状态(props,computed,data):
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)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
别的不看,只看这个:
if (opts.props) initProps(vm, opts.props)
function initProps(vm: Component, propsOptions: Object) {
const propsData = vm.$options.propsData || {}
const props = (vm._props = {})
// ...省略其他逻辑
}
2
3
4
5
6
7
这里的 propsData 就是数据源,他会从vm.$options.propsData
上取,上文说过在执行_init
的时候new MessageBoxCtor(options)
的options
会被合并和vm.$options
上,所以我们就可以在options
中传入propsData
属性,使得initProps()
能取到这个值,从而进行props的初始化。
# 绑定事件
可能有时候我们还想给组件绑定事件,其实这里应该很多小伙伴都知道怎么做,我们可以通过vm.$on
给组件绑定事件,这个也是平时经常用到的一个 api
const MessageBoxCtor = Vue.extend(MessageBox)
const messageBoxInstance = new MessageBoxCtor({
propsData: {
message: 'hello'
}
}).$mount('#target')
messageBoxInstance.$on('some-event', () => {
console.log('success')
})
2
3
4
5
6
7
8
9
# 使用插槽
为了更加灵活的定制组件,我们还可以给组件传入插槽,比如组件可能是这样的:
<template>
<div class="message-box">
{{ message }}
<slot name="footer"/>
</div>
</template>
<script>
export default {
props: {
message: {
type: String,
default: ''
}
}
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这里我们先来分析下,如何才能给组件传入插槽内容?其实这里写的 template 会被 Vue 的编译器编译成一个 render 函数,组件渲染时执行的是这个渲染函数,我们先来看下这个 template 编译后的 render 是什么:
function render() {
with (this) {
return _c(
'div',
{
staticClass: 'message-box'
},
[_v(_s(message)), _t('footer')],
2
)
}
}
2
3
4
5
6
7
8
9
10
11
12
这里的_t('footer')
就是渲染插槽时执行的函数,_t
是renderSlot
的缩写,你可以在源码目录的src/core/instance/render-helpers/render-slot.js
中找到这个函数,为方便理解,我将这个函数做了些简化,去除掉了不重要的逻辑:
export function renderSlot(name, fallback, props) {
const scopedSlotFn = this.$scopedSlots[name]
let nodes /** Array<VNode> */
if (scopedSlotFn) {
// scoped slot
props = props || {}
nodes = scopedSlotFn(props) || fallback
} else {
nodes = this.$slots[name] || fallback
}
return nodes
}
2
3
4
5
6
7
8
9
10
11
12
这个函数就是从$scopedSlots
中取到对应的插槽函数,然后执行这个函数,得到虚拟节点,然后返回虚拟节点,需要注意的是,Vue 在2.6.x
版本中已经将普通插槽和作用域插槽都整合在了$scopedSlots
,所有的插槽都是返回虚拟节点的函数,renderSlot
里面的else
分支中从$slots
取插槽是兼容以前的写法的,所以说如果你用的是Vue2.6.x
版本的话,你是不需要去关心$slots
的。
由于renderSlot
执行在组件实例的作用域中,所以this.$scopedSlots
这里的this
是组件的实例vm
,所以我们只需要在创建完组件实例后,在实例上添加$scopedSlots
就可以了,再根据之前的分析,这个$scopedSlots
是一个对象,其中的 key 是插槽名称,value 是一个返回虚拟节点数组的函数:
const MessageBoxCtor = Vue.extend(MessageBox)
const messageBoxInstance = new MessageBoxCtor({
propsData: {
message: 'hello'
}
})
const h = this.$createElement
messageBoxInstance.$scopedSlots = {
footer: function() {
return [h('div', 'slot-content')]
}
}
messageBoxInstance.$mount('#target')
2
3
4
5
6
7
8
9
10
11
12
13
这里需要注意的是$mount
一定要在设置完$scopedSlots
之后,因为$mount
中会执行渲染函数,我们要保证在执行渲染函数时能获取到$scopedSlots
。
如果你想使用作用域插槽,也很简单,和普通插槽是一样的,只需要在函数中接收参数就可以了:
<slot name="head" :message="message"></slot>
messageBoxInstance.$scopedSlots = {
footer: function(slotData) {
return [h('div', slotData.message)]
}
}
2
3
4
5
6
这样就可以成功渲染出message
了。
# 使用示范
# component/extend
// 方式1
const childComponent = Vue.extend({
data: {},
created() {},
methods: {},
})
Vue.component('child', childComponent)
// 方式2
Vue.component('child', {
name: 'child-component',
data: {},
created() {},
methods: {},
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 应用场景
在 vue 项目中,初始化的根实例后,所有页面基本上都是通过 router 来管理,组件也是通过 import 来进行局部注册,所以组件的创建不需要去关注,相比 extend 要更省心一点点。但是这样做会有几个缺点:
- 组件模板都是事先定义好的,如果我要从接口动态渲染组件怎么办?
- 所有内容都是在 #app 下渲染,注册组件都是在当前位置渲染。如果我要实现一个类似于 window.alert() 提示组件要求像调用 JS 函数一样调用它,该怎么办?
这时候,Vue.extend + vm.$mount 组合就派上用场了。
# 简单实用
# 基础用法
Vue.extend( options )
参数:{Object} options
用法:使用基础 Vue 构造器,创建一个“子类”。参数是一个包含组件选项的对象;
data 选项是特例,需要注意: 在 Vue.extend() 中它必须是函数;
2
3
4
5
<div id="mount-point"></div>
// 创建构造器
var Profile = Vue.extend({
template: '<p>{{firstName}} {{lastName}} aka {{alias}}</p>',
data: function () {
return {
firstName: 'Walter',
lastName: 'White',
alias: 'Heisenberg'
}
}
})
// 创建 Profile 实例,并挂载到一个元素上。
new Profile().$mount('#mount-point')
// 结果如下:
<p>Walter White aka Heisenberg</p>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
可以看到,extend 创建的是 Vue 构造器,而不是我们平时常写的组件实例,所以不可以通过 new Vue({ components: testExtend }) 来直接使用,需要通过 new Profile().$mount('#mount-point') 来挂载到指定的元素上。
# 第二种写法
可以在创建实例的时候传入一个元素,生成的组件将会挂载到这个元素上,跟 $mount 差不多。
// 1. 定义一个vue模版
let tem ={
template:'{{firstName}} {{lastName}} aka {{alias}}',
data:function(){
return{
firstName:'Walter',
lastName:'White',
alias:'Heisenberg'
}
}
// 2. 调用
const TemConstructor = Vue.extend(tem)
const intance = new TemConstructor({el:"#app"}) // 生成一个实例,并且挂载在 #app 上
2
3
4
5
6
7
8
9
10
11
12
13
14