本文主要基于的vue 2.x版本
vue中的通信方式比较多,以我个人使用到的做了一些总结,大致有一下几种:
- props
- events
- slot
- ref
- eventBus
- vuex
- provide / inject
vue通信方式思维图
下面就介绍每种通信方式的使用方式以及各自的区别。
props
props
是最常用,最基本的一种通信方式,通过子组件声明参数,父组件直接传入参数。这种方式大家应该都早就烂熟于心了,就没什么好介绍的了,不过需要注意的是,如果子组件没有在props
中声明的参数,父组件传入,会被识别为普通的attr属性,子组件将无法通过this
直接获取。
一下就是几个要点:
- 需要提前在组件props中声明
String
类型的传参,如果不是变量可以直接传入,无需使用:标注,比如<button size='mini'>按钮</button>
- Boolean类型的传参,如果值是true,且无需变量控制,则可以直接只写参数名,比如
<button disabled>按钮</button>
events
events也是最常用的一种通信方式,主要用于子组件返回参数给父组件。最简单的用法就是有子组件触发事件,父组件监听事件。
//子组件
this.$emit('submit',data)
//父组件
<child @submit='submit'/>
@
其实就是v-on
的缩写,v-on除了可以监听子组件的事件,也可以监听dom的原生事件,并且具有比较多的修饰符来实现更多的方式,官方示例。
这边有一个知识点:this.$emit触发的事件,除了父组件可以监听之外,子组件自身也可以监听到该事件,这个vue的文档中有说明,官方示例,具体有什么用途我们后续说到。
props和events是最常用的两种组件通信方式,除了一般的使用方式,vue还提供了
inheritAttrs:false
属性,用于组件的封装使用,可以让父组件传入的所有属性都通过this.$attrs
访问到,这样我们在包装一个组件时,可以通过v-bing=$attrs一次把所有的父组件传入属性绑定到子组件中,v-on=$listeners绑定所有父组件监听事件。
slot
slot插槽也是一种非常常用的组件传参方式,通过slot可以更灵活的控制通用组件的自定义渲染内容,slot除了可以传入vnode渲染内容,也可以通过slot传递参数。
<!-- 接收 prop 的具名插槽 -->
<infinite-scroll>
<template v-slot:item="slotProps">
<div class="item">
{{ slotProps.item.text }}
</div>
</template>
</infinite-scroll>
这种用法在element ui中很多组件都有使用到,比如autocomplete
组件的自定义渲染等。
ref
ref实例的方式实现的数据交互是比较强大的,通过调用组件的实例,可以获取到组件所有的数据,实例方法等。通过在子组件上声明ref属性,就可以通过$refs调取到对应的组件实例。
通过调取实例,我们可以操作实例中的所有参数,比如
//代码仅作实例,实际并非如此
<input ref='input' />
this.$refs.input.value = 1
类似以上例子,我们可以在其他组件中直接修改某个组件中的数据,同样也可以读取其数据,但是一般不推荐这种用法。组件的数据应该由组件自身去维护,如果有类似需要修改其数据的情况,我们应该在该组件中声明一个修改数据的方法,而其他组件去调取该方法来修改数据,这样做的目的是让组件更加的可控,不会出现一些莫名其妙的错误,而无法定位。
除了通过ref的声明方式获取组件的实例,我们也可以通过$parent
获取父组件的实例,$root
获取当前组件树的根实例,$children
获取当前组件的子组件实例数组等。
需要注意的是,所有的组件实例都需要在该组件mounted
生命周期后才可以被调取到,在该声明周期之前,实例都未挂载,无法正常调取到,这个在vue的文档中有提到:
mounted
实例被挂载后调用,这时 el 被新创建的 vm.$el 替换了。如果根实例挂载到了一个文档内的元素上,当 mounted 被调用时 vm.$el 也在文档内。
provide / inject
该用法是在vue 2.2之后新增的,主要作用就是用于父组件共享数据给子组件使用,听上去用法是不是和props一样吗?可以说效果确实如此,但是使用的场景不同。
props传参必须是子组件直接使用在父组件中才可以方便传参,如果是子组件是父组件中的子组件的子组件,那么父组件怎么怎么传参给该子组件呢?
比较笨一点的方法,就是父组件先传参给第一层子组件,子组件在传参给其下层子组件。可以说效果是可以达到,但是非常的麻烦,而且如果层次多了,工作量大增的同时,每层组件都得声明一边props,一次传入,非常的不可控,也不优雅。
provide/inject就是为了解决这样的问题。父组件声明需要传递的参数,子组件声明需要接受的参数,中间不管嵌套几层组件,子组件都可以直接在其this中访问到父组件传递的参数。
官方示例:
// 父级组件提供 'foo'
var Provider = {
provide: {
foo: 'bar'
},
// ...
}
// 子组件注入 'foo'
var Child = {
inject: ['foo'],
created () {
console.log(this.foo) // => "bar"
}
// ...
}
这个我们在使用element ui中的form组件应该有感受,我们只需要在form组件上传入label-width,rules等参数,而所有的子组件el-form-item都可以起作用。后面我会以el-form为例,分析一下各种通信方式在其中的应用。
eventBus
从名字就可以看出是一种事件通信方式,其实原理很简单,就是利用的上面提到的组件实例可以监听到自身emit触发的事件,来实现的一种全局通信方式,同时也利用了ref的实例通信方式。使用方式如下:
//main.js
Vue.prototype.$EventBus=new Vue() //在vue的实例上挂载一个vue的实例
//组件A 利用共享的实例注册其监听事件
this.$EventBus.$on('input',(value)=>{console.log(value)})
//组件B 利用共享的实例触发事件
this.$EventBus.$emit('input','test')
这样本质其实是new的vue实例触发事件,监听自己的事件,然后通过共享该实例,来让不同的组件通过该实例来做到数据通信。
需要注意的是,由于该实例是挂载在整个vue实例上的,所以即便在组件销毁之后,事件监听任然是存在的,为了避免重复触发事件,在不需要时或者组件销毁时,通过this.$EventBus.$off去注销监听事件。
vuex
vuex就不多说了,vue中重量级的状态管理管理库了,通过vue的数据的响应式来驱动,具体的用法就直接看文档吧
示例分析
以上差不多把我比较熟知的几种vue通信方式都简要的梳理了一边,但是观看用法可能比较枯燥,不够形象,下面就以element中的form组件为例,来看看该组件的封装中使用了哪些通信方式,element作为vue中最流行的开源框架,很多的设计和实现是非常值得学习的。
直接看源码吧:
//el-form
<template>
...
</template>
<script>
import objectAssign from 'element-ui/src/utils/merge';
export default {
name: 'ElForm',
componentName: 'ElForm',
provide() {
return {
elForm: this
};
},
这边一上来就可以看到el-form这个父组件使用了provide
声明了elForm
属性,并传入了自身的实例this
,那么很显然,子组件肯定会接受该参数,我们看到子组件代码。
export default {
name: 'ElFormItem',
componentName: 'ElFormItem',
mixins: [emitter],
provide() {
return {
elFormItem: this
};
},
inject: ['elForm'],
果然,ElFormItem中利用inject获取了elForm参数,同时声明了一个elFormItem的共享参数,这个自然也是给其子组件使用的,我们暂时不看。我们先看看通过elForm,ElFormItem组件可以实现什么。
//el-form-item
contentStyle() {
const ret = {};
const label = this.label;
if (this.form.labelPosition === 'top' || this.form.inline) return ret;
if (!label && !this.labelWidth && this.isNested) return ret;
const labelWidth = this.labelWidth || this.form.labelWidth;
if (labelWidth === 'auto') {
if (this.labelWidth === 'auto') {
ret.marginLeft = this.computedLabelWidth;
} else if (this.form.labelWidth === 'auto') {
ret.marginLeft = this.elForm.autoLabelWidth;
}
} else {
ret.marginLeft = labelWidth;
}
return ret;
},
form() {
let parent = this.$parent;
let parentName = parent.$options.componentName;
while (parentName !== 'ElForm') {
if (parentName === 'ElFormItem') {
this.isNested = true;
}
parent = parent.$parent;
parentName = parent.$options.componentName;
}
return parent;
},
_formSize() {
return this.elForm.size;
},
通过上面的代码,我们可以看到子组件直接可以通过this.elForm
获取到父组件上传入的一些公共的配置,但是同时我们注意到还有一个form
的computed
参数,实现的方式是循环查找到ElForm
这个父级组件实例。
这么看来this.elForm
岂不是和this.form
是一个效果,那这么做的意义是什么呢?
其实这是一个历史原因,因为上面提到provide
的特性是在vue 2.2之后才新增的,而element ui早在这之前就有这些组件了,最初的实现方式则是通过computed中的循环查找的方式来获取的父级实例实现的参数共享,而在element ui 2.0之后的版本则新增了provide
的方式来共享参数,之后的功能则是基于该特性来实现的,老原来的代码并未使用新特性重构(以上是个人参考github仓库推测而来)
接着往下看,我们知道el-form主要实现的一个功能就是数据校验,我们在输入组件中输入值可以触发el-form的数据校验和错误提示,使用方式就是在el-form上传入mode,rules以及在el-form-item上传入prop,然后就可以根据校验规则触发数据校验,这个是怎么实现的呢,细看代码。
//首先我们找一个简单的组件入手,直接从el-input来看,其他原理都是一样的。
//el-input
inject: {
elForm: {
default: ''
},
elFormItem: {
default: ''
}
}
//computed
_elFormItemSize() {
return (this.elFormItem || {}).elFormItemSize;
},
validateState() {
return this.elFormItem ? this.elFormItem.validateState : '';
},
needStatusIcon() {
return this.elForm ? this.elForm.statusIcon : false;
}
上面的参数我们可以看到有大量的共享参数的获取,而这些在1.4.13版本的elementui中式没有的,基本也可以验证上面的推测
//methods
handleBlur(event) {
this.focused = false;
this.$emit('blur', event);
if (this.validateEvent) {
this.dispatch('ElFormItem', 'el.form.blur', [this.value]);
}
},
在el-input的失焦事件中,我们可以看到一个this.dispatch('ElFormItem', 'el.form.blur', [this.value]);
很明显,这个是和el-from-item组件相关的事件。
不着急,我们先看一下这个dispatch是一个什么方法
//element-ui/src/mixins/emitter.js
function broadcast(componentName, eventName, params) {
this.$children.forEach(child => {
var name = child.$options.componentName;
if (name === componentName) {
child.$emit.apply(child, [eventName].concat(params));
} else {
broadcast.apply(child, [componentName, eventName].concat([params]));
}
});
}
export default {
methods: {
dispatch(componentName, eventName, params) {
var parent = this.$parent || this.$root;
var name = parent.$options.componentName;
while (parent && (!name || name !== componentName)) {
parent = parent.$parent;
if (parent) {
name = parent.$options.componentName;
}
}
if (parent) {
parent.$emit.apply(parent, [eventName].concat(params));
}
},
broadcast(componentName, eventName, params) {
broadcast.call(this, componentName, eventName, params);
}
}
};
可以看到这边有两个方法,一个dispath一个broadcast,从名字就可以看出来,一个是广播事件,一个是派发事件,从dispatch实现上可以看出和上面提到的compued中的form是非常像的,通过向上循环查找对应的组件实例,来触发事件。而boradcast则是怎么向下查找来触发事件,所以他们一个是子组件使用的,一个是父组件使用的,。
所以我们在上面就看到input组件通过该方法向el-form-item来触发输入事件,而el-form-item中我们可以看到利用broadcast事件来重置输入组件的参数等。
说到这边有人就要问了,前面不是说可以通过eventBus的方式实现全局的事件派发和监听吗,为什么要搞这一套,这么麻烦。那是因为element ui作为一个组件库,他是独立使用的,而eventBus则依赖于在main.js中挂载实例才可以使用,作为一个组件库肯定是做到这样的,他必须要可以独立使用,而不能依赖外部不确定性的参数。
resetField() {
this.validateState = '';
this.validateMessage = '';
let model = this.form.model;
...
this.broadcast('ElTimeSelect', 'fieldReset', this.initialValue);
},
回到el-form-item组件
//el-form-item
addValidateEvents() {
const rules = this.getRules();
if (rules.length || this.required !== undefined) {
this.$on('el.form.blur', this.onFieldBlur);
this.$on('el.form.change', this.onFieldChange);
}
},
removeValidateEvents() {
this.$off();
}
...
onFieldBlur() {
this.validate('blur');
},
onFieldChange() {
if (this.validateDisabled) {
this.validateDisabled = false;
return;
}
this.validate('change');
},
可以看到确实有监听相关的事件,那么很明显了,input在输入之后触发失焦事件,el-form-item监听输入组件的失焦和change事件,在触发参数校验,而rules则通过父级实例获取到,
同时我们看到onFieldBlur等事件虽然有监听输入事件,但是并没有接收事件触发之后的传值,继续看校验方法:
computed:{
fieldValue() {
const model = this.form.model;
if (!model || !this.prop) { return; }
let path = this.prop;
if (path.indexOf(':') !== -1) {
path = path.replace(/:/, '.');
}
return getPropByPath(model, path, true).v;
},
},
methods: {
validate(trigger, callback = noop) {
this.validateDisabled = false;
const rules = this.getFilteredRule(trigger);
if ((!rules || rules.length === 0) && this.required === undefined) {
callback();
return true;
}
this.validateState = 'validating';
const descriptor = {};
if (rules && rules.length > 0) {
rules.forEach(rule => {
delete rule.trigger;
});
}
descriptor[this.prop] = rules;
const validator = new AsyncValidator(descriptor);
const model = {};
model[this.prop] = this.fieldValue;
validator.validate(model, { firstFields: true }, (errors, invalidFields) => {
this.validateState = !errors ? 'success' : 'error';
this.validateMessage = errors ? errors[0].message : '';
callback(this.validateMessage, invalidFields);
this.elForm && this.elForm.$emit('validate', this.prop, !errors, this.validateMessage || null);
});
},
通过fieldValue的compued就可以看到在el-form上传入的mode的作用的,输入组件的值,是通过父级传入的formData来获取的。通过rules传入的参数校验之后,控制el-form-item的错误消息是否显示,通知利用共享的elForm实例触发校验事件,因此我们可以在elform组件上才可以监听任意输入组件触发的校验事件:
而所有的输入组件自然是通过slot的方式传入el-fom-item的咯
<div class="el-form-item__content" :style="contentStyle">
<slot></slot>
<transition name="el-zoom-in-top">
<slot
v-if="validateState === 'error' && showMessage && form.showMessage"
name="error"
:error="validateMessage">
<div
class="el-form-item__error"
:class="{
'el-form-item__error--inline': typeof inlineMessage === 'boolean'
? inlineMessage
: (elForm && elForm.inlineMessage || false)
}"
>
{{validateMessage}}
</div>
</slot>
</transition>
同时也可以看到有一个name=”error”的slot,传入了一个error的参数,这个就是element ui文中的自定义检验显示方式啦,通过slot传入了错误信息
可以看到el-form除了没有使用vuex来通信以外,上面的通信方式几乎全部使用到了,所以从优秀的开源项目中,我们是可以学习到非常多优秀的设计和实现的,对于我们的水平提高和开发工作还是很有帮助的。
以上是个人的一些总结和分析,或许有很多说的不正确的地方,和大家交流一下想法,也希望能帮到有需要的童鞋下😁