06 跨组件数据通信:常见的组件间数据通信方式有哪些?
你好,我是杨文坚。
经过之前几节课,我们了解到,Vue.js开发页面的最小单元就是组件,多个组件拼装组合成我们日常看到的Vue.js开发的Web页面。每个组件基于视图和逻辑代码,都可以独立实现功能的渲染和交互。
但是日常开发中,你会发现,页面上的所有组件的独立渲染功能,其实并不满足我们的需求,很多时候我们需要组件之间互相通信交换数据,甚至是互相通信进行相互的数据操作。
举个最常见的例子,在电商场景中,我们需要在订单界面重新编辑购物车商品数量,并实时计算订单总金额,具体效果案例是这样的:

你看,这个商品规格组件和订单信息一般都是不同组件,规格组件修改的数据要实时影响到订单数据显示。同时地址信息组件和订单信息组件也是不同组件,修改了地址信息,订单显示的信息也要实时修改。
类似的场景还有很多,核心就是要进行不同组件的数据联动,也就是数据通信。所以,这节课我们就由浅入深,看看如何用多种方法实现Vue.js3的数据跨组件通信。
Vue.js3项目开发过程有哪些组件通信场景?
在讲解多种组件的数据通信方式之前,我们先整体了解一下Vue.js3项目开发过程中会有哪些组件通信场景。
一般,组件的组合有基于组件嵌套的“父子组件”和基于组件并行组合的“兄弟组件”这两种情况,所以组件的数据通信一般都是处理“父子组件数据相互通信”和“兄弟组件数据相互通信”这两种场景。
在 Vue.js 中实现“父子组件”和“兄弟组件”数据单向数据通信,一般都是通过Props来进行的。“父组件”通过Props传入数据给“子组件”,而“兄弟组件”是基于“公共父组件”通过Props将数据传入子“兄弟组件”中,例如下图所示:

在实际过程中,这类通过纯Props的传输方式只能单向传递数据,不是双向传递,也就是数据只能从父组件流向子组件,或者从一个兄弟组件流向子兄弟组件。
看到这儿你可能会疑惑,根据上节课我们提到的响应式开发的知识点,难道Props不能将响应式数据传递到子组件里,然后在子组件里操作这个Props响应式数据来影响父组件吗?
如果你能提到这个问题,说明你上节课掌握得很不错,但是注意了,我们这节课讲的是数据跨组件通信,跟响应式不是一个维度上的技术点。
那么,为什么不能在子组件,或者子兄弟组件里修改Props的响应式数据来影响父组件做反向数据通信呢?我举一个实际的例子给你演示一下,你就知道是什么情况了。
以下代码是一个简单的文本编辑实时显示的组件,由两个组件嵌套组成,其中父组件包括了文本信息实时显示,子组件是文本输入框。父组件的通过 ref生成的响应式数据通过props传入子组件进行显示和修改。当子组件修改父组件props传入的响应式数据,控制台将会报错。
<!-- 子组件 -->
<template>
<div class="v-text">
<span>地址:</span>
<input :value="props.text" @input="onInput" />
</div>
</template>
<script setup>
const props = defineProps({
text: String,
});
const onInput = (e) => {
props.text = e.target.value;
}
</script>
<!-- 父组件 -->
<template>
<div>订单信息:{{text}}</div>
<div class="app">
<v-text v-bind:text="text" />
</div>
</template>
<script setup >
import { ref } from 'vue';
import VText from './text.vue'
const text = ref('环城东路888号');
</script>

当我们输入上图内容并运行时,控制台会显示这个警告:

你看,当我们要在子组件里修改父组件通过Props传递过来的ref数据时,Vue.js3就会报出警告,表示这个数据是只读的。这个其实也是由Props技术定位决定的,类似React,传入Props数据必须是只读的,不能随意变化。
那么你可能又问了,如果Props传入的不是基础数据,而是reactive数据呢?
传入reactive的响应式对象数据,是可以在子组件里修改这个对象数据里的其它属性数据,来影响父组件的,但是直接修改这个reactive数据会报出同样的警告错误。这是因为对象的属性只是一层“引用”,不是指向原始响应式数据的“顶级引用”,这也不算是Bug,只能算是JavaScript的特性。
所以,在实际项目中,我们尽量以Props传入数据为只读数据,不要随便修改Props数据,避免后续Props数据的随意更改引起数据流操作的紊乱。
那么现在问题就来了,既然通过Props传递的数据只是单向传递,那么实际组件间通信如何实现双向通信呢?也就是说,子组件可以向父组件通信,兄弟组件可以通过向父组件通信影响其它兄弟组件。这就要结合Props和Emits来实现了。
如何基于Props和Emits实现跨组件通信?
前面我们说到,Props是父组件通过设置属性来传递数据给子组件,也就是父组件可以通过Props单向给子组件传递数据。
而Emits呢,是给子组件传入自定义事件,父组件可以把操作“父级数据”方法放在这个事件中传入子组件,子组件里执行这个传入的事件间接操作“父级数据”,达到在子组件里向父组件进行数据通信的效果。
我们把刚刚报错的代码修改一下,通过事件Emits的形式来进行在子组件里修改数据,最后影响父组件的数据,达到实现组件间的数据通信的效果:
<!-- 子组件 -->
<template>
<div class="v-text">
<span>地址:</span>
<input :value="props.text" @input="onInput" />
</div>
</template>
<script setup>
const props = defineProps({
text: String,
});
const emits = defineEmits(['onChangeText'])
const onInput = (e) => {
emits('onChangeText', e.target.value)
}
</script>
<!-- 父组件 -->
<template>
<div>订单信息:{{text}}</div>
<div class="app">
<v-text
v-bind:text="text"
v-on:onChangeText="onChangeText"
/>
</div>
</template>
<script setup >
import { ref } from 'vue';
import VText from './text.vue'
const text = ref('环城东路888号');
const onChangeText = (newText) => {
text.value = newText;
}
</script>

你可以看到,这一次,子组件可以注册自定义事件给父组件,然后父组件就在这个事件中传入操作“父级数据”的回调方法,接着子组件就可以通过执行这个方法,并且可以通过这个方法传入其它数据经过事件来影响到父组件。
简单来说就是父组件把操作某些“父级数据”方法给子组件来执行,通过这个操作指定数据的方法,通过自定义事件让子组件来操作父组件数据达到向父组件的通信,具体流程如下图所示:

这样就形成了一个比较清晰的数据流向。父组件通过Props向子组件传递数据,子组件通过指定的自定义事件来操作指定的数据,不会带来数据污染。如果是兄弟组件的话,可以借助公共父组件做个自定义事件的中转,来进行相关指定的数据的通信。
如果用这个方式来实现我们一开始提出的电商订单的完整功能代码,可以参考下面的代码来实现。
父组件(最外层的组件)部分的代码如下:
<template>
<div class="app">
<v-info v-bind:text="state.text" v-bind:list="state.list" />
<v-text v-bind:text="state.text" v-on:updateText="updateText" />
<v-list
v-bind:list="state.list"
v-on:increase="increase"
v-on:decrease="decrease"
/>
</div>
</template>
<script setup >
import { reactive } from 'vue';
import VInfo from './components/info.vue'
import VText from './components/text.vue'
import VList from './components/list.vue';
const state = reactive({
text: '环城东路888号',
list: [
{ name: '苹果', price: 20, count: 0 },
{ name: '香蕉', price: 12, count: 0 },
{ name: '梨子', price: 15, count: 0 },
]
});
const updateText = (text) => {
state.text = text;
}
const increase = (index) => {
state.list[index].count += 1;
}
const decrease = (index) => {
if (state.list[index].count > 0) {
state.list[index].count -= 1;
}
}
</script>
<style>
.app {
width: 600px;
padding: 10px;
margin: 10px auto;
box-shadow: 0px 0px 9px #00000066;
text-align: center;
}
</style>
子组件(订单显示信息)部分的代码如下:
<template>
<div class="v-info">
<div>订单信息:</div>
<div>收货地址:{{props.text}}</div>
<div>总金额:{{totalPrice}}</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
text: String,
list: Array,
});
const totalPrice = ref(0);
watch(props, () => {
const list = props.list;
let total = 0;
list.forEach((item) => {
total += item.price * item.count;
});
totalPrice.value = total;
})
</script>
<style>
.v-info {
width: 400px;
margin: 20px auto;
padding: 10px;
box-shadow: 0px 0px 16px 0px #00000038;
border: 1px solid #d6d5d5;
font-size: 20px;
color: #222222;
background: #ffffff;
font-size: 18px;
text-align: left;
line-height: 22px;
}
</style>
子组件(地址文本编辑器)部分的代码如下:
<template>
<div class="v-text">
<span>地址:</span>
<input :value="props.text" v-on:input="onInput" />
</div>
</template>
<script setup >
const props = defineProps({
text: String,
});
const emits = defineEmits(['updateText']);
const onInput = (e) => {
emits('updateText', e.target.value);
}
</script>
<style>
.v-text {
width: 400px;
margin: 20px auto;
padding: 10px;
box-shadow: 0px 0px 16px 0px #00000038;
border: 1px solid #d6d5d5;
font-size: 20px;
color: #222222;
background: #2196f34d;
font-size: 16px;
}
.v-text input {
width: 200px;
height: 32px;
line-height: 32px;
margin-right: 10px;
box-sizing: border-box;
font-size: 16px;
}
</style>
子组件(商品规格选择器)部分的代码如下:
<template>
<div class="v-list">
<div class="v-list-item" v-for="(item, index) in list">
<span class="text">{{item.name}}</span>
<span class="text">单价: {{item.price}}</span>
<button class="btn" v-on:click="onClickDecrease(index)">-</button>
<span class="count"> {{item.count}}</span>
<button class="btn" v-on:click="onClickIncrease(index)">+</button>
</div>
</div>
</template>
<script setup>
const props = defineProps({
list: Array,
})
const emits = defineEmits(['increase', 'decrease'])
const onClickIncrease = (index) => {
emits('increase', index)
}
const onClickDecrease = (index) => {
emits('decrease', index)
}
</script>
<style>
.v-list {
width: 400px;
margin: 20px auto;
padding: 10px;
box-shadow: 0px 0px 16px 0px #00000038;
border: 1px solid #d6d5d5;
font-size: 20px;
color: #222222;
background: #2196f34d;
font-size: 16px;
}
.v-list .v-list-item {
border-bottom: 1px solid #7aafe29c;
line-height: 32px;
padding: 4px 0;
text-align: left;
display: flex;
justify-content: center;
align-items: center;
}
.v-list .v-list-item .text {
width: 120px;
display: inline-block;
text-align: center;
}
.v-list .v-list-item .count {
min-width: 50px;
display: inline-block;
text-align: center;
font-size: 24px;
font-weight: 800;
color: #026181;
}
.v-list .v-list-item .btn {
display: inline-block;
width: 40px;
height: 40px;
font-size: 30px;
cursor: pointer;
box-sizing: border-box;
}
</style>
不过,虽然借助Props和Emits是能清晰地看到和管理数据通信流向,但是要写一堆自定义事件和方法代码,比较麻烦。而且,Props传入响应式数据是只读的,完全发挥不了响应式数据的优势,那么,有其它的方法来充分发挥响应式的优势并实现双向数据通信吗?
答案是有的,就是直接使用公共响应式数据文件实现跨组件通信。
使用公共响应式数据文件实现跨组件通信
这种方法也就是响应式数据不通过Props传递,而是通过“组合方式”使用,这也是发挥了“响应式数据”+“组合式API” 的双重优势。我先给你画个图讲解一下:

我们把公共数据都放在一个响应式数据的文件里,无论是父组件还是子组件,都直接引用这个文件里的数据,然后直接在各自组件间进行读数据或写数据。如果有组件里的模板视图使用到这个公共响应式数据,数据被其它组件修改,也会同时触发模板视图的更新。
那么,我们再将前面写的电商订单模块代码修改成这种方式的,修改后如下。
公共响应式数据的独立文件:
import { reactive } from 'vue';
export const store = reactive({
text: '环城东路888号',
list: [
{ name: '苹果', price: 20, count: 0 },
{ name: '香蕉', price: 12, count: 0 },
{ name: '梨子', price: 15, count: 0 },
]
});
父组件(最外层的组件):
<template>
<div class="app">
<v-info />
<v-text />
<v-list/>
</div>
</template>
<script setup >
import { reactive } from 'vue';
import VInfo from './components/info.vue'
import VText from './components/text.vue'
import VList from './components/list.vue';
</script>
<style>
.app {
width: 600px;
padding: 10px;
margin: 10px auto;
box-shadow: 0px 0px 9px #00000066;
text-align: center;
}
</style>
子组件(订单显示信息):
<template>
<div class="v-info">
<div>订单信息:</div>
<div>收货地址:{{store.text}}</div>
<div>总金额:{{totalPrice}}</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import { store } from '../store';
const totalPrice = ref(0);
watch(store, () => {
let total = 0;
store.list.forEach((item) => {
total += item.price * item.count;
});
totalPrice.value = total;
})
</script>
<style>
.v-info {
width: 400px;
margin: 20px auto;
padding: 10px;
box-shadow: 0px 0px 16px 0px #00000038;
border: 1px solid #d6d5d5;
font-size: 20px;
color: #222222;
background: #ffffff;
font-size: 18px;
text-align: left;
line-height: 22px;
}
</style>
子组件(地址文本编辑器):
<template>
<div class="v-text">
<span>地址:</span>
<input :value="store.text" v-on:input="onInput" />
</div>
</template>
<script setup >
import { store } from '../store';
const onInput = (e) => {
store.text = e.target.value;
}
</script>
<style>
.v-text {
width: 400px;
margin: 20px auto;
padding: 10px;
box-shadow: 0px 0px 16px 0px #00000038;
border: 1px solid #d6d5d5;
font-size: 20px;
color: #222222;
background: #2196f34d;
font-size: 16px;
}
.v-text input {
width: 200px;
height: 32px;
line-height: 32px;
margin-right: 10px;
box-sizing: border-box;
font-size: 16px;
}
</style>
子组件(商品规格选择器):
<template>
<div class="v-list">
<div class="v-list-item" v-for="(item, index) in store.list">
<span class="text">{{item.name}}</span>
<span class="text">单价: {{item.price}}</span>
<button class="btn" v-on:click="onClickDecrease(index)">-</button>
<span class="count"> {{item.count}}</span>
<button class="btn" v-on:click="onClickIncrease(index)">+</button>
</div>
</div>
</template>
<script setup>
import { store } from '../store';
const onClickIncrease = (index) => {
store.list[index].count += 1
}
const onClickDecrease = (index) => {
if (store.list[index].count > 0) {
store.list[index].count -= 1
}
}
</script>
<style>
.v-list {
width: 400px;
margin: 20px auto;
padding: 10px;
box-shadow: 0px 0px 16px 0px #00000038;
border: 1px solid #d6d5d5;
font-size: 20px;
color: #222222;
background: #2196f34d;
font-size: 16px;
}
.v-list .v-list-item {
border-bottom: 1px solid #7aafe29c;
line-height: 32px;
padding: 4px 0;
text-align: left;
display: flex;
justify-content: center;
align-items: center;
}
.v-list .v-list-item .text {
width: 120px;
display: inline-block;
text-align: center;
}
.v-list .v-list-item .count {
min-width: 50px;
display: inline-block;
text-align: center;
font-size: 24px;
font-weight: 800;
color: #026181;
}
.v-list .v-list-item .btn {
display: inline-block;
width: 40px;
height: 40px;
font-size: 30px;
cursor: pointer;
box-sizing: border-box;
}
</style>
你看,直接用一个公共的文件管理响应式数据进行组件间数据通信,是不是很自由方便?但是自由方便都是有代价的,这个代价就是不好管理和维护。
你可以想像一下,一堆公共响应式数据,都可以在任何组件里被修改,数据流向管理很混乱,不清楚什么时候哪个组件对某个公共数据做了修改,影响了其它组件的视图内容显示。
那么,既要自由方便,又要管理方便,有更好的办法可以兼顾这两者吗?其实也有,Vue.js3官方早就想到了。Vue.js3官方提供了一个数据管理的JavaScript库,也就是Pinia,就可以做到这一点。所以,接下来我们再来看看如何基于Pinia实现跨组件数据通信。
基于Pinia实现的跨组件数据通信
首先,我来给你介绍一下什么是Pinia。简单来讲,Pinia就是一个基于Proxy实现的Vue.js公共状态数据管理的JavaScript库,可以提供组件间的数据通信,也可以跟踪定位数据的变化。
我画个图给你演示说明一下:

你可以看到,Pinia可以定义一个公共的数据store,在这个公共数据里管理多个数据的操作和计算。各个组件,无论是父子组件关系还是兄弟组件管理,都基于这个store来进行读数据展示和写数据更新状态,读写过程都是分开管理。读数据基于内置的Getter和State属性,写数据基于内部的Action方法。
数据流向都经过store统一进行管理,一旦整个应用出现什么数据读写异常或者数据被污染,都可以通过Pinia这个公共store来进行定位排查。
我们还是再写点代码来验证一下效果,把上述的代码通过Pinia来改造一下,同样也能实现多个组件的数据通信,达到实现商品订单数据的实时更新的效果。看代码。
Pinia定义的独立store文件:
import { defineStore } from 'pinia';
export const useMyStore = defineStore('my-store', {
state: () => ({
text: '环城东路888号',
list: [
{ name: '苹果', price: 20, count: 0 },
{ name: '香蕉', price: 12, count: 0 },
{ name: '梨子', price: 15, count: 0 },
]
}),
getters: {
totalPrice(state) {
let total = 0;
state.list.forEach((item) => {
total += item.price * item.count;
});
return total;
},
},
actions: {
updateText(text) {
this.text = text;
},
increase(index) {
this.list[index].count += 1;
},
decrease(index) {
if (this.list[index].count > 0) {
this.list[index].count -= 1;
}
}
}
})
父组件(最外层的组件):
<template>
<div class="app">
<v-info />
<v-text />
<v-list/>
</div>
</template>
<script setup >
import { reactive } from 'vue';
import VInfo from './components/info.vue'
import VText from './components/text.vue'
import VList from './components/list.vue';
</script>
<style>
.app {
width: 600px;
padding: 10px;
margin: 10px auto;
box-shadow: 0px 0px 9px #00000066;
text-align: center;
}
</style>
子组件(订单显示信息):
<template>
<div class="v-info">
<div>订单信息:</div>
<div>收货地址:{{myStore.text}}</div>
<div>总金额:<span class="v-info-value">{{myStore.totalPrice}}</span></div>
</div>
</template>
<script setup>
import { useMyStore } from '../store';
const myStore = useMyStore();
</script>
<style>
.v-info {
width: 400px;
margin: 20px auto;
padding: 10px;
box-shadow: 0px 0px 16px 0px #00000038;
border: 1px solid #d6d5d5;
font-size: 20px;
color: #222222;
background: #ffffff;
font-size: 18px;
text-align: left;
line-height: 1.5;
}
.v-info-value {
font-size: 24px;
font-weight: 800;
color: #fe3030;
}
</style>
子组件(地址文本编辑器):
<template>
<div class="v-text">
<span>地址:</span>
<input :value="myStore.text" v-on:input="onInput" />
</div>
</template>
<script setup >
import { useMyStore } from '../store';
const myStore = useMyStore();
const onInput = (e) => {
myStore.updateText(e.target.value);
}
</script>
<style>
.v-text {
width: 400px;
margin: 20px auto;
padding: 10px;
box-shadow: 0px 0px 16px 0px #00000038;
border: 1px solid #d6d5d5;
font-size: 20px;
color: #222222;
background: #2196f34d;
font-size: 16px;
}
.v-text input {
width: 200px;
height: 32px;
line-height: 32px;
margin-right: 10px;
box-sizing: border-box;
font-size: 16px;
}
</style>
子组件(商品规格选择器):
<template>
<div class="v-list">
<div class="v-list-item" v-for="(item, index) in myStore.list">
<span class="text">{{item.name}}</span>
<span class="text">单价: {{item.price}}</span>
<button class="btn" v-on:click="myStore.decrease(index)">-</button>
<span class="count"> {{item.count}}</span>
<button class="btn" v-on:click="myStore.increase(index)">+</button>
</div>
</div>
</template>
<script setup>
import { useMyStore } from '../store';
const myStore = useMyStore();
</script>
<style>
.v-list {
width: 400px;
margin: 20px auto;
padding: 10px;
box-shadow: 0px 0px 16px 0px #00000038;
border: 1px solid #d6d5d5;
font-size: 20px;
color: #222222;
background: #2196f34d;
font-size: 16px;
}
.v-list .v-list-item {
border-bottom: 1px solid #7aafe29c;
line-height: 32px;
padding: 4px 0;
text-align: left;
display: flex;
justify-content: center;
align-items: center;
}
.v-list .v-list-item .text {
width: 120px;
display: inline-block;
text-align: center;
}
.v-list .v-list-item .count {
min-width: 50px;
display: inline-block;
text-align: center;
font-size: 24px;
font-weight: 800;
color: #026181;
}
.v-list .v-list-item .btn {
display: inline-block;
width: 40px;
height: 40px;
font-size: 30px;
cursor: pointer;
box-sizing: border-box;
}
</style>
这里要特别注意,在使用Pinia时候,在Vue.js3应用挂载DOM节点前,要在Vue.js3应用里加上Pinia插件的使用,也就是注册Pinia插件到项目里:
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './app.vue';
const app = createApp(App);
const pinia = createPinia();
// 加载pinia插件
app.use(pinia);
app.mount('#app');
更多Pinia的使用方式,你可以查看Pinia的官网。
总结
这节课到这里就结束了,这节课我们讲了多个案例,带你体验了Vue.js3的组件间数据通信的多个方式,主要有三种,也各有优缺点:
- 基于Props+Emits的组件数据通信,让数据流向更加清晰,但是需要写很多组件间事件回调的传递代码;
- 基于公共响应式数据文件进行通信,最简单,也是最能发挥响应式特性,但是代码管理不善容易带来数据通信混乱;
- 基于Pinia的公共数据状态管理,虽然增加了学习成本,但是能管理好项目数据流向,也充分利用响应式的特性,来实现复杂大型项目的组件间通信。
在实际项目开发过程中,我们可以根据不同方式的特点灵活选择:
- 如果开发一些Vue.js组件库,可以基于Props+Emits来做组件库内部数据通信,这样可以方便管理组件库里组件的数据状态变化,减少数据污染;
- 如果要快速开发一些小型Vue.js3应用,可以直接“基于公共的响应式数据文件进行通信”,因为这种方式比较自由方便,不用写太多的数据定义和自定义事件的代码;
- 如果要开发大型Vue.js3项目,例如一些管理后台等复杂应用,建议你用Pinia来进行组件间的数据通信,方便数据的灵活使用和状态数据的流向管理。
希望你在后续进行Vue.js3跨组件的数据通信开发过程中,能够灵活且优雅地运用上述的多种方式。
思考题
我们这节课主要讲解多种跨组件的数据通信方式,每种方式虽然有其适用的场景,但是若使用不规范,都会存在响应式数据被污染的隐患,那么如何更好地保护响应式数据,在跨组件通信过程中得到规范使用呢?
欢迎和我一起讨论,期待在留言区看到你的身影。
完整的代码在这里
- 杜子 👍(1) 💬(2)
那不是也可以使用vuex来管理数据
2022-12-05 - ZR-rd 👍(1) 💬(4)
老师,使用 pinia 时为什么每次都要在组件里通过调用 useMyStore 创建 myStore ,我在 store.js 里创建再在组件里引入就会报错,这是为啥呢? export const orderStore = useMyStore();
2022-12-02 - Bruce 👍(1) 💬(0)
provide/inject也可以,老师为什么没有加进来呢?
2023-08-18 - hao-kuai 👍(0) 💬(0)
无论是store的设计也好还是pinia 的设计也好,都是假定像购物车一样全app单例似的场景,如果是文档编辑器多开的场景,如何管理状态比较好?
2025-01-08 - ifelse 👍(0) 💬(0)
学习打卡
2024-09-02 - xhsndl 👍(0) 💬(0)
把需要通信的数据封装成一个对象,再进行传递
2024-01-23