vue的通信方式总结及案例分析

552 阅读6分钟

本文主要基于的vue 2.x版本

vue中的通信方式比较多,以我个人使用到的做了一些总结,大致有一下几种:

  • props
  • events
  • slot
  • ref
  • eventBus
  • vuex
  • provide / inject

image.png 下面就介绍每种通信方式的使用方式以及各自的区别。

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一次把所有的父组件传入属性绑定到子组件中,von=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 替换了。如果根实例挂载到了一个文档内的元素上,当 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.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获取到父组件上传入的一些公共的配置,但是同时我们注意到还有一个formcomputed参数,实现的方式是循环查找到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来通信以外,上面的通信方式几乎全部使用到了,所以从优秀的开源项目中,我们是可以学习到非常多优秀的设计和实现的,对于我们的水平提高和开发工作还是很有帮助的。

以上是个人的一些总结和分析,或许有很多说的不正确的地方,和大家交流一下想法,也希望能帮到有需要的童鞋下😁