您的位置:首页 > Web前端 > Vue.js

【前端进阶良品】Vue 组件间通信方式完整版

2019-03-24 20:32 447 查看
版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。 本文链接:https://blog.csdn.net/qq_41875664/article/details/98870796

注:文中有许多链接,可以点击原文,获得更好的阅读体验

前言

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

  • 文中示例依然在 🍧🍭😻 webrtc-stream

  • 文章仓库 🍹🍰 fe-code

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

  • 下期预告:深入 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>
  • [p]
    $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 方法,实现跨级通信。[/p]

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.js 1.x 的同学,应该对此有所了解。在 1.x 的实现中,是有 

$dispatch
 和 
$broadcast
 方法的。 
$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
    、 
    $ref
    、 
    broadcast

  • 全局通信:EventBus、Vuex

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

后记

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

  • 本文示例 源码库 webrtc-stream

  • 文章仓库 🍹🍰fe-code

往期文章:

  • 从头到脚撸一个多人视频聊天 — 前端 WebRTC 实战(一)

  • JavaScript 原型和原型链及 canvas 验证码实践

  • 站住,你这个Promise!

  • 💘🍦🙈Vchat — 从头到脚,撸一个社交聊天系统(vue + node + mongodb)

欢迎关注公众号 前端发动机,江三疯的前端二三事,专注技术,也会时常迷糊。希望在未来的前端路上,与你一同成长。


内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: