写在前面
在前端开发中,越来越多开发者选择从多页应用(MPA)向单页应用(SPA)转变,这是前端技术发展的一个标志。
在单页应用开发中,开发者多选择Vue和React等框架,这些框架有着高效的DOM操作能力和组件化设计,使得代码结构清晰,可维护性好。特别是在页面的更新上,只需要相关组件局部更新,这极大提高了用户的体验,开发者也能有更多精力去关注核心业务逻辑。
在Vue.js中,除了响应式数据的绑定,组件间的数据通信也是一个关键任务,尤其是父子组件的数据通信。本次,我就借助Vue.js框架来分享关于组件数据通信的手段。
正文
Vue.js框架中,推荐开发者遵循单向数据流的方式,也就是数据从父组件流向子组件,事件(携带数据)从子组件向上传递给父组件。虽然这不是强制的,但在某些页面错误情况下,这能让我们更容易对错误进行溯源。
那么接下来我们就遵循单向数据流这一原则,分别从以下几个点来说明:
- 父子通信 | Props Down
- 子父通信 | Event UP
- 双向绑定通信 | v-model
- ref实现组件通信 | ref
- 跨组件通信 | provide/inject
一、父子通信
按照数据单向流的思想,父子通信是由掌握数据的父组件向子组件传递数据。
场景:一个简单的列表项添加功能。
功能十分简单,但是一套下来内容还是很足。
方式一(defineProps)
思路:定义一个父组件,一个子组件。在父组件中引入子组件,在父组件中修改列表数组,并将列表项数据一次性通过props的方式传递给子组件。
先看父组件:
<template>
//输入
<div class="header">
<input type="text" v-model="newMsg">
<button @click="add">确定</button>
</div>
//传递整个列表数组
<Child :list="list"></Child>
</template>
<script setup>
import { ref } from 'vue';
import Child from './child.vue'
//输入框内容
const newMsg = ref('')
//列表内容
const list = ref(['html', 'css'])
const add = () => {
list.value.push(newMsg.value)
}
</script>
子组件:
<template>
<div class="body">
<ul>
<li v-for="item in list">{{item}}</li>
</ul>
</div>
</template>
<script setup>
const props = defineProps({
list : {
type : Array,
default : () => []
}
})
</script>
这样就相当于把列表项抽离出来,封装成一个组件,子组件只负责展示内容。
添加成功!
方式一变种
思路:将每次新增的列表项传递给子组件,由子组件自行添加进列表数组。
这时候,父组件长这样:
<template>
<div class="header">
<input type="text" v-model="newMsg">
<button @click="add">确定</button>
</div>
//传递单个列表项内容
<Child :newMsg="val"></Child>
</template>
<script setup>
import { ref } from 'vue';
import Child from './child.vue'
const newMsg = ref('')
//代替newMsg传递给子组件
const val = ref('')
const add = () => {
val.value = newMsg.value
}
</script>
这里还定义了一个响应式对象val,这是确保在点击确定之后再触发显示出完整的新增列表项,如果直接传递newMsg过去,会像下面这样。
继续看子组件,依旧使用了defineProps:
<template>
<div class="body">
<ul>
<li v-for="item in list">{{item}}</li>
</ul>
</div>
</template>
<script setup>
import { ref ,onBeforeUpdate } from 'vue';
const list = ref(['html', 'css'])
const props = defineProps({
newMsg : {
type : String,
default : ''
}
})
//更新子组件DOM结构之前添加列表项
onBeforeUpdate(() => {
list.value.push(props.newMsg)
})
</script>
这里简单的使用了一个生命周期函数,保证在页面显示时有正确的数据。
事实证明,不使用生命周期函数将数据添加控制在渲染之前,就会导致出现空列表项的情况,因为数据传递的过程是异步的,需要花费时间。
如果将list.value.push(props.newMsg)直接放在script标签中,那么在一开始便会执行成list.value.push('')。况且使用onBeforeUpdate()还需要我们对执行时机进行判断,导致不必要的性能开销。
从效果上来看,方式二的结果没毛病!
对比
从技术手段来看,两种方式差不多(defineProps),唯一的区别就是列表项数据添加的时机,交由谁去添加?
方式一:列表数组由父组件维护,子组件直接拿去用。
方式二:列表数组由子组件维护,父组件传递新增列表项,由子组件添加到列表数组。
这是我刚开始学习vue时曾纠结的一点,但是仔细想想,方式一还是更为优雅。
- 数据从父组件流向子组件,符合Vue的原则。
- 父组件负责数据管理,子组件专注于数据展示,职责分离,减少耦合。
反观方式二,父组件和子组件将列表数组和单个列表项数据分而治之,有数据流混乱的影响。
所以在实际开发中,当我们需要使用生命周期函数时,应该想想是否自己对数据的处理不够合理?
二、子父通信
涉及到父子组件通信时,常常是父组件主动把数据传递给子组件。但在某些情况,需要通过子组件的需求来决定是否传递数据。也就是子组件向父组件传递信息。
在更多的场景下,往往是子组件中想要去修改父组件传递的数据,但是数据所有权归父组件,子组件不能(不建议)自行修改,只能通知父组件修改,也就是子组件请求更新数据。
❓这时,面对着两种需求,如果我们是编写框架的作者,我们应该怎么做呢?
💡我们需要做的是:将需要传递的信息或者修改的数据向上传递给父组件。
💡再联想到事件冒泡机制,我们还需要定义一个事件去携带这个数据,以此来通知父组件更新数据。
没错,Vue大致就是这样的设计理念,只不过在这里我们可以用一个去术语概括这个行为————发布-订阅模式。
发布-订阅模式(Publish/Subscribe Pattern)
发布-订阅模式是一种设计模式,主要用于实现组件的消息通信。在这个模式中,发布消息的组件被称为发布者(Publisher),接收消息的组件被称为订阅者(Subscriber)。这个设计模式中的重点是需要我们搞懂发布和订阅的关系。
❓问一个问题,在实际业务逻辑中,大家觉得要先有发布还是先有订阅呢?
答案是:先要有订阅,这是相对于业务逻辑而言。因为通常在编写代码时,我们都是先编写子组件中的发布事件代码,再去父组件订阅事件。而我们潜意识的那个“发布”,其实是注册一个事件。
对于实际业务而言。我们只有订阅了,发布的事件才能接收到。例如,掘金中我们如果想要准时收到某个大佬写的文章,就得先关注,这样文章才会及时推送。
在这个发布-订阅模式,订阅者也并不是直接收到发布者发布的消息,而是在一个事件总线(消息总线)中获取,事件总线在实际业务流程中可能是一个具体的类、接口等等。这就涉及到发布-订阅模式的实例化了,我们在这里不过多深究。
回到前面提及的子父通信,我们接下来就去实现:
实现
父组件订阅:
<template>
//订阅事件
<Child @addMsg="handle"></Child>
<div class="body">
<ul>
<li v-for="item in list">{{ item }}</li>
</ul>
</div>
</template>
<script setup>
import { ref } from "vue";
import Child from "./child.vue";
const list = ref(["html", "css"]);
//形参默认
const handle = (e) => {
list.value.push(e);
};
</script>
上述我们订阅了addMsg事件,当子组件发布事件后,父组件就能够拿到该消息。
子组件发布:
<template>
<div class="header">
<input type="text" v-model="newMsg" />
<button @click="add">确定</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
//注册了一个事件
const emits = defineEmits(['addMsg'])
const newMsg = ref('')
const add = () => {
//发布
emits('addMsg',newMsg.value)
}
</script>
子组件中想要添加一个数据到父组件数据列表,则先注册一个事件后发布,携带一个参数返回父组件。其中,通过defineEmits()传入一个数组,数组中写入事件名,发布时,只需要调用该对象emits()传入事件还有参数即可。这样父组件中默认接收一个形参。
//订阅事件 handle中实际上有对应形参
<Child @addMsg="handle"></Child>
结果一样优雅。
三、双向绑定通信
在Vue中,v-model是一个常用的语法糖,它可以实现表单控件和组价状态之间的双向数据绑定。根据组件的prop和emit动态地传递数据。
许多人了解v-model是因为其可以进行数据的双向绑定,例如动态绑定一个输入框的数据。
<template>
<div class="header">
<input type="text" v-model="newMsg" />
<button @click="add">确定</button>
</div>
</template>
其实,在自定义组件中使用v-model时,它还能对子组件的prop数据进行绑定,对特定的事件进行监听。
这里我们就使用v-model来实现,还是以前面的增添列表项为例:
父组件中:
<template>
//常规方式
<!-- <Child :list="list" @update:list="handle"></Child> -->
//使用v-model语法糖
<Child v-model:list="list" ></Child>
<div class="body">
<ul>
<li v-for="item in list">{{item}}</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue';
import Child from '../demo4/child.vue';
//父组件维护的列表数组
const list = ref(['html', 'css'])
</script>
可以看到,我们在父组件维护一个列表数组。同时,我们希望能够通过子组件内部状态改变时一,触发一个自定义事件,父组件去订阅该事件,将处理好的列表数组传递回来。
这里简单的:
<Child v-model:list="list" ></Child>
替代了:
<Child :list="list" @update:list="handle"></Child>这种v-bind和v-on的组合。
先撇开子组件的内部实现,我们需要先搞懂父组件中v-model做了什么?
- 将list绑定到子组件的prop上,也就是把list传递给子组件。
- 监听子组件的特定事件,以便在子组件内部状态改变后,更新父组件的list。
我们再来看看子组件:
<template>
<div class="header">
<input type="text" v-model="newMsg" />
<button @click="add">确定</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
list:{
type : Array,
default : () => []
}
})
const newMsg = ref('');
//发布器update:
const emits = defineEmits(['update:list'])
//将数据添加到新的数组
const add = () => {
const arr = [...props.list];
arr.push(newMsg.value);
emits('update:list',arr);
}
</script>
上述子组件的props和emits都会被父组件的v-model监听。
效果正确。
props这一块内容和之前一致,但是对于子组件发布的事件类型,这里我选择了update:xxx事件。
为什么不使用普通的自定义事件呢?
因为v-model被尤大大打造成专注于处理update:xxx格式事件,普通的自定义事件还需要我们显式监听,所以在这里使用update:xxx格式事件还是比较香的。
回到子组件的内部实现逻辑,我们还会看到:
const add = () => {
const arr = [...props.list];
arr.push(newMsg.value);
emits('update:list',arr);
}
这样一个函数,创建了一个新数组承载数据,并把新数组返回回去,这是v-model的典型用法。
不过看到这里有的朋友可能会疑惑,列表项的添加逻辑不应该在父组件上更好吗?毕竟list列表是由父组件维护的,这样在子组件修改数据算怎么回事?
这是就是一个细节问题了,我们会提出这个问题,是因为我们考虑到了Vue的以下原则:
- 单向数据流原则:数据从父组件流向子组件,子组件应通过事件来通知父组件状态的改变,直接修改父组件维护的数据会让数据流变得混乱。
- 组件的独立性:子组件应该尽可能独立,避免直接依赖或者修改外部状态。
上述都是对于普通场景,在v-model时的情况稍有不同。
v-model的设计初衷是解决表单元素的值与组件状态之间的同步问题,它遵循子组件修改由v-model绑定的数据 prop,然后通过触发update:xxx事件来通知父组件更新数据。
我写的例子中:
const add = () => {
const arr = [...props.list];
arr.push(newMsg.value);
emits('update:list',arr);
}
注意!!!这里虽然创建了一个新数组arr,并且修改了数据的状态。但是,我并没有直接修改父组件传递来的list prop,而是通过触发事件来间接通知父组件修改数据,这与直接修改是不同的。
这通常是一个容易出错的地方,不应该直接修改prop。
在v-model中,创建新数组并通过触发update:xxx事件来更新数据是符合Vue.js的设计原则的,因为这符合事件驱动的数据更新机制。
总之,对于子组件修改数据状态一事,需要细细思量。
四、ref实现组件通信
我相信许多朋友和ref的初见都是美好的,在它还是一个函数的时候,我们用ref()创建数据的响应式引用。
在选项式API的场景中,ref却是模板中的一个属性,用于在组件的$ref实例对象中创建一个引用,指向DOM元素或子组件。
前者用于管理组件内部响应式数据的状态,后者用于组件实例间的交互。我们常常使用ref属性获取子组件的DOM元素,这为组件通信提供了一个可能。
依据之前的开发场景,先看父组件:
<template>
<Child ref="childRef"></Child>
<div class="body">
<ul>
<li v-for="item in childRef?.list">{{item}}</li>
</ul>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import Child from './child.vue'
//使用ref获取子组件的dom结构
const childRef = ref(null);
</script>
这里<Child ref="childRef"></Child>,我们拿到了子组件的实例对象。
再看子组件:
<template>
<div class="header">
<input type="text" v-model="newMsg">
<button @click="add">确定</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
const newMsg = ref('')
const list = ref(['html', 'css'])
const add = () => {
list.value.push(newMsg.value)
}
//子组件暴露出一个list,表示同意父组件获取子组件的值
defineExpose({list})
</script>
结果:
为了让父组件能够获取到子组件实例对象的数据list,子组件必须通过defineExpose()暴露数据。
在开发中,我们也需要谨慎使用ref。子组件暴露内置属性,实际上破坏了一些封装度,容易造成紧耦合。在大型项目中,应优先考虑事件驱动的通信方式或者使用状态管理库来处理复杂的场景。
主要原因:
-
可预测性:通过事件驱动机制,所有组件的数据变化都是通过触发事件来实现的,这种方式让状态的变化路径更加清晰。在大型项目中,清晰地掌握组件的状态变化是十分有必要的。
-
单一数据源:状态管理库集中维护了各组件的状态,让开发者更容易把控状态的访问和修改。
五、跨组件通信
接下来我们来简单了解更为强大的组件通信方式,通过依赖注入的方式来进行组件通信甚至是跨组件通信。
提及跨组件通信,我们都会考虑一个问题,为什么要进行跨组件通信呢?
没错,因为普通的组件通信步子跨的太小了,造成一个prop需要逐级传递,就像这样:
如果组件树很深,那么子组件想要拿到数据得猴年马月。如果能直接点就好了。
一次到位,说的就是依赖注入方式,涉及到provide()和inject()。
父组件作为所有子组件(无论层级多深)的依赖提供者,所有子组件都可以注入由父组件提供的依赖。
尽管我们本期的例子是简单的父子通信,但是我们还是小试牛刀🔪一次。
在使用依赖注入方式进行组件通信时,需要确定依赖于哪个组件,确定注入到哪个组件。简单来说,谁需要数据,谁就要注入数据。
例子中,子组件需要去显示一个列表,所以子组件需要数据,那么子组件需要去注入数据。
<template>
<div class="body">
<ul>//渲染数据
<li v-for="item in list">{{item}}</li>
</ul>
</div>
</template>
<script setup>
import { inject } from 'vue';
//注入数据
const list = inject('list')
</script>
一个inject()搞定!
再看看父组件:
<template>
<div class="header">
<input type="text" v-model="newMsg" />
<button @click="add">确定</button>
</div>
<Child></Child>
</template>
<script setup>
import Child from "./child.vue";
import { ref, provide } from "vue";
const newMsg = ref("");
const list = ref(["html", "css"]);
const add = () => {
list.value.push(newMsg.value);
};
//提供依赖
provide("list", list.value);
</script>
父组件使用一个provide()就搞定了~
依赖注入可以作为组件间的通信策略,但并不意味着它适合直接在兄弟组件或者子父通信中使用。Vue中引入依赖注入方式的初衷是解决父子组件的prop逐级透传问题。
对于兄弟组件通信,可以考虑以下可行的方式:
- 创建事件总线,通过组件间对事件的发布订阅驱动数据流动,实现松耦合的通信方式。
- 状态管理库提供了公共的数据仓库,各组件均有机会访问和修改共享的数据。
- 考虑组件树的结构,通过父组件进行传递数据,但是通常这不是首选的方式。
总结
本期我分享了这些内容:
- 父子通信
- defineProps
- 子父通信
- defineEmits(发布-订阅模式)
- 双向绑定通信
- v-model:propName
- ref实现组件通信
- ref获取子组件实例对象
- 跨组件通信
- 依赖注入
组件通信是一个庞大的话题,其业务场景和实现方式有很多,具体使用哪种方式,还要围绕实际场景进行讨论。由于我的知识有限,有其他方式的话还请各位补充。以上内容都是我在学习Vue3时的记录,如果你觉得对你有帮助的话,还请点个赞,感谢!