vue3 快速入门系列 – 组件通信
组件通信在开发中非常重要,通信就是你给我一点东西,我给你一点东西。
本篇将分析 vue3 中组件间的通信方式。
Tip:下文提到的绝大多数通信方式在 vue2 中都有,但是在写法上有一些差异。
准备环境
在 vue3 基础上进行。
新建三个组件:爷爷、父亲、孩子A、孩子B,在主页 Home.vue 中加载组件Gradfather.vue
:
# 爷爷
import Father from './Father.vue';
# 父亲
import ChildA from '@/views/ChildA.vue'
import ChildB from '@/views/ChildB.vue'
# 孩子A
# 孩子B
浏览器呈现:
# 爷爷
——————————————————
# 父亲
——————————————————
# 孩子A
——————————————————
# 孩子B
下文将再此基础上演示组件间的通信。
props
需求
:实现父给子一件新衣服,子给父一个吻,都用 props 实现。
请看代码:
# 父亲
来自孩子A: {{ b }}
// 传一个属性和一个方法
import ChildA from '@/views/ChildA.vue'
import {ref} from 'vue'
let a = ref('新衣服')
let b = ref('')
function getWen(val:string){
b.value = val
}
# 孩子A
来自父亲:{{ gift }}
const props = defineProps(['gift', 'sendWen'])
// 调用方法,通过参数传递数据给父组件
props.sendWen('kiss')
页面呈现:
# 父亲
来自孩子A: kiss
————————————————————
# 孩子A
来自父亲:新衣服
子给父传数据借助了方法。
通常我们可能会用自定义事件来向父组件传递数据,但是在 react 中,子组件给父组件传递数据就是用 props 传递方法的这种方式进行的。
Tip:祖父给孙子传递就不要用 props。否则按照这个思路,无论什么情况都可以用这个方法。
自定义事件
请看示例:
# 父亲
来自孩子A: {{ b }}
import ChildA from '@/views/ChildA.vue'
import {ref} from 'vue'
let a = ref('新衣服2')
let b = ref('')
function getGift(val:string){
b.value = val
}
父组件通过 @send-gift="getGift"
给孩子绑定自定义事件,子组件通过 defineEmits
声明可以触发的事件,最后通过 emit('send-gift', 'kiss2')
触发事件,并将参数传过去。
# 孩子A
来自父亲:{{ gift }}
defineProps(['gift',])
// 声明事件 - 定义一个组件可以发射(emit)的事件
const emit = defineEmits(['send-gift'])
emit('send-gift', 'kiss2')
浏览器呈现:
# 爷爷
——————————————————
# 父亲
来自孩子A: kiss2
——————————————————
# 孩子A
来自父亲:新衣服2
Tip:我们推荐你始终使用 kebab-case 的事件名 —— vue2 官网 – 事件名
mitt
在 vue2 中我们学过中央事件总线
。
Vue 3中,中央事件总线(Vue 2中的emit/on机制)已被废除。Vue 3更加推崇使用组合 API、provide/inject以及props/emits来进行组件之间的通信。这样的做法使得组件通信更加明确和可追踪,并且更容易维护和理解。而像mitt这样的第三方库可以作为替代方案,用于实现更灵活的事件管理。
mitt 可以实现任意组件
之间的通信。
pubsub(例如 pubsub-js 库)、$bus(例如 vue2 中的中央事件总线)、mitt 都是前端中常见的用于实现事件总线(Event Bus)或事件订阅-发布(Publish-Subscribe)模式的解决方案。这三者都是一个套路。也就是:
- 接收数据:提前绑定(订阅数据)
- 提供数据:适时触发(发布消息)
mitt 用法很简单,直接看 mitt 仓库。首先下载包:
PS hello_vue3> npm install --save mitt
added 1 package, and audited 72 packages in 2s
10 packages are looking for funding
run `npm fund` for details
1 moderate severity vulnerability
To address all issues, run:
npm audit fix
Run `npm audit` for details.
"mitt": "^3.0.1"
创建 emitt 并在 main.ts 将其引入项目:
// srcutilsemitter.ts
import mitt from 'mitt'
const emitter = mitt()
export default emitter
// 引入
import emitter from './utils/emitter'
需求
:现在我们让 ChildA 给 ChildB 送礼物。
请看实现:
ChildA 中触发事件:emitter.emit
# 孩子A
import emitter from '@/utils/emitter';
ChildB 中绑定事件:emitter.on
# 孩子B
兄弟送的礼物:{{ gift }}
import emitter from '@/utils/emitter';
import {ref} from 'vue'
let gift = ref('')
// 如果将 any 改成 string,vscode 报错。暂时不知解决:
/*
没有与此调用匹配的重载。
第 1 个重载(共 2 个),“(type: "*", handler: WildcardHandler<Record>): void”,出现以下错误。
第 2 个重载(共 2 个),“(type: "get-toy", handler: Handler): void”,出现以下错误。ts(2769)
*/
emitter.on('send-toy', (e: any) => {
gift.value = e;
});
在 ChildA 中点击按钮,B就能收到礼物。完成任意组件的通信。
Tip: 建议组件卸载时解绑事件。就像这样:
import {onUnmounted} from 'vue'
onUnmounted(() => {
// 移除该类型的所有事件处理程序
emitter.off('send-toy')
})
其他写法有:
// 监听
// foo { a: 'b' }
emitter.on('foo', e => console.log('foo', e) )
// 触发
emitter.emit('foo', { a: 'b' })
// 监听所有事件。比如 foo2 就会触发
// foo2 {a: 'b'}
emitter.on('*', (type, e) => console.log(type, e) )
emitter.emit('foo2', { a: 'b' })
// 清除所有事件
emitter.all.clear()
// 注册和解绑事件
function onFoo() {}
emitter.on('foo', onFoo) // listen
emitter.off('foo', onFoo) // unlisten
v-model
vue2 中 v-model 用于简化父子之间的通信
你可能不会经常直接在自定义组件中编写 v-model,但是许多 UI 组件库
的底层确实会使用 v-model 来简化父子组件之间的通信和数据流动。这种设计可以使得使用这些组件时更加方便和直观。
举例来说,当你使用一个 UI 组件库提供的输入框组件时,通常可以通过 v-model 来实现父组件与该输入框组件之间的双向绑定,让你可以直接在父组件中操作输入框的值,而不需要手动监听事件或者通过 props 和 emit 进行通信。这种方式大大简化了组件的使用方式和数据流动。
v-model 作用在 input 上可以实现双向绑定,作用在组件上,也能实现父子组件之间的通信(vue2 v-model、数字输入框组件)
v-model 实际上是语法糖,对于 input,等于绑定了 :value 和 @input。就像这样:
// vue2
等于
vue3 中 v-model 类似,v-model 对应的是 modelValue 的 prop 和 update:modelValue 的事件。比如我想封装一个 MyInput 组件。
等价
需求
:组件A使用 MyInput,通过 v-model 实现父子之间的通信。
首先不用语法糖,实现如下:
# 组件A
val: {{ val }}
// 方式1
import MyInput from '@/views/MyInput.vue'
import {ref} from 'vue'
let val = ref('p')
function changeVal($event: string){
val.value = $event
}
Tip:update:modelValue
就是事件名,只是包含一个冒号。
i am MyInput:
import { ref,toRefs } from 'vue'
const props = defineProps(['modelValue'])
const emits = defineEmits(['update:modelValue'])
console.log('props: ', props);
// 组件接手初始值
// 注:之后父组件的 props 修改后,val 不会在响应,需要自己手动修改 val
let val = ref(props.modelValue)
function handleInput($event: Event){
// 断言是一个 input 对象。否则ts报错:没有 value
val.value = ($event.target).value
emits('update:modelValue', val.value)
}
浏览器呈现:
# 组件A
val: p
i am MyInput:
// 这是 input 元素
p
编辑 input 内容时, val 对应的值也会同步,于是实现了父子之间的通信。
这三种方式在这里完全可以替换,于是我们知道 v-model 确实就是个语法糖。
// 方式1
// 方式2:模板自动对 ref 进行解包
// 方式3
重命名 modelValue
目前属性名和方法名中默认是 modelValue,就像:,希望重命名。
下面这个例子通过 v-model 同时传2个值,并修改默认值。请看示例:
# 组件A
val: {{ val }}
val2: {{ val2 }}
import MyInput from '@/views/MyInput.vue'
import {ref} from 'vue'
let val = ref('p')
let val2 = ref(18)
i am MyInput:
$event.target).value)" />
$event.target).value)" />
const props = defineProps(['name', 'age'])
const emits = defineEmits(['update:name', 'update:age'])
$attrs
祖孙数据互传
可以使用 $attrs 实现。
Tip:$attrs 详细请看:vue2 $attrs
父组件给子组件传递三个属性,子组件通过 props 接收一个,剩余2个属性就会到 $attrs:
# 爷爷
let name = ref('peng')
let age = ref(18)
let tel = ref('131xxx')
// Vite 使用了 ES 模块的动态引入特性,允许在运行时动态加载模块,而不需要在编译时就确定所有的依赖关系。
// 这种特性使得在 块中将 import 放在尾部成为可能。
import Father from './Father.vue';
import {ref} from 'vue'
# 父亲
$attrs {{ $attrs }}
import ChildA from '@/views/ChildA.vue'
defineProps(['name'])
接着用 $attrs 实现祖父给孙子传递数据。核心代码如下:
# 父亲
$attrs {{ $attrs }}
import ChildA from '@/views/ChildA.vue'
# 组件A
$attrs: {{ $attrs }}
来自祖父的name: {{ name }}
来自祖父的age: {{ age }}
defineProps(['name', 'age'])
浏览器呈现:
# 父亲
$attrs { "name": "peng", "age": 18, "tel": "131xxx" }
————————————————————————————————————————————————————————
# 组件A
$attrs: { "tel": "131xxx" }
来自祖父的name: peng
来自祖父的age: 18
孙子给祖父传数据,利用 props 的方法,这里祖父提供一个修改电话的方法,孙子调用该方法即可。核心代码如下:
# 爷爷
tel: {{ tel }}
function changeTel(v: string){
console.log('v: ', v);
tel.value = v
}
# 组件A
defineProps(['name', 'age', 'changeTel'])
在孙子中点击按钮,祖父的 tel 就会改变。
Tip:孙子给祖父传递数据也可以用自定义事件的升级版本 $listener。
$refs 和 $parent
Tip: 在Vue.js 2.x中,$refs是一个特殊的属性,用于访问组件或DOM元素的引用。当在模板中使用ref属性给元素或组件命名时,Vue.js会自动生成一个$refs对象,其中包含了对这些元素或组件的引用。
需求
:父给子一个玩具,子给父一个吻。
父组件通过 ref 给子组件一个玩具。请看代码:
# 父亲
import ChildA from '@/views/ChildA.vue'
服务器托管
import {ref} from 'vue'
const c1 = ref()
function sendGift(){
// c1.value: Proxy(Object){gift: RefImpl, __v_skip: true}
console.log('c1.value: ', c1.value);
c1.value.gift = '篮球'
}
# 组件A
父亲给的礼物:{{ gift }}
import {ref} from 'vue'
const gift = ref('')
// defineExpose 是一个用于在组合式 API 中将组件的属性或方法暴露给父组件的函数
defineExpose({gift})
当在模板中使用ref属性给元素或组件命名时,Vue.js会自动生成一个$refs对象。可以通过 $refs 给孩子礼物。请看示例:
# 父亲
// 注:空对象
$refs: {{ $refs }}
import ChildA from '@/views/ChildA.vue'
import {ref} from 'vue'
const c1 = ref()
function sendGift(){
...
}
// {[key: string]: any} 表示对象的键是字符串类型,而值可以是任意类型
function test(v: {[key: string]: any}){
// v就是传来的 $refs
v.c1.gift = '足球'
console.log('v: ', v);
}
Tip:模板通点击可以将 $refs 传入js中,模板中直接通过 $refs 为空(或许是 $refs 是后生成的,并且没有响应式)。
疑惑
:如何在vue3的组合式api的js里直接取得 $refs?
孩子给父亲礼物,可以使用 $parent,最终代码如下:
# 父亲
孩子给的礼物:{{ gift }}
import ChildA from '@/views/ChildA.vue'
import {ref} from 'vue'
const c1 = ref()
function sendGift(){
console.log('c1.value: ', c1.value);
c1.value.gift = '篮球'
}
let gift = ref('')
// 父亲只让别人访问gift,其他不允许
defineExpose({gift})
# 组件A
父亲给的礼物:{{ gift }}
import {ref} from 'vue'
const gift = ref('')
function sendGift(parent: any){
console.log('p: ', parent);
parent.gift = 'kiss'
}
defineExpose({gift})
Tip:ref($refs)、$parent 直接操作父组件或子组件的数据,不太好。但某些情况下或许有用。
provide 和 inject
上面我们使用 $arrts
实现了祖孙数据互传。有个缺点就是会打扰到中间人:父亲 —— 在父组件中需要写 v-bind=$attrs
。
而 provide/inject 不打扰中间人,实现祖孙数据互传。请看示例:
祖父通过 provide 提供属性或方法给后代:
# 爷爷
import Father from './Father.vue';
import {ref,} from 'vue'
function changeAddress(v: string){
address.value = v
}
import { provide} from 'vue'
let address = ref('长沙')
provide('address', address)
provide('changeAddress', changeAddress)
父组件通过 inject 能收到祖父提供出来的数据:
# 父亲
address {{ address }}
import ChildA from '@/views/ChildA.vue'
import { inject } from 'vue';
let address = inject('address')
孙子通过 inject 接收祖父提供的属性和方法:
# 组件A
address: {{ address }}
import { inject } from 'vue';
let address = inject('address')
// inject 第二个参数用于设置默认值,用于解决 ts 报错。
let changeAddress = inject('changeAddress', (v:string) => {})
浏览器呈现:
# 爷爷
——————————————————————
# 父亲
address 长沙
——————————————————————
# 组件A
address: 长沙
// 按钮
change 祖父 tel
长沙
来自祖父。点击按钮,长沙变成北京
,实现孙子到祖父的通信。
升级一下上述示例:祖父提供对象,并将地址和修改地址的方法合并一起传出。请看示例:
# 爷爷
import Father from './Father.vue';
import {reactive, ref,} from 'vue'
function changeAddress(v: string){
address.value = v
}
import { provide} from 'vue'
let address = ref('长沙')
let phone = reactive({
price: 1800,
color: 'red'
})
// 注:不要 address.value,否则就不是响应式,孩子的address不会变。
provide('addressContext', {address, changeAddress})
// 传对象
provide('phone', phone)
# 父亲
phone.color: {{ phone.color }}
import Ch服务器托管ildA from '@/views/ChildA.vue'
import { inject } from 'vue';
// 隐晦的告诉模板中的 phone.color 是字符串类型
let phone = inject('phone', {color: '', price: 0})
# 组件A
address: {{ address }}
import { inject } from 'vue';
let {address, changeAddress} = inject('addressContext', {address: '', changeAddress: (v:string) => {}})
// address: RefImpl{__v_isShallow: false, dep: undefined, __v_isRef: true, _rawValue: '长沙', _value: '长沙'}
// address 已经是响应式的。无需使用 toRefs
console.log('address: ', address);
插槽
vue2 中就存在这个概念,详细请看官网:vue3 插槽
具名插槽和默认插槽用于父传子
,作用域插槽用于子传父
。
默认插槽
子组件通过 slot 定义插槽。
比较简单,直接看例子:
# 父亲
click me
import ChildA from '@/views/ChildA.vue'
# 组件A
默认值
如果没传,则显示“默认值”
Tip:父组件使用子组件,比如子组件有标题和内容,标题通过父组件 props 传递,内容可以是图片、视频,列表等等,就可以在父组件中使用插槽。
具名插槽
默认插槽其实就是具名插槽的一种。因为默认插槽也有名字(即default
)。
# 父亲
- c
- d
- a
- b
默认插槽的名字叫 default
import ChildA from '@/views/ChildA.vue'
通过 name 属性给插槽定义名字,父组件通过 v-slot 应用对应的插槽。
现在用 v-slot,只能用于组件或 标签。用于组件的缺点是:标签内的全部内容会放在具名插槽上,如果存在多个具名插槽就不行了。
v-slot:list1
简写成 #list1
。
Tip:slot-scope 在2.6废除了,而在 2.6.x 中,scope、slot和slot-scope 都推荐使用 v-scope。
# 组件A
浏览器呈现大概这样:
# 组件A
- a
- b
- c
- d
默认插槽的名字叫 default
作用域插槽
作用域插槽(scoped slots)的主要作用是允许父组件在插槽内容中访问子组件中的数据或方法。
数据在子组件,但数据生成的结构,由父组件决定
作用域插槽,UI组件库用的很多,比如 table、对话框。写过 table的通常会用插槽。表格某列的结构由我们决定。数据我们会传给ui组件。
Tip:为什么叫作用域插槽?可以这么理解:父组件中需要访问孩子的数据,但是有作用域的限制,于是用这个作用域插槽解决。
作用域插槽有点子传父
的感觉。因为在父组件中用到了子组件的数据
请看这个简单的示例:
# 父亲
父组件定义结构,数据来自孩子:{{ myProps }}
import ChildA from '@/views/ChildA.vue'
# 组件A
import {ref} from 'vue'
let age = ref(18)
浏览器呈现:
# 组件A
父组件定义结构,数据来自孩子
{ "age": 18 }
Tip:有人觉得作用域插槽难,其实是因为写法多。比如:
可以直接解构:
父组件定义结构,数据来自孩子:{{ age }}
在加上具名插槽:
父组件定义结构,数据来自孩子:{{ age }}
总结
-
父传子
:props、v-model、$refs、插槽 -
子传父
:props、自定义事件、v-model、$parent、作用域插槽 -
祖孙互传
:$attrs、provide/inject -
兄弟和任意组件
:mitt、pinia(Pinia 是一个基于 Vue 3 的状态管理库,可代替vuex,配合 vue3 使用)
扩展
v-bind
v-bind 最基本用途是动态更新html元素上的属性,比如 id
div v-bind:id="dynamicId">
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net
机房租用,北京机房租用,IDC机房托管, http://www.fwqtg.net
过滤器和拦截器的辨析 介绍 过滤器和拦截器都是为了在请求到达目标处理器(Servlet或Controller)之前或者之后插入自定义的处理逻辑 过滤器: 遵循AOP(面向切面编程)思想实现,基于Servlet规范提供的Filter接口,它是位于客户端请求与服务…