一、面试题
不用多说,参加过前端面试的小伙伴应该都遇到过,很经典的面试题。下面这些组件间的通信方式不但要会代码实现,还要知道每种方式的优缺点以及适用场景,学会灵活运用。
听别人说过一句话,原文忘记了,只记得大概是说:架构师在技术选型时不是选择最好最前沿的技术,而是选择项目最适合的方案。可能vue
,react
很好,但是有些项目可能更适合jsp
,这是业务场景决定的。因此我们需要熟悉各个技术的特点,灵活的选用最合适的。
关于每一种通信方式的优缺点总结,是自己平时开发和工作中总结出来的,并没有参考其余博客,可能不是特别全面,大家做个参考,可以结合自己的经验进行思考,也欢迎大家提出自己的意见
二、$emit + props
最常用的通信方式,适用于直接父子间的通信。vue
是单向数据流,也就是数据只能从父组件流向子组件,这也就是为什么子组件直接修改props
会警告。
父组件通过props
将值传递到子组件,子组件通过$emit('event', payload)
将数据作为payload(负载)
传递回父组件
案例: 父组件
<!-- 父组件 -->
<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>
子组件
<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>
这样就完成了父子组件通过props
将name
传递到子组件,子组件通过$emit
方法,将payload
传递到父组件
如果是兄弟节点之间通信可以通过他们共同的父组件作为媒介,一个子组件将消息先发送到父组件,再由父组件将消息发送给另一个子组件,但是并不推荐这样做。
优点:通信方式简单,适用父子组件间直接通信。
缺点:不适合跨组件通信方式(需要父组件作为媒介,代价较高)
三、事件总线
事件总线利用的是一种设计模式发布订阅者模式
,实现思路就是通过一个vue
实例作为载体,这个实例接受各方通过$emit
发出的各种事件,在需要的地方引入这个实例并通过$on
监听对应的事件,如果不需要了,在通过$off
移除这个事件,释放资源
有两种方式
3.1 修改原型链
直接修改全局vue
实例的原型链,这样可以随处访问,简单快速。
只需要在main.js
中加入这一行代码
Vue.prototype.$bus = new Vue();
案例:两个兄弟节点之间通信
父组件:
<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
组件,发布事件
<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
组件,监听事件并展示响应内容
<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文件,只在需要的地方引入并监听。
import Vue from 'vue';
const Bus = new Vue();
export default Bus;
不需要在main.js
引入,只需要在需要的地方引入。父组件不需要修改,修改ChildA
和ChildB
组件代码,在script标签最顶端引入这个js文件
import Bus from '@/config/bus.js';
同时将ChildA
和ChildB
组件中this.$bus
更换为Bus
即可
优点:相对于
props
和$emit
的方式,事件总线可以轻松跨组件通信,实现方式也比较简单缺点:需要手动移除监听事件(通过
$off()
),如果想单独移除某个事件的处理函数,不能使用匿名处理函数,必须指定函数名,具体见api,这里不再赘述
四、$attrs 和 $listeners
先说用法,$attrs
可以接收到props
没有接收到的参数,$listeners
可以接收到除了.native
修饰的所有绑定的事件
这种方式平时业务开发应用场景比较少,目前个人接触更多的是二次封装组件时,后面会举例说明。
思想有点像继承,通过$attrs
来继承父组件提供的属性,通过$listeners
来继承父组件的方法(事件)。
举例,二次封装Element-UI
的数据表格el-table
,这里只是举例介绍用法,不会详细介绍如何具体封装
定义一个组件RamblerTable
,代码如下
<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>
父组件(调用)
<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
<el-table :data="list" v-bind="$attrs" v-on='$listeners'> <!-- 这里面根据自己的需要进行表格的二次封装 --></el-table>
通过v-on
指令将外层注册的事件穿透到el-table
身上,这样封装组件可以少些很多重复代码,而且可以让使用者直接阅读element-ui
的文档就知道可以使用什么属性和事件
优点:属性穿透,可以利用这个特性二次封装组件
缺点:只适用于父子组件,如果想要穿透多层子组件需要在每一层都通过
v-bind
和v-on
进行注册
五、Vuex
通过vuex可以做到和事件总线一样轻松跨组件通信,同时还支持响应式,使用也比较简单,除了特别小的项目,基本是vue
项目的标配
使用方法:通过分发(dispatch)action
,在action
中提交(commit)mutations
,在mutation
中修改state
的值,根据业务需要可能还需要对数据进行持久化(如sessionStorage
或者localStorage
)
如登录流程中,登录成功后,分发action
this.$store.dispatch("user/login", response.data);
在action
中提交mutation
login({ commit}, data) { commit('USER_LOGIN', data.user); setToken(data.token)}
mutation
中修改state
并对数据进行持久化(登录可以考虑用sessionStorage
保存用户信息)
USER_LOGIN(state, value) { state.userInfo = value; sessionStorage.setItem('userInfo', JSON.stringify(value));}
关于vuex还有一个常考的问题:
action
和mutation
的区别是什么?答案是关于同步和异步问题,不知道的小伙伴可以查阅相关资料
优点:1. 响应式; 2. 跨组件通信方便; 3. 规范,易于统一管理
缺点:增加开发成本,需要定义
action
,mutation
等,还需要对数据进行持久化
六、provide 和 inject
父组件,通过provide选项提供一个数据,provide可以是一个对象也可以是返回对象的函数(类似data);子组件,通过inject选项注入到当前页面中,inject可以是一个字符串数组,也可以是一个对象(对象有多种形式)。
举个例子:
父组件
<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
直接通过对象数组接受
<template> <p>A:{{ name1 }}</p></template><script>export default { inject: ['name'] // 也可以这样写, 对象的key是绑定到哪个变量上,value是指定搜索哪个变量,这里父组件提供一个name,因此应该是name inject: { name1: "name" }};</script>
ChildB
通过对象接收,和props一样,可以给一个默认值
<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中提供数据,也可以做到全局注入。传值方式比较形象
缺点:非响应式,只能向下层提供数据,限制了应用场景
七、 p a r e n t 、 parent、 parent、children以及ref
三者用法类似,都是通过获取组件实例来访问属性和方法进而实现通信。
$parent
获取当前组件的直接父组件,$children
获取当前组件的子组件(返回一个数组,通过下标访问),$refs
中保存着所有通过ref
注册的组件,因此可以直接通过$refs
获取到组件实例,获取到组件实例就可以访问实例的属性和方法。
这个比较简单,举个简单例子
父节点:
<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>
首先是父节点访问子节点
// 访问child-b节点的name属性this.$children[0].name;// 通过ref调用child-b的test方法this.$refs.child.test();
子节点访问父节点
// 访问父节点this.$parent.name;
子节点访问兄弟节点
// 访问兄弟节点的name属性this.$parent.$children[1].name;
这里和dom
操作类似。
优点:通过
$parent
和$children
基本可以访问到所有的节点,也可以访问属性以及调用方法。缺点:跨越层级较大时不方便;代码可读性较差(跨越层级较多时)
八、slot分发,具名插槽
父传子用slot
插槽,子传父用具名插槽,也勉强算是一种通信,毕竟父子组件确实访问了彼此的属性
在这之前贴一句官方文档的一句话,防止有些同学看的迷糊
父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。
8.1 父传子的例子
子组件只提供一个插槽
<template> <div> <slot></slot> </div></template>
父组件
<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
布局,预留出了Header
,Footer
的插槽
Layout.vue
定义了两个具名插槽,同时绑定了两个参数header
和footer-title
<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
这个参数,因此渲染出了默认值
<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>
这样定义布局也有个好处,可以将Header
和Footer
封装成组件,实现了软件的高可插拔性
,在需要时只需要替换对应的组件即可
优点:具名插槽可以让父组件访问子组件的属性,让封装组件更加灵活
缺点:通信方式比较简单,只能访问子组件绑定的属性
九、总结
大概就是这七种常用的,没有涵盖vue1.x中$dispatch
和 $broadcast
(在vue2.0已被移除)
也有人把v-model
作为一种通信方式,其实v-model
只是一种语法糖,本质上也是$emit
和props
这种方式
每种方式都有各自的特定,没有最好这一种说法,正如开头说的,无论是选择哪种方式,都要根据实际业务特点。脱离了业务谈技术选型没有任何意义。