VUE组件间通信方式总结

一、面试题

不用多说,参加过前端面试的小伙伴应该都遇到过,很经典的面试题。下面这些组件间的通信方式不但要会代码实现,还要知道每种方式的优缺点以及适用场景,学会灵活运用。

听别人说过一句话,原文忘记了,只记得大概是说:架构师在技术选型时不是选择最好最前沿的技术,而是选择项目最适合的方案。可能vuereact很好,但是有些项目可能更适合jsp,这是业务场景决定的。因此我们需要熟悉各个技术的特点,灵活的选用最合适的。

关于每一种通信方式的优缺点总结,是自己平时开发和工作中总结出来的,并没有参考其余博客,可能不是特别全面,大家做个参考,可以结合自己的经验进行思考,也欢迎大家提出自己的意见

二、$emit + props

最常用的通信方式,适用于直接父子间的通信。vue是单向数据流,也就是数据只能从父组件流向子组件,这也就是为什么子组件直接修改props会警告。

父组件通过props将值传递到子组件,子组件通过$emit('event', payload)将数据作为payload(负载)传递回父组件

案例: 父组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 父组件 -->
<template>
<div>
<child name="test" @test="testCallback"></child>
</div>
</template>
<script>
import Child from './TestChild';
export default {
components: { Child },
methods: {
// 获取用户列表
testCallback: function(payload) {
// TODO
}
}
};
</script>

子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<template>
<div @click="testEmit">{{name}}</div>
</template>
<script>
export default {
name: 'Child',
props: {
// 子组件通过props接受父组件传递的name属性,可以通过default给默认值
name: {
type: String,
required: false,
default: () => {
return '这是默认值';
}
}
},
methods:{
testEmit: function() {
const payload = {
name: "rambler",
age: 24
}
this.$emit("test", payload);
}
}
};
</script>

这样就完成了父子组件通过propsname传递到子组件,子组件通过$emit方法,将payload传递到父组件

如果是兄弟节点之间通信可以通过他们共同的父组件作为媒介,一个子组件将消息先发送到父组件,再由父组件将消息发送给另一个子组件,但是并不推荐这样做。

优点:通信方式简单,适用父子组件间直接通信。

缺点:不适合跨组件通信方式(需要父组件作为媒介,代价较高)

三、事件总线

事件总线利用的是一种设计模式发布订阅者模式,实现思路就是通过一个vue实例作为载体,这个实例接受各方通过$emit发出的各种事件,在需要的地方引入这个实例并通过$on监听对应的事件,如果不需要了,在通过$off移除这个事件,释放资源

有两种方式

3.1 修改原型链

直接修改全局vue实例的原型链,这样可以随处访问,简单快速。

只需要在main.js中加入这一行代码

1
Vue.prototype.$bus = new Vue();

案例:两个兄弟节点之间通信

父组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div>
<child-a></child-a>
<child-b></child-b>
</div>
</template>

<script>
export default {
name: 'test',
components: {
ChildA: () => import('./ChildA'),
ChildB: () => import('./ChildB')
}
};
</script>

ChildA组件,发布事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div><button @click="send">发布事件</button></div>
</template>

<script>
export default {
methods: {
send: function() {
const payload = {
name: 'rambler',
};
// 注册change事件,所有组件都可以监听这个事件
this.$bus.$emit('change', payload);
}
}
};
</script>

ChildB组件,监听事件并展示响应内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div>监听bus事件:{{ param.name }}</div>
</template>

<script>
export default {
data: function() {
return {
param: {}
};
},
mounted: function() {
// 监听change事件
this.$bus.$on('change', payload => {
this.param = payload;
});
}
};
</script>

3.2 按需引入

另一个种是定义一个单独的js文件,只在需要的地方引入并监听。

1
2
3
import Vue from 'vue';
const Bus = new Vue();
export default Bus;

不需要在main.js引入,只需要在需要的地方引入。父组件不需要修改,修改ChildAChildB组件代码,在script标签最顶端引入这个js文件

1
import Bus from '@/config/bus.js';

同时将ChildAChildB组件中this.$bus更换为Bus即可

优点:相对于props$emit的方式,事件总线可以轻松跨组件通信,实现方式也比较简单

缺点:需要手动移除监听事件(通过$off()),如果想单独移除某个事件的处理函数,不能使用匿名处理函数,必须指定函数名,具体见api,这里不再赘述

四、$attrs 和 $listeners

先说用法,$attrs可以接收到props没有接收到的参数,$listeners可以接收到除了.native修饰的所有绑定的事件

这种方式平时业务开发应用场景比较少,目前个人接触更多的是二次封装组件时,后面会举例说明。

思想有点像继承,通过$attrs来继承父组件提供的属性,通过$listeners来继承父组件的方法(事件)。

举例,二次封装Element-UI的数据表格el-table,这里只是举例介绍用法,不会详细介绍如何具体封装

定义一个组件RamblerTable,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<template>
<div class="rambler-table-container" v-loading="loading">
<el-table :data="list" v-bind="$attrs">
<!-- 这里面根据自己的需要进行表格的二次封装 -->
</el-table>
</div>
</template>

<script>
export default {
name: 'RamblerTable',
data: function() {
return {
list: [
{
id: 1,
name: '测试表格',
age: 2
}
]
};
}
};
</script>

父组件(调用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<div>
<rambler-table :stripe="true" :border="true"></rambler-table>
</div>
</template>

<script>
export default {
name: 'test',
components: {
RamblerTable: () => import('@/components/RamblerTable')
}
};
</script>

可以看到表格已经有了表格和斑马纹,在使用时只需要将el-table提供的属性(stripe, border等)定义rambler-table身上就可以通过$attrs穿透到子组件上

$listener也是同理,只需要稍稍修改RamblerTable

1
2
3
<el-table :data="list" v-bind="$attrs" v-on='$listeners'>
<!-- 这里面根据自己的需要进行表格的二次封装 -->
</el-table>

通过v-on指令将外层注册的事件穿透到el-table身上,这样封装组件可以少些很多重复代码,而且可以让使用者直接阅读element-ui的文档就知道可以使用什么属性和事件

优点:属性穿透,可以利用这个特性二次封装组件

缺点:只适用于父子组件,如果想要穿透多层子组件需要在每一层都通过v-bindv-on进行注册

五、Vuex

通过vuex可以做到和事件总线一样轻松跨组件通信,同时还支持响应式,使用也比较简单,除了特别小的项目,基本是vue项目的标配

使用方法:通过分发(dispatch)action,在action中提交(commit)mutations,在mutation中修改state的值,根据业务需要可能还需要对数据进行持久化(如sessionStorage或者localStorage

如登录流程中,登录成功后,分发action

1
this.$store.dispatch("user/login", response.data);

action中提交mutation

1
2
3
4
login({ commit}, data) {
commit('USER_LOGIN', data.user);
setToken(data.token)
}

mutation中修改state并对数据进行持久化(登录可以考虑用sessionStorage保存用户信息)

1
2
3
4
USER_LOGIN(state, value) {
state.userInfo = value;
sessionStorage.setItem('userInfo', JSON.stringify(value));
}

关于vuex还有一个常考的问题:

actionmutation的区别是什么?答案是关于同步和异步问题,不知道的小伙伴可以查阅相关资料

优点:1. 响应式; 2. 跨组件通信方便; 3. 规范,易于统一管理

缺点:增加开发成本,需要定义actionmutation等,还需要对数据进行持久化

六、provide 和 inject

父组件,通过provide选项提供一个数据,provide可以是一个对象也可以是返回对象的函数(类似data);子组件,通过inject选项注入到当前页面中,inject可以是一个字符串数组,也可以是一个对象(对象有多种形式)。

举个例子:

父组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<template>
<div>
<child-a></child-a>
<child-b></child-b>
</div>
</template>

<script>
export default {
components: {
ChildA: () => import('./ChildA'),
ChildB: () => import('./ChildB')
},
provide: function() {
return {
name: this.name
};
},
data: function() {
return {
name: 'rambler'
};
}
};
</script>

ChildA直接通过对象数组接受

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<p>A:{{ name1 }}</p>
</template>

<script>
export default {
inject: ['name']
// 也可以这样写, 对象的key是绑定到哪个变量上,value是指定搜索哪个变量,这里父组件提供一个name,因此应该是name
inject: {
name1: "name"
}
};
</script>

ChildB通过对象接收,和props一样,可以给一个默认值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div>B:{{ name }}</div>
</template>

<script>
export default {
inject: {
name: {
// 这里故意写错from,本应该写name,这里为了测试默认值,所以故意写错
from: 'haha',
default: '默认值'
}
}
};
</script>

昨天和别人聊起这个provide和inject,因为他不是响应式,开发中可能用的不是这么多,特意去网上看了下如何将数据变成响应式。办法一般是两种,一个是将this传递过去,因为当前实例是响应式的,第二种是利用Vue提供的一个方法Vue.observable( object )

今天又考虑这个问题,为什么一定要做到响应式呢,这个设计出来可能就不是为了这个目的,如果是为了响应式还要跨组件通信,那为什么不用vuex呢。我们需要做的就是结合业务考虑更适合的方式,毕竟通信方式这么多。

优点:无论层级多深,只要是子组件,都可以注入这个provide提供的数据,可以利用这一特性在App.vue中提供数据,也可以做到全局注入。传值方式比较形象

缺点:非响应式,只能向下层提供数据,限制了应用场景

七、$parent、$children以及ref

三者用法类似,都是通过获取组件实例来访问属性和方法进而实现通信。

$parent获取当前组件的直接父组件,$children获取当前组件的子组件(返回一个数组,通过下标访问),$refs中保存着所有通过ref注册的组件,因此可以直接通过$refs获取到组件实例,获取到组件实例就可以访问实例的属性和方法。

这个比较简单,举个简单例子

父节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div>
<child-b name="123"></child-b>
<child-a ref="child"></child-a>
</div>
</template>

<script>
export default {
data: function() {
return {
name: "rambler"
}
}
};
</script>

首先是父节点访问子节点

1
2
3
4
// 访问child-b节点的name属性
this.$children[0].name;
// 通过ref调用child-b的test方法
this.$refs.child.test();

子节点访问父节点

1
2
// 访问父节点
this.$parent.name;

子节点访问兄弟节点

1
2
// 访问兄弟节点的name属性
this.$parent.$children[1].name;

这里和dom操作类似。

优点:通过$parent$children基本可以访问到所有的节点,也可以访问属性以及调用方法。

缺点:跨越层级较大时不方便;代码可读性较差(跨越层级较多时)

八、slot分发,具名插槽

父传子用slot插槽,子传父用具名插槽,也勉强算是一种通信,毕竟父子组件确实访问了彼此的属性

在这之前贴一句官方文档的一句话,防止有些同学看的迷糊

父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。

8.1 父传子的例子

子组件只提供一个插槽

1
2
3
4
5
<template>
<div>
<slot></slot>
</div>
</template>

父组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<div>
<layout>
<h1>{{ name }}</h1>
</layout>
</div>
</template>

<script>
export default {
components: {
Layout: () => import('./Layout')
},
data: function() {
return {
name: '测试'
};
}
};
</script>

虽然h1是在父组件作用域中编译的,但是父组件还是将h1标签分发到子组件中,也算是父组件将数据传到子组件把(是在牵强)

8.2 子传父例子

比如我定义了一个页面布局,Layout布局,预留出了HeaderFooter的插槽

Layout.vue定义了两个具名插槽,同时绑定了两个参数headerfooter-title

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<div>
<slot name="header" :header="header"></slot>
<slot name="footer" :footer-title="footer"></slot>
</div>
</template>

<script>
export default {
name: 'layout',
data: function() {
return {
header: 'rambler-header',
footer: 'rambler-footer'
};
}
};
</script>

父组件调用:

Parent.vue利用子组件的参数渲染页面,子组件通过一个函数将所有绑定的值通过一个对象返回,因此可以利用ES6的解构语法,以及默认值的语法,见下面例子,由于子组件没有绑定noParam这个参数,因此渲染出了默认值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div>
<layout>
<template v-slot:header="headerProps">
<h1>{{ headerProps.header }}</h1>
</template>
<template v-slot:footer="{footerTitle, noParam = '自己随便写一个把'}">
<h1>{{ footerTitle }}</h1>
<h1>{{ noParam }}</h1>
</template>
</layout>
</div>
</template>

<script>
export default {
components: {
Layout: () => import('./Layout')
}
};
</script>

这样定义布局也有个好处,可以将HeaderFooter封装成组件,实现了软件的高可插拔性,在需要时只需要替换对应的组件即可

优点:具名插槽可以让父组件访问子组件的属性,让封装组件更加灵活

缺点:通信方式比较简单,只能访问子组件绑定的属性

九、总结

大概就是这七种常用的,没有涵盖vue1.x中$dispatch$broadcast(在vue2.0已被移除)

也有人把v-model作为一种通信方式,其实v-model只是一种语法糖,本质上也是$emitprops这种方式

每种方式都有各自的特定,没有最好这一种说法,正如开头说的,无论是选择哪种方式,都要根据实际业务特点。脱离了业务谈技术选型没有任何意义。