Vue框架有一大特色,就是组件化

即我们可以把一个复杂的页面,拆分成一个个独立的组件,这样子更加便于维护和调试;再者,组件还有一个特定就是可复用性,我们可以将多个页面的共有部分抽取成一个组件,比如导航栏、底部信息、轮播图等等。

组件化的实现,有助于我们提供开发效率、方便重复使用,简化调试步骤,提升项目可维护性。

而组件化的实现,就避免不了组件之间的通信,即数据传输和方法调用。而且现实开发中,不仅仅只有父子组件,还会有兄弟组件、爷孙组件等等。

我们先简单过一遍常见的组件通讯方法。

Vue2组件通信方法

之前我写过一篇关于Vue2的组件通信方法的文章,相对比较详细,这里的话就比较简单带过。

属性绑定(props)

https://cn.vuejs.org/v2/guide/components-props.html

对于父组件向子组件传递数据的时候,我们常用的就是属性绑定。

  1. // 父组件
  2. <comp :msg="Hello World"></comp>
  3. // 子组件
  4. <script>
  5. export default {
  6. props: {
  7. msg: { // 使用props接收msg属性
  8. type: String, // 进行类型判断
  9. default: '' // 设置默认值
  10. }
  11. }
  12. }
  13. </script>

事件绑定(on、emit)

https://cn.vuejs.org/v2/api/#实例方法-事件

当子组件先调用父组件的方法的时候,我们常用就是将父组件的方法绑定给子组件,然后子组件通过$emit调用。

而且,我们也可以通过此方法,实现子组件向父组件进行传值。

// 父组件
<comp @saySomething="saySomething"></comp>

<script>
      export default {
          methods: {
              saySomething(msg) {   // 接收到子组件的参数
                  console.log(msg);
            }
        }
    }
</script>

// 子组件
<script>
      export default {
          methods: {
              saySomething() {
                  // 调用绑定的saySomething事件,并且将HelloWorld作为参数传过去
                  this.$emit('saySomething', 'HelloWorld')
            }
        }
    }
</script>

访问子组件实例(ref)

https://cn.vuejs.org/v2/guide/components-edge-cases.html#访问子组件实例或子元素

当父组件想要调用子组件的方法的时候,我们可以先获取子组件的实例,然后直接通过实例调用方法。

// 父组件
<comp ref="comp"></comp>

<script>
      export default {
          methods: {
              saySomething() { 
                  // 通过this.$refs.comp获取到comp组件实例,然后直接调用其方法并传入参数。
                  this.$refs.comp.saySomething("HelloWorld");
            }
        }
    }
</script>


// 子组件
<script>
      export default {
          methods: {
              saySomething(msg) {
                  console.log(msg);
            }
        }
    }
</script>

事件总线

对于兄弟组件通信,或者多级组件之间的通信,经常都是使用事件总线去实现。

而事件总线不是Vue原生自带的,这些需要我们自己去封装或者找插件去实现。而它的实现原理其实很简单,就是模仿原生的$emit$on$once$off的实现。

vue2中通常也会用new Vue()去代替Bus,但在vue3就取消了$on全局接口,就只能同自己实现或者使用插件

class Bus {
    constructor(){
          // 存放所有事件
            this.callbacks = {}
    }

      // 事件绑定
    $on(name, fn){
        this.callbacks[name] = this.callbacks[name] || []
        this.callbacks[name].push(fn)
    }

      // 事件派发
    $emit(name, args){
        if(this.callbacks[name]){
        this.callbacks[name].forEach(cb => cb(args))
        }
    }
}


// main.js
Vue.prototype.$bus = new Bus()


// comp1
this.$bus.$on('saySomthing', (msg) => { console.log(msg) });

// comp2
this.$bus.$emit('saySomthing', 'HelloWorld');

VueX

https://vuex.vuejs.org/zh/

对于复杂结构的组件通讯,我们可以选择VueX去实现通讯,这里就不多讲了。

非prop特性($attrs/$listeners

https://cn.vuejs.org/v2/api/#vm-attrs

$attrs 包含了父作用域中不作为 prop 被识别 (且获取) 的 attribute 绑定 (classstyle 除外)。

$listeners包含了父作用域中的 (不含 .native 修饰器的) v-on 事件监听器。

这两种常用于隔代通讯的情况上。

// 父组件
<comp1 :msg1="helloWorld" :msg2="HiWorld" @saySomething="saySomething"></comp1>


// 子组件 comp1
<comp2 v-bind="$attrs" v-on="$listeners"></comp2>
<!-- 此时的$attrs只存在msg1,因为msg2已经被props识别了 -->
<!-- 上面的代码,等同于下列代码 -->
<!-- <comp2 :msg1="$attrs.msg1" @saySomething="$listeners.saySomething"></comp2> -->

<script>
      export default {
          props: ['msg2']
    }
</script>


// 孙组件 comp2
<div @click="$emit('saySomething')">{{msg1}}</div>

<script>
      export default {
          props: ['msg1']
    }
</script>

$parent/$root/$children

https://cn.vuejs.org/v2/api/#vm-parent

我们可以通过$parent$root$children分别获取到父级组件实例、根组件实例、子组件实例。

$children返回是一个数组,并且不能保证数组中子元素的顺序。

我们可以使用这些接口,配合$on$emit实现一些组件通讯。

/* 兄弟组件使用共同祖辈搭桥 */
// comp1
this.$parent.$on('foo', handle)
// comp2
this.$parent.$emit('foo')
// slot通信
<comp1>
      <comp2></comp2>
</comp1>


// comp1
<div>
      <slot></slot>
</div>

<script>
      export default {
          methods: {
              saySomething() {
                  // 遍历$children进行派发事件
                  this.$children.forEach(comp => comp.$emit('saySomething', 'HelloWorld'))
            }
        }
    }
</script>


// comp2
<script>
      export default {
          mounted() {
               // 在mounted的事件进行事件绑定
              this.$on('saySomething', (msg) => {
                  console.log(msg)
            })
        }
    }
</script>

provide/inject

https://cn.vuejs.org/v2/api/#provide-inject

provideinject能够实现祖先组件与后代组件之间的传值,也就是说不论是多少代,只要是嵌套关系,都可以使用该属性进行传值。

// 祖先组件
provide() { 
      return {
          msg: 'Hello World' // 提供一个msg属性
    }
}

// 后代组件
inject: ['msg'];  // 注入属性
mounted() {
      console.log(this.msg);
}

Vue2实现Form表单

下列代码会有删减,可以到 github 查看源码

我们通过模仿一下ElementUIForm表单实现,来实践一下组件通信。

我们大致一个Form组件结构如下:

<o-form>
    <o-form-item>
        <o-input></o-input>
    </o-form-item>
 </o-form>

因此我们先实现一下三个组件的页面结构。

<!--  OForm.vue  -->
<template>
    <div>
        <slot></slot>
    </div>
</template>


<!--  OFormItem.vue  -->
<template>
    <div class="input-box">
        <!--   标签   -->
        <p v-if="label" class="label">{{ label }}:</p>
        <slot></slot>
        <!--   错误提示   -->
        <p v-if="error" class="error">{{ error }}</p>
    </div>
</template>


<!--  OInput.vue  -->
<template>
    <div>
        <input>
    </div>
</template>

首先我们从最简单的开始,实现inputvalue双向绑定,这时候需要用到v-model去实现。

<!--  app.vue  -->
<template>
    <o-form>
        <o-form-item>
            <o-input v-model="model.email" @input="input"></o-input>
        </o-form-item>
     </o-form>
</template>

<script>
      export default {
          data() {
              return {
                  model: {
                      email: ''
                }
            }
        },
          methods: {
              input(value) {
                  console.log(`value = ${value},this.model.email = ${this.model.email}`);
            }
        }
    }
</script>



<!--  OInput.vue  -->
<template>
    <div>
        <input :value="value" @input="input">
    </div>
</template>

<script>
      export default {
          props: {
              value: {
                  type: String
            }
        },
          methods: {
              input(e) {
                  // 派发input事件
                  this.$emit('input', e.target.value);
            }
        }
    }
</script>

通过上面我们可是实现最简单的双向绑定,也实现了OInput组件的input事件。

当然我们可以顺便实现一下input的其他属性,比如placeholdertype等等,当然这些属性可以使用$attrs来实现,这样子就不需要一个个props出来。

<!--  app.vue  -->
<template>
    <o-form>
        <o-form-item>
            <o-input v-model="model.email" @input="input" type="email" placeholder="请输入邮箱"></o-input>
        </o-form-item>
     </o-form>
</template>


<!--  OInput.vue  -->
<template>
    <div>
          <!--    使用$attrs绑定input其它属性    -->
        <input :value="value" @input="input" v-bind="$attrs">
    </div>
</template>

<script>
      export default {
          inheritAttrs: false,  // 不继承默认属性
          ...
    }
</script>

接下来也实现一下o-form-item的属性绑定,这个组件出现简单显示label和错误信息之外,其实还有一个功能,就是数据校验,这个在后面再细讲。

这个组件默认传入两个属性,一个是label,一个是propprop主要适用于后面数据校验判断该form-item是对应哪个数据。

<!--  app.vue  -->
<template>
    <o-form>
        <o-form-item label="邮箱" prop="email">
            <o-input v-model="model.email" @input="input" type="email" placeholder="请输入邮箱"></o-input>
        </o-form-item>
          <o-form-item label="密码" prop="password">
              <o-input v-model="model.password" placeholder="请输入密码" type="password"/>
        </o-form-item>
     </o-form>
</template>


<!--  OFormItem.vue  -->
<script>
      export default {
          props: {
            label: {
                type: String,
                default: ''
            },
            prop: {  // 用于判断该item是哪个属性
                type: String,
                default: ''
            }
        },
        data() {
            return {
                error: ''  // 错误信息
            }
        }
    }
</script>

同时将检验规则rulesmodel传入给OForm

<!--  app.vue  -->
<template>
    <o-form :model="model" :rules="rules">
        <o-form-item label="邮箱" prop="email">
            <o-input v-model="model.email" @input="input" type="email" placeholder="请输入邮箱"></o-input>
        </o-form-item>
          <o-form-item label="密码" prop="password">
              <o-input v-model="model.password" placeholder="请输入密码" type="password"/>
        </o-form-item>
     </o-form>
</template>

<script>
      export default {
        data() {
            return {
                model: {
                    email: '',
                    password: ''
                },
                  // 校验规则
                rules: {
                    email: [
                        {required: true, message: "请输⼊邮箱"},  // 必填
                        {type: 'email', message: "请输⼊正确的邮箱"}  // 邮箱格式
                    ],
                    password: [
                        {required: true, message: "请输⼊密码"},  // 必填
                        {min: 6, message: "密码长度不少于6位"}   // 不少于6位
                    ]
                }
            }
        }
    }
</script>


<!--  OForm.vue  -->
<script>
      export default {
        props: {
              model: {
                  type: Object,
                  required: true   // 必填项
            },
            rules: {
                type: Object
            }
        }
    }
</script>

现在基本的组件传参已经实现了,接下来我们就要来实现一下校验功能。

首先,我们在输入的过程中,就要开始调用数据检验了,因此在OInput组件中的input方法,需要调用到OFormItem的检验方法。但因为是使用slot嵌套,所以我们可以使用$parent去派发事件。

// OInput.vue
input(e) {
    // 派发input事件
    this.$emit('input', e.target.value);
        // 派发validate事件
    this.$parent.$emit('validate');
}


// OFormItem.vue
 mounted() {
       // 在mounted钩子实现事件绑定
       this.$on('validate', () => {this.validate()}); 
 },
methods: {
       // 校验方法
     validate() {}
 }

紧接着就来实现validate方法。

首先我们需要从OForm组件拿到对应的值和规则,因为我们已经有prop值,因此我们只需要拿到OFormmodelrules属性即可,然后通过prop获取对应的值和规则。

而这时,我们就可以使用到provideinject来实现。

// OForm.vue
provide() {
    return {
          form: this  // 返回整个实例
    }
}


// OFormItem.vue
inject: ['form'],  // 注入
methods: {
       // 校验方法
     validate() {
               // 获取对应的值和规则
          const value = this.form.model[this.prop];
          const rules = this.form.rules[this.prop];
     }
 }

这个校验使用了async-validator,这里就简单带过。

<!--  OFormItem.vue  -->
<script>
import Schema from "async-validator";

export default {
    ...

    methods: {
        validate() {
            // 获取对应的值和规则
            const value = this.form.model[this.prop];
            const rules = this.form.rules[this.prop];

            // 创建规则实例
            const schema = new Schema({[this.prop]: rules});
              // 调用实例方法validate进行校验,该方法返回Promise
            return schema.validate({[this.prop]: value}, errors => {
                if (errors) {
                      // 显示错误信息
                    this.error = errors[0].message;
                } else {
                    this.error = '';
                }
            })
        }
    }
}
</script>

最后一个功能,就是提交表单的时候,需要全部表单校验一遍。因此点击提交按钮的时候,需要调用到OForm里的校验方法。

<!--  app.vue  -->
<template>
    <o-form :model="model" :rules="rules">
        <o-form-item label="邮箱" prop="email">
               <o-input v-model="model.email" @input="input" type="email" placeholder="请输入邮箱"></o-input>
        </o-form-item>
          <o-form-item label="密码" prop="password">
              <o-input v-model="model.password" placeholder="请输入密码" type="password"/>
        </o-form-item>
          <o-form-item>
              <button @click="register">注册</button>
      </o-form-item>
     </o-form>
</template>

<script>
export default {
    ...
    methods: {
        register() {
              // 调用form组件的validate方法
            this.$refs.form.validate(valid => valid ? alert('注册成功') : '');
        }
    },
}
</script>

OForm组件中的validate方法,需要遍历调用每个OFormItemvalidate方法,并且将结果方法。

// OForm.vue
validate(cb) {
      const tasks = this.$children 
          .filter(item => item.prop)  // 遍历$children,筛选掉没有prop值的实例
          .map(item => item.validate());  // 调用子组件的validate方法

      // 因为OFormItem的validate方法返回的是Promise,因此通过Promise.all判断是否全都通过
      Promise.all(tasks)  
            .then(() => cb(true))
            .catch(() => cb(false))
}

这时我们的Form组件就基本实现了。

Vue3组件通讯的改动

Vue3中,组件通讯的方法发生了不少变化。

移除了$on$once$off

https://v3.cn.vuejs.org/guide/migration/events-api.html

Vue3不再支持$on$once$off这三个方法,而当我们必须使用此类方法的话,可以通过自己封装EventBus事件总线或者使用第三方库实现。

官方也推荐了mitttiny-emitter这两个库,使用方法也比较简单,可以自己去研究一下。

移除了$children

https://v3.cn.vuejs.org/guide/migration/children.html#children

Vue3同时也移除了$children方法,官方推荐是使用$refs去实现获取子组件的实例。

Vue3composition api中实现$refs也有所不同,因为在setup中的this不是指向组件实例,因此我们不能直接通过this.$refs来获取组件实例。

因此,下面简单写一下新的实现方法:

<template>
    <comp ref="comp"></comp>
</template>

<script>
  import {ref, onMounted} from "vue";

  export default {
      setup() {
          const comp = ref();  // 该变量名必须与上面绑定的名称一致,并初始化的值为空或为null

          onMounted(() => {
              // 在mounted钩子的时候,Vue会将该实例赋值给comp
              // 但如果你在mounted生命周期前访问该值还是为空的
              console.log(comp.value);  
          })

          return {
             comp  // 一定得将该属性暴露出去,否则Vue不会将子组件实例赋值给它
          }
      }
  }
</script>

emits、provide、inject选项

如果在Vue3依旧使用option api的话,依旧可以使用this.$emits以及provideinject选项;但如果使用compsition api的话,emits方法会通过setup参数参入,而provideinject可以通过引入钩子实现。

<script>
  import {provide, inject} from "vue";

  export default {
      setup(props, {attrs, slots, emit}) {
             // 派发事件
            emit('saySomething', 'Hello World');

            // 提供属性
            provide('msg', 'Hello World');
            // 注入属性
            const msg = inject('msg');
      }
  }
</script>

Vue3实现Form表单

下列代码会有删减,可以到 github 查看源码

结构样式跟上面Vue2实现一样,重复的东西我就不多讲,重点是在于后面数据校验的实现上,那部分后面会详细讲一讲。

首先看看app.vue的结构,样式结构没有太大变化,而这边使用了composition api写法。

<template>
    <div class="form">
        <h1 class="title">用户注册</h1>
        <o-form :model="model" :rules="rules" ref="formRef">
            <o-form-item label="邮箱" prop="email">
                <o-input v-model="model.email" @input-event="input" placeholder="请输入邮箱" type="email" />
            </o-form-item>
            <o-form-item label="密码" prop="password">
                <o-input v-model="model.password" placeholder="请输入密码" type="password" />
            </o-form-item>
            <o-form-item>
                <button @click="register">注册</button>
            </o-form-item>
        </o-form>
    </div>
</template>

<script>
import OInput from "./components/OInput.vue";
import OFormItem from "./components/OFormItem.vue";
import OForm from "./components/OForm.vue";
import {ref, reactive} from "vue";

export default {
    name: 'App',
    components: {
        OInput,OFormItem,OForm
    },
    setup() {
        // 表单数据
        const model = reactive({
            email: '',
            password: ''
        })

        // 表单规则
        const rules = reactive({
            email: [
                {required: true, message: "请输⼊邮箱"},
                {type: 'email', message: "请输⼊正确的邮箱"}
            ],
            password: [
                {required: true, message: "请输⼊密码"},
                {min: 6, message: "密码长度不少于6位"}
            ]
        })

        // input方法
        const input = (value) => {
            console.log(`value = ${value},model.email = ${model.email}`);
        }


        // 获取OForm的实例
        const formRef = ref();
        // 提交事件
        const register = () => {
            // 因为点击事件会发生在mounted生命周期后,因此formRef已经被赋值实例
            formRef.value.validate(valid => valid ? alert('注册成功') : '');
        }

        return {
            model,
            rules,
            input,
            register,
            formRef
        }
    }
}
</script>

接着来看看其他组件的基本实现。

<!--  OInput.vue  -->
<template>
    <input v-model="modelValue" v-bind="$attrs" @input="input">
</template>

<script>
export default {
    name: "OInput",
    props: {
           // Vue3中,v-model绑定的值默认为modelValue,而不再是value
        modelValue: {   
            type: String
        }
    },
    setup(props, {emit}) {
        const input = (e) => {
            const value = e.target.value
            // 派发事件
            emit('inputEvent', value);
        }

        return {
            input
        }
    }
}
</script>
<!--  OFormItem.vue  -->
<template>
    <div class="input-box">
        <p v-if="label" class="label">{{ label }}:</p>
        <slot></slot>
        <p v-if="error" class="error">{{ error }}</p>
    </div>
</template>

<script>
import {ref} from "vue";

export default {
    name: "OFormItem",
    props: {
        prop: {
            type: String,
            default: ''
        },
        label: {
            type: String,
            default: ''
        }
    },
    setup() {
        // error响应式变量初始化
        const error = ref('');

        return {
            error
        }
    }
}
</script>
<!--  OForm.vue  -->
<template>
    <div>
        <slot></slot>
    </div>
</template>

<script>
export default {
    name: "OForm",
    props: {
        model: {
            type: Object,
            required: true
        },
        rules: {
            type: Object,
            default: {}
        }
    }
}
</script>

现在基本的组件结构就实现了。

紧接着第一件事就是实现在OInput组件中,在input方法能够调用OFormItem的校验方法。

而在vue2中,我们是通过this.$parent.$emit去派发实现,但是在vue3composition api中显然是不太好这么去实现的,因为在setup中获取不到$parent方法,况且在OFormitem中也使用不了$on去绑定事件。

因此,我们可以使用provideinject的方法,将检验方法传递给OInput组件,然后它直接调用就可以了。

// OFormItem.vue
import {provide} from "vue";

export default {
    setup() {
        ...

        // 校验方法
        const validate = () => {}
        // 提供validate方法
        provide('formItemValidate', validate);

        ...
    }
}


// OInput.vue
import { inject } from 'vue'

export default {
    setup(props, {emit}) {
        // 注入formItemValidate
        const validate = inject('formItemValidate');

        const input = (e) => {
            const value = e.target.value
            emit('inputEvent', value);

            // 调用数据检验
            validate();
        }

        return {
            input
        }
    }
}

紧接着,我们就要实现OFormItem的校验方法,首先要获取到OFormmodelrules属性,同样使用provideinject的方法去实现。

// OForm.vue
import {provide} from "vue";

export default {
    setup({model, rules}) {
        // 向下提供model和rules,此时model和rules本身就是响应式数据,因此子组件注入的时候也是响应式数据
        provide('model', model);
        provide('rules', rules);

       ...
    }
}


// OFormItem.vue
import {inject} from "vue";
import Schema from "async-validator"

export default {
      ...
    setup({prop}) {
       ...

        // 注入model和rules
        const model = inject('model');
        const rules = inject('rules');

        // 校验方法
        const validate = () => {
            // 获取对应的值和校验规则
            const value = model[prop];
            const rule = rules[prop];
            // 进行校验
            const schema = new Schema({[prop]: rule});
            return schema.validate({[prop]: value}, errors => {
                if (errors) {
                    error.value = errors[0].message;
                } else {
                    error.value = '';
                }
            })
        }

        ...
    }
}

最后呢,就是提交表单的时候,需要校验所有的表单数据是否通过。

app.vue中,通过$refs的方法调用OForm的校验方法。

// 获取OForm的实例
const formRef = ref();

// 提交事件
const register = () => {
      // 因为点击事件会发生在mounted生命周期后,因此formRef已经被赋值实例
      formRef.value.validate(valid => valid ? alert('注册成功') : '');
}

而最难实现的就是OFormvalidate方法。

vue2中,我们是直接使用this.$children进行遍历执行就可以了,但是在vue3中,我们没有了$children方法,而且官方推荐的$refs方法也没办法使用,因为我们使用的是slot插槽,无法绑定每个OFormItem上。

这时候,我们需要使用事件总线来实现这个方法。

这里我采用的是自己简单写一个EventBus

// utils/eventBus.js
const eventBus = {
    callBacks: {},
      // 收集事件
    on(name, cb) {
        if(!this.callBacks[name]){
            this.callBacks[name] = [];
        }

        this.callBacks[name].push(cb);
    },

      // 派发事件
    emit(name, args) {
        if(this.callBacks[name]) {
            this.callBacks[name].forEach(cb => cb(args));
        }
    }
}

export default eventBus

紧接着,我们采用的方案是,在OForm组件中,收集每个OFormItem的实例上下文,然后我们就可以直接调用对应实例上下文的validate方法既可。

这个方案有点类似于Vue源码中的依赖收集。

我们需要在OFormItem组件初始化的时候,即mounted生命周期的时候,派发一下收集事件,并将该组件的组件实例上下文作为参数传递过去;即通知OForm的收集,将传入的上下文收集起来。

而在OForm中,我们需要在setup中实现事件绑定,而不应该在OnMounted钩子实现,因为子组件的OnMounted钩子会比父组件的OnMounted先调用,而我们需要在事件派发前先绑定事件。

// OFormItem.vue
import {onMounted, getCurrentInstance} from "vue";
import eventBus from "../utils/eventBus"

export default {
    setup() {
           ...

        onMounted(() => {
            // 在mount周期派发collectContext,让OForm收集该组件上下文
            const instance = getCurrentInstance();
            eventBus.emit('collectContext', instance.ctx);
        })

        return {
              ...
            validate  // 方法必须返回出去,反正OForm获取到的OFormItem实例无法调用该方法
        }
    }
}


// OForm.vue
import eventBus from "../utils/eventBus"

export default {
    ...
    setup({model, rules}) {
          ...

        // 在mount声明之前收集collectContext事件
        const formItemContext = [];
        eventBus.on('collectContext', (instance) => formItemContext.push(instance));

        const validate = (cb) => {
            // 遍历收集到的子组件上下文,调用其校验方法
            const tasks = formItemContext
                .filter(item => item.prop)
                .map(item => item.validate())

            Promise.all(tasks)
                .then(() => cb(true))
                .catch(() => cb(false))
        }

        return {
            validate
        }
    }
}

这时候,我们的Vue3版本表单组件就实现了。