阅读 11477

【2019 前端进阶之路】Vue 组件间通信方式完整版

前言

Vue.js 在现今使用有多广泛不用多说,而 Vue 的一大特点就是组件化。本期要讲的,便是 Vue 组件间通信方式的总结,这也几乎是近年 Vue 面试中的必考题。注:文中示例都基于 Vue 脚手架讲解,会用到一些 Element UI 示例。

【前端进阶之路】会作为一个新系列连载,后续会更多优质前端内容,感兴趣的同学不妨关注一下。 文章最后有 交流群公众号,可以一起学习交流,感谢🍻。

  • 下期预告:深入 Vue 响应式原理,手写一个 mvvm

组件

组件是可以复用的 Vue 实例。 — Vue 官方文档

在进入主题之前,还是决定先简单聊聊组件。在 Vue 中,根据注册方式的不同,可以分为:

  • 局部组件 (局部注册)
  • 全局组件 (全局注册)

顾名思义,全局注册的组件,可以用在 Vue 实例的任意模板中。但是带来的隐患是,在 webpack 模块化构建时,即便你没有在项目中使用这个组件,依然会打包到最终的项目代码中。而局部组件,则需要在使用到的实例中注册该组件。

// 全局注册
// install.js
import Icon from './Icon.vue';
const install = {
    install:function(Vue){
        Vue.component('VIcon', Icon);
    }
};
export default install;
// main.js
import install from './install.js'; // 引入全局插件
Vue.use(install); // 注册

// 局部注册
import VIcon from './Icon.vue';
export default{
    components: {
        VIcon
    }
}

// 使用
<v-icon> </v-icon>
复制代码

根据应用场景的不同,又可以分为:

  • 页面组件:我们使用 Vue 时,每个路由代表的页面,都可以称之为组件。
  • 基础组件:就像上面栗子中的 Icon 组件,就是一个典型的基础组件。基本上不掺杂业务逻辑,在项目中可能被大量使用,易于移植。类似的基础组件还有 Button、Input 等,常见于各类 UI 组件库。
  • 业务组件:业务组件和项目具体的业务逻辑有大量耦合,一般抽离于当前项目。

以上就是组件的简单介绍,那我们到底为什么要推崇组件化?组件化有什么好处?复用?我个人认为组件化最大的好处,便是解耦,易于项目管理。所以在大型项目管理中,组件化是非常有必要的。当然,这并不是今天学习的重点,以后有机会再聊。

正因为在 Vue 中处处都是组件,而我们也偏向于组件化、模块化。那我们在一堆组件中,便需要解决一个问题 — 组件间通信。下面,我们就进入今天的主题,Vue 的组件间通信。

组件间通信

组件间通信是我们在 Vue 项目中不可避免的问题,深刻了解了 Vue 组件间通信的几种方式,才能让我们在处理各种交互问题时游刃有余。

Props

Vue 中,最基本的通信方式就是 Props,它是父子组件通信中父组件传值给子组件的一种方式。它允许以数组形式接收,但是更推荐你开启类型检查的形式。更详细的类型检查前往 vue 文档

// communication.vue
<communication-sub v-bind="dataProps"></communication-sub>
// v-bind="dataProps" 等同于 :title="title",适用于多个参数一起传递
···
data() {
    return {
        dataProps: {
            title: '我是父组件的值',
        }
    }
}
// communication-sub.vue
<div class="communication-sub">
    {{title}}
</div>
···
props: ['title']
// 更推荐开启类型检查
props: {
    title: {
        type: String,
        required: true,
        default: '' // 允许指定默认值,引用类型需要函数返回
    }
}
···
复制代码

我们都知道,Props 是单向数据流,这是 Vue 为了避免子组件意外改变父组件的状态,从而导致数据流向难以理解而做出的限制。所以 Vue 推荐需要改动的时候,通过改变父组件的值从而触发 Props 的响应。或者,我们可以在接收非引用类型的值时,使用子组件自身的 data 做一次接收。

props: ['title'],
data: function () {
  return {
    text: this.title
  };
}
复制代码

为什么是非引用类型呢,因为在 JavaScript 中,引用类型的赋值,实际是内存地址的传递。所以上面栗子中的简单赋值,显然会指向同一个内存地址,所以如果是数组或是对象,你可能需要一次深拷贝。

let obj = JSON.parse(JSON.stringify(obj));
复制代码

上面这个操作有一些缺陷,不能序列化函数、undefined、循环引用等,详见传送门,但是也能应付一些日常情况了。

事实上,在 Props 是引用类型时,单独修改对象、数组的某个属性或下标,Vue 并不会抛出错误。当然,前提是你要非常清楚自己在做什么,并写好注释,防止你的小伙伴们疑惑。

有的同学可能知道,在组件上绑定的属性,如果没有在组件内部用 Props 声明,会默认绑定到组件的根元素上去。还是之前的栗子:

<communication-sub v-bind="dataProps" class="one" type="div"></communication-sub>
复制代码

结果如下:

这是 Vue 默认处理的,而且,除了 class 和 style 采用合并策略,其它特性(如上栗 type)会替换掉原来根元素上的属性值。当然,我们也可以显示的在组件内部关闭掉这个特性:

...
inheritAttrs: false,
props: ['title']
复制代码

利用 inheritAttrs,我们还可以方便的把组件绑定的其它特性,转移到我们指定的元素上。这就需要用到下一个我们要讲的 $attrs 了。

attrs、listeners

我们在使用组件库的时候经常会这么写:

<el-input v-model="input" placeholder="请输入内容"></el-input>
复制代码

实际渲染后:

可以看到我们指定的的 placeholder 是渲染在 input 上的,但是 input 并不是根元素。难道都用 Props 声明后,再赋值给 input?这种情况就可以用到 $attrs 了,改造一下我们之前那个栗子。

// communication.vue
<template>
    <div class="communication">
        <communication-sub v-bind="dataProps" class="input" type="text" placeholder="请输入内容">
        </communication-sub>
    </div>
</template>
<script>
import communicationSub from './communication-sub.vue';
export default{
    name: 'communication',
    data() {
        return {
            dataProps: {
                title: '我是 communication 的值',
            }
        }
    },
    components: {
        communicationSub
    }
}
</script>

// communication-sub.vue
···
<div class="communication-sub">
    <input v-bind="$attrs" v-model="title"></input>
</div>
···
 export default {
    inheritAttrs: false
}
复制代码

可以看到,type 已经转移到了子元素 input 标签上,但是 class 没有。这是因为 inheritAttrs: false 选项不会影响 style 和 class 的绑定。可以看出 $attrs 则是将没有被组件内部 Props 声明的传值(也叫非 Props 特性)收集起来的一个对象,再通过 v-bind 将其绑定在指定元素上。这也是 Element 等组件库采用的策略。

这里需要注意一点,通过 $attrs 指定给元素的属性,不会与该元素原有属性发生合并或替换,而是以原有属性为准。举个例子,假如我将上述 input 的 type 默认设置为 password。

<input v-bind="$attrs" v-model="title" type="password"></input>
复制代码

则不会采用 $attrs 中的 type: 'text',将以 password 为准,所以如果需要默认值的属性,建议不要用这种方式。

$listeners$attrs 类似,可以看做是一个包含了组件上所有事件监听器(包括自定义事件、不包括.native修饰的事件)的对象。它也支持上述的写法,适用于将事件安放于组件内指定元素上。

// communication.vue
<communication-sub v-bind="dataProps"
class="input"
type="text"
placeholder="请输入内容"
@focus="onFocus" >
</communication-sub>
···
methods: {
    onFocus() {
        console.log('onFocus');
    }
}
// communication-sub.vue
<input v-bind="$attrs" v-model="title" v-on="$listeners"></input>
复制代码

给之前的栗子绑定一个聚焦事件,在子组件中通过 $listeners 绑定给 input,则会在 input 聚焦时触发。

那么除了用在这种给组件内指定元素绑定特性和事件的情况,还有哪些场景可以用到呢?官方说明:在创建更高层次的组件时非常有用。比如在祖孙组件中传递数据,在孙子组件中触发事件后要在祖辈中做相应更新。我们继续之前的栗子:在孙辈组件触发点击事件,然后在祖辈中修改相应的 data。

// communication.vue
<communication-sub v-bind="dataProps"
                @click="onCommunicationClick">
</communication-sub>
···
methods: {
    onCommunicationClick() {
        this.dataProps.title = '我是点击之后的值';
    }
};

// communication-sub.vue
<communication-min-sub v-on="$listeners"></communication-min-sub> // 子组件中将事件透传到孙辈

// communication-min-sub.vue
<template>
    <div class="communication-min-sub">
        <p>我是 communication-min-sub</p>
        <button v-on="$listeners">click</button>
    </div>
</template>
<script>
export default{
    name: 'communication-min-sub',
    inheritAttrs: false
}
</script>
复制代码

这样就能很方便的在多级组件的子级组件中,快速访问到父组件的数据和方法。正如在刚才的例子中,button 点击时,是直接调用的 communication.vue 中定义的方法。

依赖注入 provide、inject

上面的方法,在大多数多级组件嵌套的场景很有用,但有时我们遇到的并不一定是有父子关系的组件。比如基础组件中的 Select 下拉选择器。

<el-select v-model="value" placeholder="请选择">
    <el-option
      v-for="item in options"
      :key="item.value"
      :label="item.label"
      :value="item.value">
    </el-option>
</el-select>
复制代码

相信大家都使用过上栗或者类似于上栗的基础组件,它们借助 vue 插槽 实现。所以这个时候,el-select 和 el-option 之间的数据通信,我们之前的 $attrs$listeners就没有用武之地了。有同学可能不太理解上面的代码为什么要通信,我简单介绍一下 Element 的处理方式:

我们可以简单的认为(Element 源码比这个要稍复杂,为了方便理解,简化一下,如有需要,可直接前往源码阅读),在 el-select 中有一个 input 元素,el-option 中是一列渲染好的 li。根据需求,我们在选中某个 li 的时候,要通知 input 展示相应的数据。而且我们在实际使用的时候,一般还伴随 el-form、el-form-item等组件,所以迫切需要一种方式:

可以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在起上下游关系成立的时间里始终生效。--- Vue 文档

有同学可能会想到,这种多级的可以用 Vuex、EventBus等方式,当然可以。只不过我们现在的前提是基础组件,一般第三方组件库是不会增加一些额外的依赖的。事实上 Vue 本身并不推荐直接在业务中使用 provide、inject,一般在组件、插件库用到的比较多。

但是在项目比较小、业务逻辑比较简单的时候,我们完全不必特意引入 Vuex。只要使用得当,provide、inject 确实不失为一种好办法。说了这么多,我们来看一下具体用法,我们将之前的栗子,改为用 provide、inject 来实现。

// communication.vue
<communication-sub v-bind="dataProps" >
</communication-sub>
// @click="onCommunicationClick" 移除之前绑定的时间
···
// 在 provide 添加子代需要接收的方法 onCommunicationClick,
// 也可以直接指定为 this,子代便能访问父代所有的数据和方法。
provide: function () {
    return {
        onCommunicationClick: this.onCommunicationClick
    }
},
methods: {
    onCommunicationClick() {
        this.dataProps.title = '我是点击之后的值';
    }
};

// communication-sub.vue
<communication-min-sub></communication-min-sub>
// 移除之前的 v-on="$listeners",因为在这个组件中不需要用到父组件的方法,所以不用做其它处理

// communication-min-sub.vue
<template>
    <div class="communication-min-sub">
        ···
        <button @click="onCommunicationClick">click</button>
        // 移除 v-on="$listeners",然后绑定 inject 接收到的方法
    </div>
</template>
<script>
export default{
    name: 'communication-min-sub',
    inject: ['onCommunicationClick'] // inject 接收父组件的方法
}
</script>
复制代码

这种写法和之前的 $listeners 得到的效果是一样的,就不再放图了。大家可以自己尝试一下,也可以前往源码 webrtc-stream

思考:有些同学可能会想到,如果我在根实例,app.vue 中如此设置:

<script>
  export default {
    provide () {
      return {
        app: this // 设置app为this
      }
    },
    data () {
      return {
        userInfo: null,
        otherState: null
      }
    }
  }
</script>
复制代码

那这样把所有的状态管理都放在 app.data 中,所有的子代中不就可以共享了吗?是不是就不需要 Vuex 了呢?实际上,Vue 本身就提供了一个方法来访问根实例 $root,所以即使没有 provide 也是可以做到的。那为什么不这么用呢?还是前面提到的原因,不利于追踪维护,也失去了所谓状态管理的意义。不过,如果你的项目足够小的话,依然可以这么使用。

ref、parent、children

我们前面一直说的都是子组件如何触达父组件,那么父组件能不能访问到子组件呢?当然是可以的。

  • ref

简单来说就是获取元素的 Dom 对象和子组件实例。如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素;如果用在子组件上,引用就指向组件实例。获取 Dom 元素就是为了进行一些 Dom 操作,需要注意的一点就是,要在 Dom 加载完成后使用,否则可能获取不到。比如我要将之前 input 的字体颜色改成红色:

<input type="text" v-model="dataProps.title" ref="input">
...
mounted() {
    this.$nextTick(_ => { // 确保 Dom 更新完成
        this.$refs['input'].style.color = 'red';
    });
}
// 这里只是举一个栗子,实际项目中的需求,最好通过 class 的方式,尽量减少 Dom 操作。
复制代码

那什么情况下需要获取组件实例呢?比如父元素的某个状态改变,需要子组件进行 http 请求更新数据。通常情况下,我们会选择通过 Props 将状态传递给子组件,然后子组件进行 Watch 监测,如果有变更,则进行相应操作。这个时候,我们便可以选择使用 ref。

<child ref="child"></child>
···
<script>
  export default {
    methods () {
      onStateChange() { // 变更状态后直接调用子组件方法进行更新
          this.$refs['child'].updateData();
      }
    }
  }
</script>
复制代码
  • $children$parent

无独有偶,$children 同样可以完成上面的任务。$children$parent,顾名思义,一个会找到当前组件的子组件,一个会找到当前组件的父组件。如果有多个子组件,需要依赖组件实例的 name 属性。改写一下上面的方法:

<script>
  export default {
    methods () {
      onStateChange() { // 子组件返回的是一个数组,多个子组件用 $options.name 区分。
          this.$children[0].updateData();
      }
    }
  }
</script>
复制代码

$parent$children 用法一样,不过 $parent 返回的父组件实例,不是数组,因为父组件肯定只有一个。ref、parent、children 它们几个的一个缺点就是无法处理跨级组件和兄弟组件,后续我们会介绍 dispatch 和 broadcast 方法,实现跨级通信。

emit、on、off

$emit,想必大家都非常熟悉,我们通常用作父子组件间通信,我们也叫它自定义事件。$emit$on都是组件自身的方法,$on 可以监听 $emit 派发的事件,$off 则用来取消事件监听。这也是我们下一个要讲的通信方式 EventBus 所依赖的原理。

// 父组件
<template>
    <button-component @clickButton="clickButton"></button-component>
    // 在父组件利用 v-on 监听
</template>
<script>
export default {
    methods: {
      clickButton () { ··· }
    }
}
</script>

// 子组件
<template>
    <button @click="handleClick"></button>
</template>
<script>
export default {
    methods: {
      handleClick () { // 触发 $emit
        this.$emit('clickButton');
      }
    },
    mounted() {
        this.$on('clickButton', (...arr) => { // 也可以自己监听 $emit,虽然没什么用···
            console.log(...arr);
        })
    }
}
</script>
复制代码

EventBus

$emit的痛点依然是不支持跨级和兄弟组件,Vue 官方推荐我们使用一个新的 Vue 实例来做一个全局的事件通信(或者叫中央事件总线···),也就是我们要讲的 EventBus。了解过的同学都知道,正常的 bus,我们一般会挂载到 Vue 的 prototype 上,方便全局调用。

// main.js
Vue.prototype.$bus = new Vue();
复制代码

依旧改写之前的栗子:

<!--communication.vue-->
<communication-sub v-bind="dataProps" >
</communication-sub>
···
beforeDestroy() { <!-- 实例销毁时,需要卸载监听事件 -->
    this.$bus.$off('busClick');
},
created() {  <!-- 监听子组件触发的 Bus 事件-->
    this.$bus.$on('busClick', (data) => {
        this.dataProps.title = data;
    });
}

<!--communication-min-sub.vue-->
<template>
    <div class="communication-min-sub">
        <button @click="busClick">click bus</button>
        <!--子组件触发点击事件-->
    </div>
</template>
<script>
export default{
    methods: {
        busClick() {
            this.$bus.$emit('busClick', 'bus 触发了');
        }
    }
}
</script>
复制代码

这是一个基础的 EventBus 的实现。现在我们设想一下,类似于 userInfo 这样的信息,在很多页面都需要用到,那我们需要在许多页面都做 $on 监听的操作。那能否将这些操作整合到一起呢?我们一起来看:

 // 新建一个 eventBus.js
import Vue from 'vue';
const bus = new Vue({
    data () {
        return {
            userInfo: {}
        }
    },
    created () {
        this.$on('getUserInfo', val => {
            this.userInfo = val;
        })
    }
});
export default bus;
// main.js
import bus from './eventBus';
Vue.prototype.$bus = bus;
// app.vue
methods: {
    getUserInfo() {
        ajax.post(***).then(data => {
            this.$bus.$emit('getUserInfo', data); // 通知 EventBus 更新 userInfo
        })
    }
}
复制代码

这样在其他页面用到 userInfo 的时候,只需要 this.$bus.userInfo 就可以了。注意刚刚其实没有用 off 卸载掉监听,因为其实 userInfo 这种全局信息,并没有一个准确的说要销毁的时机,浏览器关闭的时候,也用不着我们处理了。但是,如果只是某个页面组件用到的,建议还是用最开始的方法,在页面销毁的时候卸载掉。

不过反过来讲,既然用到了 EventBus,说明状态管理并不复杂,否则还是建议用 Vuex 来做。最后再给大家推荐一篇文章 Vue中eventbus很头疼?我来帮你,作者处理 EventBus 的思路很巧妙,大家不妨仔细看看。

派发与广播:dispatch 与 broadcast

此部分参考自 Element 源码

在 Vue 1.x 的实现中,有 $dispatch$broadcast 方法,但是在 2.x 被废弃了。$dispatch 的主要作用是向上级组件派发事件,$broadcast 则是向下级广播。它们的优点是都支持跨级,再看一下官方废弃这两个方法的理由:

因为基于组件树结构的事件流方式实在是让人难以理解,并且在组件结构扩展的过程中会变得越来越脆弱。并且 $dispatch$broadcast 也没有解决兄弟组件间的通信问题。

可以看到,主要原因是在组件结构扩展后不易理解,以及没有解决兄弟组件通信的问题。但是对于组件库来说,这依旧是十分有用的,所以它们大多自己实现了这两个方法。对我们来讲,也许在项目中用不到,但学习这种解决问题的思路,是十分必要的。

派发和广播,依赖于组件的 name(最怕此处有人说:如果不写 name,这方法不就没用了?2333···),以此来逐级查找对应的组件实例。Element 的实现中,给所有的组件都加了一个 componentName 属性,所以它是根据 componentName 来查找的。我们在实现的时候还是直接用 name。

我们先来看一下 $dispatch 的简单用法,再来分析思路。

<!--communication-min-sub.vue-->
<template>
  <button @click="handleDispatch">dispatch</button>
</template>
<script>
import Emitter from '../../utils/emitter';
export default {
  mixins: [Emitter], // 混入,方便直接调用
  methods: {
    handleDispatch () {
      this.dispatch('communication', 'onMessage', '触发了dispatch');
    }
  }
}
</script>
复制代码
<!--communication.vue-->
<script>
  export default {
    beforeDestroy() { // 销毁
        this.$off('onMessage');
    },
    mounted () {
        this.$on('onMessage', (data) => { // 监听
            this.dataProps.title = data;
        })
    }
  }
</script>
复制代码

现在明确一下目标,dispatch 方法接收三个参数,组件 name、事件名称、基础数据(可不传)。要做到向上跨级派发事件,需要向上找到指定 name 的组件实例,利用我们前文提到的 $emit方法做派送,所以在指定组件就可以用 $on 来监听了。所以 dispatch 本质上就是向上查找到指定组件并触发其自身的 $emit,以此来做响应,broadcast 则相反。那么如何做到跨级查找呢?

function broadcast(componentName, eventName, params) {
  this.$children.forEach(child => { // 遍历所有的 $children
    var name = child.$options.name; // 拿到实例的name,Element 此处用的 componentName
    if (name === componentName) { // 如果是想要的那个,进行广播
      child.$emit.apply(child, [eventName].concat(params));
    } else { // 不是则递归查找 直到 $children 为 []
      broadcast.apply(child, [componentName, eventName].concat([params]));
    }
  });
}
export default {
  methods: {
    dispatch(componentName, eventName, params) {
      var parent = this.$parent || this.$root;
      var name = parent.$options.name;
      while (parent && (!name || name !== componentName)) {
      // 存在 parent 且 (不存在 name 或 name 和 指定参数不一样) 则继续查找
        parent = parent.$parent; // 不存在继续取上级
        if (parent) {
          name = parent.$options.name; // 存在上级 再次赋值并再次循环,进行判断
        }
      }
      if (parent) { // 找到以后 如果有 进行事件派发
        parent.$emit.apply(parent, [eventName].concat(params));
      }
    },
    broadcast(componentName, eventName, params) {
      broadcast.call(this, componentName, eventName, params);
    }
  }
};
复制代码

以上是详细的 emitter.js,可以看见,这和我们之前讲到的 $parent$children$emit$on都密切相关。这也是为什么把它放到后面讲的原因。之前说过,派发和广播并没有解决兄弟组件通信的问题,所以这里大家也可以拓展思考一下,如何支持兄弟组件间通信。依然是依赖于$parent$children,可以找到任意指定组件。

Vuex

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。--- 官方文档

Vuex 相信大家都比较熟悉了,我不打算在这里把 API 再演示一遍。因为我觉得,官方文档 已经非常详细了。Vuex 的核心是单向数据流,并以相应规则保证所有的状态管理都可追踪、可预测。

我们需要知道什么时候该用 Vuex,如果你的项目比较小,状态管理比较简单,完全没有必要使用 Vuex,你可以考虑我们前文提到的几种方式。

总结

本期文章内容到这里就讲完了,我们来总结回顾一下:

  • 子组件触达父组件的方式:Props、$parent$attrs$listeners、provide 和 inject、$dispatch
  • 父组件触达子组件的方式:$emit$on$children$refbroadcast
  • 全局通信:EventBus、Vuex

本来想按照是否支持跨级来分,但是这里的界定比较模糊:如果逐级传递,有些也能做到跨级,但这并不是我们想要的。所以我们只要自己清楚在什么情况下该怎么用就好了。

交流群

qq前端交流群:960807765,欢迎各种技术交流,期待你的加入

后记

如果你看到了这里,且本文对你有一点帮助的话,希望你可以动动小手支持一下作者,感谢🍻。文中如有不对之处,也欢迎大家指出,共勉。

更多文章:

欢迎关注公众号 前端发动机,第一时间获得作者文章推送,还有各类前端优质文章,希望在未来的前端路上,与你一同成长。

关注下面的标签,发现更多相似文章
评论