主题
vue3通过mitt完成事件总线与v-model实现父子数据(Lesson26 2/3)
本页对应 Lesson26,共 6 段视频,约 1 小时 24 分。
学习目标
- 能安装并配置 mitt,创建事件总线模块,完成跨组件的发布、订阅与取消订阅,并说明路由切换时为何需要调用
off清理订阅。 - 能解释 Vue2 中
v-model等价于value属性与input事件的组合,并手写实现父子组件数据同步。 - 能解释 Vue3 中
v-model等价于modelValue属性与update:modelValue事件的组合,并独立实现父子组件双向同步。 - 能使用 Vue3 多个
v-model及具名v-model,正确处理对应的update:xxx事件并完成多组数据同步。 - 能说明 Vue3 废弃
.sync的原因,能在 Vue2 中使用.sync实现属性与update:xxx事件同步,并比较其与 Vue3v-model的对应关系。 - 能区分 Vue2 的
$attrs、$listeners与 Vue3 的useAttrs,使用它们透传属性、获取自定义事件或属性,并说明 Vue3 中监听器合并到attrs后的使用差异。
知识点
一、为什么 Vue3 还要做事件总线
为什么需要:Vue2 里常把事件总线挂到 Vue.prototype 上,组件里通过 this.$bus 使用;Vue3 没有 Vue.prototype 这一套,所以不能照搬。跨组件通信时,如果只是父子传参,用 props 就够了;但兄弟组件、跨层组件之间没有直接关系,逐个传 props 会很绕,这时就需要一个“公共事件中心”。
怎么想:事件总线本质是“发布—订阅”。谁要通知别人,就 emit 一个事件;谁关心这个事件,就提前 on 订阅。Vue3 官方推荐用第三方库 mitt 来做这件事,转写里也明确说它是第三方库,不是 Vue 内置。
怎么做:先安装 mitt,再创建一个公共 bus,最后在组件里发布和订阅。
易错点:不要以为 Vue3 还能直接写 this.$bus;也不要自己随便造一个全局对象却忘了统一导出,否则每个组件拿到的不是同一个实例。
二、安装 mitt 并创建 bus
为什么需要:mitt 是一个独立包,必须先安装;bus 必须只创建一次并统一导出,才能让所有组件操作同一个事件中心。
怎么想:把 bus 放到 src/utils/index.ts,因为转写里说 utils 一般放工具方法;以后还可以在这个文件里继续导出其他工具,不要用 export default 把文件限制成只能导出一个 bus。
怎么做:下载:
shell
cnpm i mitt新建 src/utils/index.ts:
ts
import mitt from "mitt";
export const bus = mitt();易错点:mitt() 返回的是对象,常用方法是 on、emit、off;转写里提到它没有现成的 once,如果要“只执行一次”,可以 on 后在回调里手动 off。另外事件名要统一,比如本节统一用 changenum。
三、在 Two 中发布事件
为什么需要:Two 是触发方,点击后要通知 App 和 One 更新或打印,所以它负责 emit。
怎么想:发布事件时传两个关键信息:事件名和参数。转写里反复调试到“只接收一个参数”,多个参数不行;如果要传多个值,应该包成一个对象。
怎么做:
vue
<template>
<h3 @click="changeNum">Two1:{{num}}</h3>
<h3 @click="bus.emit('changenum',300)">Two2:{{num}}</h3>
</template>
<script setup lang="ts">
import {bus} from "@/utils";
defineProps(["num"]);
const changeNum = function(){
bus.emit("changenum",200);
}
</script>
<style scoped>
</style>易错点:bus.emit("changenum",200) 的事件名必须和订阅方一致;模板里能直接写 bus.emit,是因为 script setup 中已经导入了 bus。defineProps(["num"]) 不需要再引入。
四、在 App 和 One 中订阅事件
为什么需要:App 持有 num,收到通知后要改数据;One 只是观察者,收到通知后打印日志。订阅必须发生在事件触发之前,所以放在 onMounted 里。
怎么想:App 的 num 是 ref(100),脚本里改值要写 num.value;回调参数 v 在 TypeScript 下可能是未知类型,转写里用 v as number 断言。One 只订阅打印,暂时不取消。
怎么做:
vue
<template>
<h3>One:{{num}}</h3>
</template>
<script setup lang="ts">
import {onMounted} from "vue";
import {bus} from "@/utils";
defineProps(["num"]);
onMounted(function(){
bus.on("changenum",()=>{
console.log("ONE->changenum");
// bus.off("changenum");
})
})
</script>
<style scoped>
</style>vue
<template>
<h3>App组件(父组件){{num}}</h3>
<hr/>
<One :num="num"/>
<hr/>
<Two :num="num"/>
</template>
<script lang="ts" setup>
import {onMounted, ref} from "vue";
import {bus} from "@/utils";
import One from "@/components/One.vue"
import Two from "@/components/Two.vue"
const num = ref(100);
onMounted(function(){
bus.on("changenum",(v)=>{
console.log("APP->changenum",v);
num.value = v as number;
})
})
</script>
<style scoped>
</style>易错点:模板里 {{num}} 会自动解包,脚本里必须 num.value;v as number 是类型断言,不写可能在 TS 中报“未知类型”。onMounted 里注册订阅,组件每次挂载都会注册一次,路由切换或反复挂载时要记得 off。
五、联调流程与取消订阅
怎么想:先让 App 把 num 传给 One、Two,页面初始都显示 100;点击 Two1 发 200,点击 Two2 发 300;App 收到后改 num.value,One、Two 的 props 随之更新;One 收到后只打印。
怎么做:转写中通过控制台确认 APP->changenum 和 ONE->changenum 都执行,再确认页面数字变化。若只想让 One 执行一次,可在回调里调用 bus.off("changenum");路由组件切换时,订阅不会自动消失,应在离开前 off,否则会一直存在。
易错点:bus.off("changenum") 要写对事件名;不要以为刷新页面会保留订阅,刷新会重新挂载;但路由切换不等于刷新,旧订阅可能残留。bus 本身是公共事件中心,不需要像组件状态那样清除,关键是清掉不再使用的订阅回调。
从原生 input 理解 v-model 的语法糖
为什么需要:输入框的值和 ref 数据要双向同步,如果每次都手写 :value 和 @input,样板代码多且容易漏。怎么想:v-model 本质是“值绑定 + 输入事件更新”的组合。怎么做:在 App.vue 中写 <input v-model="userName">,旁边用 {{userName}} 观察变化。易错点:v-model 绑定的必须是可写数据,不要直接绑 props;原生输入框的事件是 input,不是组件上的自定义 input 事件。
vue
<template>
<h3>v-model数据的双向绑定</h3>
<input v-model="userName" type="text"><span>{{userName}}</span>
<hr/>
<h3>vue3中实现父子组件数据同步</h3>
<Child :value="userName" @input="setUserName"/>
<hr/>
<h3>v-model实现父子组件数据同步</h3>
<!-- 1- 向子组件传递了两个属性:modelValue onUpdate:modelValue -->
<!-- 2- 向子组件传递了自定义事件:update:model-value -->
<Child2 v-model="userName"/>
</template>
<script lang="ts" setup>
import {ref} from "vue";
import Child from "@/components/Child.vue"
import Child2 from "@/components/Child2.vue"
let userName = ref("zhangsan");
const setUserName = function(info:string){
console.log("setUserName",info);
userName.value = info;
}
</script>
<style scoped>
</style>手动版::value + @input 完成父子同步
为什么需要:先不用 v-model,把“父传值、子触发、父更新”的链路走通,才能理解后面的语法糖。怎么想:父组件把 userName 通过 :value 传给子组件;子组件不能直接改 props,只能触发事件把新值交回父组件。怎么做:父组件监听 @input="setUserName",子组件用 defineEmits(["input"]) 声明事件,点击时触发并传 lisi。易错点:子组件里 defineProps(["value"]) 只负责接收;事件必须 defineEmits 声明;父组件的 setUserName 要接收参数并赋给 userName.value。
vue
<template>
<h3 @click="$my('input','lisi')">我是一个子组件-Child:{{value}}</h3>
</template>
<script lang="ts" setup>
defineProps(["value"]);
const $my = defineEmits(["input"]);
</script>
<style scoped>
</style>调试时可在 setUserName 中打印 info,点击子组件标题,确认收到 lisi,并且父组件 userName 被更新,子组件显示也随之变化。
组件上的 v-model:modelValue 与 onUpdate:modelValue
为什么需要:手动写 :value + @input 仍重复。Vue3 组件上的 v-model="userName" 会编译成向子组件传 modelValue 属性,并监听 onUpdate:modelValue;同时对应自定义事件 update:model-value。怎么想:子组件要么调用 props["onUpdate:modelValue"]("lisi"),要么 emit("update:model-value","wangwu"),两条路都能把新值交回父组件。怎么做:子组件声明 defineProps(["modelValue","onUpdate:modelValue"]) 和 defineEmits(["update:model-value"])。易错点:Vue3 默认名是 modelValue,不是 Vue2 的 value;事件名不要写成 update:modelValue 和 update:model-value 混用;onUpdate:modelValue 是函数型 prop,调用时注意 TS 类型,必要时按实际类型声明或断言。
vue
<template>
<h3>我是子组件Child2:{{modelValue}}</h3>
<button @click="test">onUpdate:modelValue</button>
<button @click="test2">update:model-value</button>
</template>
<script lang="ts" setup>
const props = defineProps(["modelValue","onUpdate:modelValue"]);
const emit = defineEmits(["update:model-value"]);
const test = function(){
props["onUpdate:modelValue"]("lisi");
}
const test2 = function(){
emit("update:model-value","wangwu")
}
</script>
<style scoped>
</style>调试时打印 props,能看到 modelValue 和 onUpdate:modelValue;点击两个按钮,父组件 userName 分别变为 lisi、wangwu。注意:声明到 props 后,$attrs 里对应项会被消费掉,这是正常现象。
多个 v-model 与 Vue2 对比
为什么需要:一个子组件可能要同步多个字段。怎么想:v-model 可带参数,v-model:age="age" 会传 age 属性和 onUpdate:age,子组件触发 update:age。怎么做:子组件接收 age,emit("update:age", 新值)。易错点:Vue3 已废弃 .sync,不要照搬 Vue2 的 :userName.sync;默认 v-model 与 v-model:age 可共存;事件名和 prop 名要成对匹配。
为什么需要多个 v-model
父组件里经常有多个状态要交给子组件展示和修改,比如 userName 和 age。如果只有一个默认 v-model,它默认只对应 modelValue 这一份数据,多个状态就不好同时同步。Vue3 允许给 v-model 加参数:v-model:userName、v-model:age,本质是“多个 prop + 多个 update 事件”的语法糖。
怎么想:先把 v-model 拆开看。组件上的 v-model="userName" 等价于 :modelValue="userName" 加 @update:modelValue="userName = $event";那么 v-model:userName="userName" 就等价于 :userName="userName" 加 @update:user-name="userName = $event"。多个 v-model 就是重复这套规则。
易错点:不要把 v-model:userName 理解成只能绑一个值;它和普通 prop 一样,参数名要和子组件 props 对应。
单向绑定 :value 为什么不够
笔记里父组件先写了:
vue
<input type="text" :value="userName">这是单向绑定:数据能传到输入框,但在输入框里改值不会反过来改 userName。所以它只适合展示,不适合双向同步。
怎么做:需要双向时用 v-model;需要父子同步时,用组件 v-model 或“prop + emit”手动实现。易错点:看到输入框有值就以为已经双向绑定,实际改输入框后父组件数据没变。
父组件:使用多个 v-model
src/App.vue:
vue
<template>
<h3>我是父组件App{{userName}}</h3>
<!-- 将数据userName的值绑定在value属性上,但更改value的值不会影响数据。(不会响应) -->
<input type="text" :value="userName">
<input type="text" v-model="userName">
<input type="text" v-model="age">
<hr/>
<Child v-model:userName="userName" v-model:age="age"/>
</template>
<script lang="ts" setup>
import Child from "@/components/Child.vue";
import {ref} from "vue";
let userName = ref("zhangsan");
let age = ref(100)
</script>
<style scoped>
</style>这里 <Child v-model:userName="userName" v-model:age="age"/> 表示:把 userName 作为 userName prop 传给子组件,并监听子组件的 update:user-name;把 age 作为 age prop 传给子组件,并监听 update:age。父组件不需要再手写 @update:user-name。
易错点:v-model:userName 的参数名是 userName,子组件 props 要能接收到 userName;事件名通常写成 update:user-name,注意连字符。
子组件:声明 props 和 emits
src/components/Child.vue:
vue
<template>
<h3>我是子组件Child</h3>
<p @click="$emit('update:user-name',userName+'!')">userName:{{userName}}</p>
<p @click="$emit('update:age',age+1)">age:{{age}}</p>
</template>
<script lang="ts" setup>
defineProps(["userName","age"]);
const $emit = defineEmits(["update:user-name","update:age"]);
</script>
<style scoped>
</style>子组件先用 defineProps 接收父组件传来的 userName、age,模板里可以直接使用。点击第一行时触发 update:user-name,并把 userName + '!' 作为新值传给父组件;点击第二行时触发 update:age,把 age + 1 传给父组件。父组件收到后更新自己的 ref,再通过 prop 传回子组件,形成同步。
易错点:子组件不能直接写 userName = ... 改 props;必须 emit 通知父组件改。defineEmits 里声明的事件名要和 $emit 调用名一致,多个 v-model 就要分别声明、分别触发。
调试过程:点击后看父组件是否更新
转写稿里的调试思路是:先让子组件能显示父组件传来的值,再点击子组件触发 update 事件,观察父组件标题或输入框是否变化;再增加第二个 v-model:age,点击年龄行看是否只改年龄、不影响用户名。若点击没反应,优先检查三处:父组件有没有写对应的 v-model:参数;子组件 props 有没有声明该参数;defineEmits 和 $emit 的事件名是否一致。若子组件显示 undefined,通常是父组件没传对应 prop,或参数名写错。
易错点汇总
v-model:userName不是只传值,它还会监听update:user-name。- 事件名建议统一用连字符形式,避免大小写混淆。
- 子组件不要直接修改 props,必须通过
emit让父组件改。 - Vue3 中不再使用 Vue2 的
.sync,具名v-model就承担了类似“同步多个 prop”的角色。 :value是单向的,只有v-model或手动prop + emit才能双向同步。
先明确本节目标:父子组件数据同步
为什么需要:父组件把 userName 传给子组件显示,子组件点击后又要反过来修改父组件的数据。如果只传 prop,子组件不能直接改 prop;如果只写自定义事件,父组件还要手动绑定监听,代码会啰嗦。
怎么想:把“传值”和“通知父组件更新”合成一个固定套路:父组件传一个属性,同时监听一个 update:属性名 事件;子组件需要修改时 $emit 这个事件。Vue2 的 .sync 就是把这个套路做成语法糖。
怎么做:本节笔记里的 App.vue 使用 :userName.sync="userName",子组件 Child.vue 在点击时触发 $emit('update:userName','lisi')。
易错点:.sync 不是让子组件直接改 prop,子组件仍然必须 emit 正确事件;事件名必须是 update:userName,大小写和属性名要一致。
Vue2 .sync 的推导:它到底帮你写了什么
为什么需要:如果每次父子同步都手写 :userName="userName" 和 @update:userName="...",重复且容易漏。.sync 的价值是减少模板重复。
怎么想:转写稿里反复强调,Vue2 的 .sync 本质上是“传一个属性 + 传一个自定义事件”。可以把它理解成编译后近似:
vue
<Child :userName="userName" @update:userName="val => userName = val" />子组件里则通过 $emit('update:userName', 新值) 通知父组件。父组件收到后更新 userName,子组件接收的 prop 也会随之更新。
怎么做:看本节笔记代码。App.vue 中:
vue
<template>
<div>
<h3>{{userName}}</h3>
<Child :userName.sync="userName"/>
</div>
</template>
<script>
import Child from "@/components/Child";
export default {
name:"App",
components: {Child},
data(){
return {
userName:"zhangsan"
}
}
}
</script>
<style scoped>
</style>Child.vue 中:
vue
<template>
<div>
<h3 @click="$emit('update:userName','lisi')">子组件:child-->{{userName}}</h3>
</div>
</template>
<script>
export default {
name:"Child",
props:["userName"],
mounted(){
console.log(this)
}
}
</script>
<style scoped>
</style>点击子组件标题后,$emit('update:userName','lisi') 触发,父组件 userName 从 zhangsan 变成 lisi,父组件标题和子组件 prop 都会显示 lisi。
易错点:不要写成 $emit('userName','lisi'),.sync 需要的是 update:userName;也不要在子组件里写 this.userName = 'lisi',那是在改 prop,Vue 会警告。
Vue3 废弃 .sync:改用 v-model 体系
为什么需要:Vue3 移除了 .sync 修饰符。转写稿里的“点SYC”就是 .sync,“微杠model”就是 v-model。本节笔记也明确写了:Vue3 废弃掉了 .sync,Vue2 中的 .sync 相当于 Vue3 中的 v-model。
怎么想:Vue3 的 v-model 不是只能用在输入框,也可以用在组件上。默认 v-model 对应 prop modelValue 和事件 update:modelValue;带参数时,v-model:userName="userName" 对应 prop userName 和事件 update:userName。所以本节这种具名同步,应写成 v-model:userName。
怎么做:把父组件改成:
vue
<Child v-model:userName="userName" />子组件仍然可以 props: ["userName"],点击时仍然 $emit('update:userName', 'lisi')。这与 Vue2 .sync 的“属性 + update 事件”思路一致。若是默认 v-model,子组件则要接收 modelValue,并 $emit('update:modelValue', 新值)。
易错点:在 Vue3 里继续写 :userName.sync="userName" 没有意义,不会产生 update:userName 监听。转写稿里的调试过程就是:保留 .sync 写法点击子组件,页面不更新;去掉或随便写都“没有区别”,说明 Vue3 不认这个修饰符。不要把它当成还能用的兼容写法。
调试与易错点汇总
为什么需要:这类写法最容易“代码看起来对,点击没反应”,必须能定位是 prop 没传、事件名不对,还是框架版本不对。
怎么想:先看子组件 props 是否收到值,再看 $emit 的事件名是否与父组件监听一致,最后确认 Vue2/Vue3 语法。转写稿中通过 console.log(this) 观察子组件实例,能看到 props、$attrs、$listeners 等信息,帮助判断数据从哪来。
怎么做:在 Vue3 项目里先写 :userName.sync 试一次,点击后父组件不更新;改成 v-model:userName="userName" 后,再点击,父组件和子组件都变成 lisi。如果是多个字段,就写多个具名 v-model,例如 v-model:userName、v-model:age,分别对应 update:userName、update:age。
易错点:Vue2 的 .sync 和 Vue3 的 v-model 不能混写;Vue3 默认 v-model 的 prop 是 modelValue,不是 Vue2 的 value;事件名拼写、大小写必须严格一致;子组件永远不要直接修改 prop,只 emit 让父组件改。
本节要解决什么问题
父组件 App.vue 使用子组件 Child.vue 时,除了正常写 props 接收数据,还有一种“我不提前声明 props,也要把父组件传下来的属性和事件拿到”的场景。Vue2 提供了两个实例属性:
this.$attrs:获取父组件传过来、但没有被子组件props声明的属性。this.$listeners:获取父组件传过来的自定义事件(原生事件不行)。
为什么需要它?因为有时子组件只是“中转站”或“二次封装”,父组件传了很多属性、绑了很多事件,子组件不想一个个写 props 和 $emit。这时 $attrs 与 $listeners 可以让子组件直接读取并转发。怎么想?先把父组件绑定的内容分成两类:普通属性归 $attrs,自定义事件归 $listeners。怎么做?看下面代码。
父组件 App.vue:传属性、绑事件
vue
<template>
<div>
<h3>父组件App</h3>
{{sex}}
<hr/>
<Child @click="fn" a="1" b="2" c="3" :sex="sex"/>
</div>
</template>
<script>
import Child from "@/components/Child";
export default {
name:"App",
components: {Child},
data(){
return {
sex:"男"
}
},
methods:{
fn(a,b,c,d){
console.log('fn',a,b,c,d)
this.sex = d;
}
}
}
</script>
<style scoped>
</style>这里 Child 上写了 a="1" b="2" c="3" :sex="sex",还写了 @click="fn"。注意:在组件标签上写 @click,对子组件来说它是“自定义事件”,不是原生 DOM 的 click。转写稿里调试时也强调:先打印 this,在控制台展开能看到 $attrs 里有 a、b、c、sex,$listeners 里有 click。如果给事件加上 .native,$listeners 里就看不到了,因为 .native 走的是原生事件绑定,不进入自定义事件集合。
子组件 Child.vue:读取 $attrs 与 $listeners
vue
<template>
<div>
<h3>子组件Child</h3>
<p>a:{{$attrs.a}}</p>
<p>b:{{$attrs.b}}</p>
<p>c:{{$attrs.c}}</p>
<p>sex:{{$attrs.sex}}</p>
<button @click="test"> 点我</button>
</div>
</template>
<script>
// 如果你在
// 可以通过this.$attrs获取父向子传递过来的属性。
// 可以通过this.$listeners获取父向子传递过来的自定义事件(原生不可以)。
export default {
name:"Child",
methods:{
test(){
this.$listeners.click(1,2,3,4);
}
},
mounted(){
console.log(this);
}
}
</script>
<style scoped>
</style>子组件没有写 props,但模板里可以用 $attrs.a、$attrs.b、$attrs.c、$attrs.sex 直接取值。mounted 里 console.log(this) 是为了调试:展开对象确认 $attrs 和 $listeners 的内容。test 方法里调用 this.$listeners.click(1,2,3,4),相当于主动触发父组件传下来的 click 自定义事件,并把 1、2、3、4 按顺序传给父组件的 fn(a,b,c,d)。父组件打印 fn 1 2 3 4,并把 this.sex = d,所以 sex 变成 4,页面上父组件的 {{sex}} 和子组件里的 $attrs.sex 都会更新。
易错点与调试提醒
第一,$attrs 不是 props。父组件传了 a、b、c、sex,子组件没声明 props,所以它们都在 $attrs 里;模板中不能直接写 {{a}},要写 {{$attrs.a}}。第二,$listeners 只收自定义事件。父组件 @click="fn" 对子组件是自定义事件,所以能通过 $listeners.click 调到;如果写成 @click.native="fn",它就不在 $listeners 里了。第三,调用 this.$listeners.click(...) 前要确认父组件确实绑定了 click,否则可能报错;本节代码能成立,是因为 App.vue 明确写了 @click="fn"。第四,fn 接收四个参数,所以 test 里传 1,2,3,4 正好对应 a,b,c,d,最后 d 是 4,sex 被改成 4。调试时先看 mounted 打印的 this,再点按钮看 fn 的输出,就能把“属性归 $attrs、自定义事件归 $listeners”这条线走通。
先看本节要解决的问题
这一节的主角是 Vue3 的 useAttrs。父组件 App.vue 在子组件标签上写了 @click="fn" a="1" b="2" c="3" :count="count",子组件 Child.vue 既没有把这些都声明成 props,也没有逐个声明 emits,却仍然要拿到这些“额外”的属性和事件监听器。useAttrs 就是用来做这件事的。
本节笔记代码如下,先保留原样:
vue
<!-- src/App.vue -->
<template>
<h3>我是父组件App{{count}}</h3>
<hr/>
<Child @click="fn" a="1" b="2" c="3" :count="count"/>
</template>
<script lang="ts" setup>
import Child from "@/components/Child.vue";
import {ref} from "vue";
const count = ref(100);
const fn = function(a,b,c,d){
console.log("fn",a,b,c,d);
count.value = a+b+c+d+count.value;
}
</script>
<style scoped>
</style>vue
<!-- src/components/Child.vue -->
<template>
<h3>我是子组件Child</h3>
<p>{{$attrs}}</p>
<p>{{$attrs.onClick}}</p>
<button @click="$attrs.onClick(1,2,3,4)">点我</button>
</template>
<script lang="ts" setup>
// 模板中可以使用$attrs
// 可以使用组合API->useAttrs
// props的优先级高于attrs(如果在props当中声明了一个数据状态,那么attrs中不会存在与其同名的。)
// 相较于vue2中的$attrs而言通过useAttrs获取到的不仅仅是属性也包含自定义的事件。
// 即:useAttrs运行的结果是vue2中的$attrs与$listeners的一个结合体。
import {useAttrs} from "vue";
const $attrs = useAttrs();
// const props = defineProps(["count"]);
console.log($attrs);
</script>
<style scoped>
</style>为什么需要:把 $attrs 和 $listeners 合起来
转写稿里对比了 Vue2:$attrs 能拿到父组件传下来的属性,但拿不到自定义事件;自定义事件要用 $listeners 拿。并且给原生事件加 .native 也不是这里想要的方式。到了 Vue3,$listeners 没有了,useAttrs 的运行结果相当于 Vue2 中 $attrs 与 $listeners 的结合体:既包含未声明为 props 的属性,也包含未声明为 emits 的事件监听器。
所以这里的“怎么想”是:父组件写在 Child 标签上的内容,先分流。声明成 props 的进 props;声明成 emits 的进事件;剩下没声明的,就落到 attrs 里。笔记里特别强调:props 的优先级高于 attrs,如果 props 中声明了 count,那么 attrs 中就不会再存在同名的 count。
怎么想:attrs 里到底有什么
父组件传了 a="1" b="2" c="3" :count="count",子组件没有写 defineProps,所以这些都会进 attrs。父组件写的 @click="fn" 在组件标签上不是原生 DOM 点击,而是给子组件传了一个事件监听器,编译后键名是 onClick,值就是父组件的 fn 函数。因此子组件模板里可以写 {{$attrs.onClick}} 看到这个函数。
按钮上的 @click="$attrs.onClick(1,2,3,4)" 要理解成:按钮自己的原生点击触发后,手动调用父组件传进来的 onClick,并传入 1,2,3,4。父组件 fn(a,b,c,d) 收到后打印,并执行 count.value = a+b+c+d+count.value。初始 count 是 100,第一次点击就是 100+1+2+3+4=110,再点一次继续累加。
怎么做:模板 $attrs 与组合 API useAttrs
模板里可以直接用 $attrs,脚本里用组合 API:
ts
import {useAttrs} from "vue";
const $attrs = useAttrs();
console.log($attrs);调试时先看控制台打印的 $attrs,能看到 a、b、c、count、onClick 这一类内容;再点按钮,观察父组件 fn 的打印和页面上的 count 变化。若把 // const props = defineProps(["count"]); 取消注释,count 就进入 props,attrs 里不再有同名 count,这就是“props 优先级高于 attrs”的直接验证。
易错点
第一,事件名不是 click,而是 onClick;$attrs.onClick 才是父组件传进来的函数。第二,按钮上不要写成 @click="$attrs.onClick",否则原生事件对象会作为第一个参数传进去,a 变成事件对象,后面的加法容易得到 NaN;本节代码明确写 $attrs.onClick(1,2,3,4)。第三,useAttrs() 拿到的对象不要随意解构后期待它自动更新,脚本里访问属性要谨慎;模板中 $attrs 更直观。第四,本节子组件有多个根节点,未声明的事件监听器不会像单根组件那样自动透传,所以这里用按钮手动调用 $attrs.onClick。第五,@click 写在组件标签上是组件事件监听器,不要和普通 DOM 的 click 混为一谈。
课堂画面






代码示例
1. mitt 事件总线(跨组件通信,替代 Vue2 的 $bus)
src/utils/index.ts
ts
import mitt from "mitt";
export const bus = mitt();src/App.vue
vue
<template>
<h3>App组件(父组件){{num}}</h3>
<hr/>
<One :num="num"/>
<hr/>
<Two :num="num"/>
</template>
<script lang="ts" setup>
import {onMounted, ref} from "vue";
import {bus} from "@/utils";
import One from "@/components/One.vue"
import Two from "@/components/Two.vue"
const num = ref(100);
onMounted(function(){
bus.on("changenum",(v)=>{
console.log("APP->changenum",v);
num.value = v as number;
})
})
</script>
<style scoped>
</style>src/components/One.vue
vue
<template>
<h3>One:{{num}}</h3>
</template>
<script setup lang="ts">
import {onMounted} from "vue";
import {bus} from "@/utils";
defineProps(["num"]);
onMounted(function(){
bus.on("changenum",()=>{
console.log("ONE->changenum");
// bus.off("changenum");
})
})
</script>
<style scoped>
</style>src/components/Two.vue
vue
<template>
<h3 @click="changeNum">Two1:{{num}}</h3>
<h3 @click="bus.emit('changenum',300)">Two2:{{num}}</h3>
</template>
<script setup lang="ts">
import {bus} from "@/utils";
defineProps(["num"]);
const changeNum = function(){
bus.emit("changenum",200);
}
</script>
<style scoped>
</style>要点与易错点:
- Vue3 没有
Vue.prototype,所以没有$bus,必须先cnpm i mitt安装第三方库;mitt()返回的是一个对象,不是函数,直接导出即可(笔记用export const bus = mitt(),不用export default,方便 utils 里再加别的方法)。 bus.on(name, cb)订阅、bus.emit(name, 参数)发布、bus.off(name)取消。三者都挂在同一个bus实例上,任何组件只要import {bus}就能互通,不需要像自定义事件那样一层层往下传 props/emit。emit只能带一个参数(转写稿明确:传多个没用),要传多个值就传对象。num.value = v as number:TS 里on回调的参数类型是未知的,必须断言后才能赋给ref<number>。- 订阅不取消会一直存在(和 Vue2 事件总线一样)。用路由组件时,路由切走前要
bus.off("changenum");页面刷新会重新挂载,不会有残留。
2. v-model 实现父子数据同步 + 多个 v-model
src/App.vue
vue
<template>
<h3>v-model数据的双向绑定</h3>
<input v-model="userName" type="text"><span>{{userName}}</span>
<hr/>
<h3>vue3中实现父子组件数据同步</h3>
<Child :value="userName" @input="setUserName"/>
<hr/>
<h3>v-model实现父子组件数据同步</h3>
<!-- 1- 向子组件传递了两个属性:modelValue onUpdate:modelValue -->
<!-- 2- 向子组件传递了自定义事件:update:model-value -->
<Child2 v-model="userName"/>
<hr/>
<h3>使用多个v-model</h3>
<input type="text" v-model="age">
<Child v-model:userName="userName" v-model:age="age"/>
</template>
<script lang="ts" setup>
import {ref} from "vue";
import Child from "@/components/Child.vue"
import Child2 from "@/components/Child2.vue"
let userName = ref("zhangsan");
let age = ref(100)
const setUserName = function(info:string){
console.log("setUserName",info);
userName.value = info;
}
</script>
<style scoped>
</style>src/components/Child.vue
vue
<template>
<h3 @click="$emit('update:user-name',userName+'!')">userName:{{userName}}</h3>
<p @click="$emit('update:age',age+1)">age:{{age}}</p>
</template>
<script lang="ts" setup>
defineProps(["userName","age"]);
const $emit = defineEmits(["update:user-name","update:age"]);
</script>
<style scoped>
</style>src/components/Child2.vue
vue
<template>
<h3>我是子组件Child2:{{modelValue}}</h3>
<button @click="test">onUpdate:modelValue</button>
<button @click="test2">update:model-value</button>
</template>
<script lang="ts" setup>
const props = defineProps(["modelValue","onUpdate:modelValue"]);
const emit = defineEmits(["update:model-value"]);
const test = function(){
props["onUpdate:modelValue"]("lisi");
}
const test2 = function(){
emit("update:model-value","wangwu")
}
</script>
<style scoped>
</style>要点与易错点:
v-model="userName"在组件上等于两件事:给子组件传modelValue属性,并传一个onUpdate:modelValue函数(即update:model-value事件)。转写稿强调:Vue2 里v-model传的是value+input,Vue3 默认改成了modelValue+update:modelValue,所以直接照抄 Vue2 的value/input写法是收不到值的。Child2里onUpdate:modelValue虽然没在defineEmits里声明,但它被当成 prop 函数传进来了,可以直接调用;update:model-value则走defineEmits+emit,两条路都能改父组件数据。v-model:userName="userName"是具名 v-model:属性名变成userName,事件名变成update:user-name(模板里写 kebab-case,子组件defineEmits里也写update:user-name)。可以同时挂多个v-model:xxx,这是 Vue3 才有的能力,Vue2 只能靠.sync。- 在
defineProps里声明过的名字(如modelValue)就不会再出现在$attrs/useAttrs()里,props 优先级高于 attrs。 - 在模板里直接用
$emit(...)可以,但更规范的是const $emit = defineEmits([...])后调用,否则 TS 类型不校验。
3. useAttrs(Vue3 版的 $attrs + $listeners 合体)
src/App.vue
vue
<template>
<h3>我是父组件App{{count}}</h3>
<hr/>
<Child @click="fn" a="1" b="2" c="3" :count="count"/>
</template>
<script lang="ts" setup>
import Child from "@/components/Child.vue";
import {ref} from "vue";
const count = ref(100);
const fn = function(a,b,c,d){
console.log("fn",a,b,c,d);
count.value = a+b+c+d+count.value;
}
</script>
<style scoped>
</style>src/components/Child.vue
vue
<template>
<h3>我是子组件Child</h3>
<p>{{$attrs}}</p>
<p>{{$attrs.onClick}}</p>
<button @click="$attrs.onClick(1,2,3,4)">点我</button>
</template>
<script lang="ts" setup>
// 模板中可以使用$attrs
// 可以使用组合API->useAttrs
// props的优先级高于attrs(如果在props当中声明了一个数据状态,那么attrs中不会存在与其同名的。)
// 相较于vue2中的$attrs而言通过useAttrs获取到的不仅仅是属性也包含自定义的事件。
// 即:useAttrs运行的结果是vue2中的$attrs与$listeners的一个结合体。
import {useAttrs} from "vue";
const $attrs = useAttrs();
// const props = defineProps(["count"]);
console.log($attrs);
</script>
<style scoped>
</style>要点与易错点:
- Vue2 里要分两个:
this.$attrs拿属性、this.$listeners拿自定义事件(原生事件不行);Vue3 用useAttrs()一把拿全,属性 + 自定义事件都在里面,等于两者合并。 - 父组件写
@click="fn"传到子组件后,在 attrs 里的键名是onClick(驼峰、带on前缀),所以调用要写$attrs.onClick(1,2,3,4),fn里就收到 1、2、3、4,能改父组件的count。 - 一旦用
defineProps(["count"])声明了count,count就从 attrs 里消失,$attrs.count拿不到——这是"props 优先级高于 attrs",易错点常在这里。 useAttrs()返回的对象是只读的,不要试图直接改它。- 注意区分:Vue2 里给事件加
.native才能拿原生事件,Vue3 没有.native,普通@click就是自定义事件名onClick。
常见错误
点击后控制台没有任何输出
- 现象:子组件
bus.emit('changenum', 200)已执行,父组件bus.on里的console.log不打印。 - 原因:
on写在onMounted里,而emit发生在挂载完成之前(例如子组件 setup 期间就 emit),订阅者还没注册。 - 修法:把
bus.on提前到setup顶层或父组件的onMounted中;或者在 emit 前确认订阅方已挂载。
- 现象:子组件
切换路由再回来,点一次触发两次、三次
- 现象:控制台重复打印,
num一次加了好几倍。 - 原因:
bus.on注册的匿名回调没有注销,路由组件卸载后回调仍留在 mitt 的事件表里,重新进入又注册一份。 - 修法:
onUnmounted中bus.off('changenum', handler),handler 必须是同一个函数引用(先命名再传),不能 off 一个新建的匿名函数;必要时bus.all.clear()。
- 现象:控制台重复打印,
bus.emit('xxx', a, b)第二个参数丢失- 现象:监听回调只收到第一个值,后面的
undefined。 - 原因:mitt 的 emit 只接受一个 payload。
- 修法:打包成对象
bus.emit('xxx', { a, b }),回调里解构。
- 现象:监听回调只收到第一个值,后面的
TS 报
unknown不能赋值给number- 现象:
num.value = v报类型错误。 - 原因:
mitt()未声明事件表,回调参数默认unknown。 - 修法:
mitt<{ changenum: number }>()声明事件映射;临时可用as number断言,但优先用泛型。
- 现象:
子组件直接改
props.modelValue,父组件不更新还报警告- 现象:子组件数据变了,父组件不变,控制台出现 "Set operation on key ... failed: target is readonly"。
- 原因:把 props 当本地状态写。
- 修法:
emit('update:modelValue', 新值);或调用props['onUpdate:modelValue'](新值),二者等价。
useAttrs()里拿不到某个属性或事件- 现象:
$attrs打印出来少了count,或少了onUpdate:modelValue。 - 原因:该字段已在
defineProps中声明,props 优先级高于 attrs,会从 attrs 中剔除。 - 修法:要么不声明、纯靠 attrs 透传;要么声明了就只从 props 取,不要两边都指望。
- 现象:
多个 v-model 时事件名大小写写错,回调收不到
- 现象:
v-model:userName已写,子组件emit('update:username')无反应。 - 原因:
update:userName与update:username是两个不同事件;props 里'onUpdate:userName'的大小写也必须一致。 - 修法:模板中用
update:user-name(kebab-case)最稳,props 中用'onUpdate:userName'(camelCase);两边保持同一套命名约定。
- 现象:
Vue3 中写
:userName.sync="userName"完全无效- 现象:子组件 emit
update:userName后父组件数据不变,且无报错。 - 原因:
.sync在 Vue3 已废弃,修饰符被当成普通属性透传。 - 修法:改成
v-model:userName="userName";Vue2 旧代码迁移时逐处替换。
- 现象:子组件 emit
Vue2 里
$listeners.click取到undefined- 现象:
this.$listeners.click(1,2,3)报错。 - 原因:父组件写的是原生
@click且子组件没加.native,原生事件不会进入$listeners。 - 修法:父组件加
@click.native,或子组件用$emit('click')自行转发。
- 现象:
练习
总线收发的最小闭环:在
src/utils/index.ts导出一个带类型声明的bus,让Two.vue点击时发出{ id, value }结构的消息,One.vue与App.vue同时订阅并各自处理;要求One.vue在卸载时正确注销,App.vue收到后更新num。定位并修复重复订阅:给定一个在路由组件
A.vue的onMounted里写了bus.on('changenum', () => {...})的代码,反复进入/离开该路由 3 次后点击按钮,控制台打印了 4 次。请说明原因并给出两种修复方案(一种用off,一种用命名函数 +onUnmounted),比较二者差异。手写一个 v-model 等价实现:不用
v-model,只用:modelValue+@update:modelValue让Child2.vue与父组件userName双向同步;写完后改成v-model,并打印子组件的props与$attrs,指出哪些键发生了变化。多字段双向绑定:实现
<Child v-model:userName="userName" v-model:age="age" />,子组件点击userName追加!、点击age加 1。要求:父组件两个ref同步更新;在子组件里用useAttrs()打印一次,解释为什么userName、age、onUpdate:userName是否出现在 attrs 中。透传封装:写一个
MyButton.vue,要求父组件写在<MyButton>上的class、style、@click、自定义属性data-id全部原样落到内部<button>上,同时组件内部还声明一个typeprop 用于控制按钮类型。说明为什么type不会出现在$attrs中。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 事件总线-mitt | 17:17 |
| 2 | v-model实现父子组件数据同步 | 16:18 |
| 3 | v-model实现父子数据同步 | 19:49 |
| 4 | vue3使用多个v-model | 14:02 |
| 5 | vue3-sync | 07:56 |
| 6 | vue2之$attrs与$listeners | 08:39 |
小结
- 选型优先级:父子传值优先
props/emit;需要双向同步用v-model;跨层级、无直接关系的组件才用 mitt,不要拿总线替代正常的父子通信。 - mitt 三条铁律:一个事件只传一个 payload;
on与off必须成对且函数引用一致;路由组件中订阅必须随卸载注销,否则回调会累积。 - Vue3 的
v-model就是"一个 prop + 一个事件"的语法糖,modelValue是默认名,:name可改名;理解这一点后,props['onUpdate:xxx']与emit('update:xxx')只是同一件事的两种写法。 .sync在 Vue3 已彻底移除,迁移时一律等价替换为v-model:字段名;在 Vue3 里写.sync不会报错但完全无效,是最容易被忽略的静默失败。- props 优先级高于 attrs:字段一旦在
defineProps中声明就从$attrs消失,这是useAttrs()拿不到数据的首要排查点。 useAttrs()= Vue2 的$attrs+$listeners,且包含事件(onXxx形式),Vue3 不再需要$listeners;Vue2 中$listeners不含原生事件,需.native。- 命名一致性是排查顺序的第一站:
update:userName/update:user-name/onUpdate:userName三者大小写不匹配时,事件会静默丢失,不报错。 - 模板里能直接用
$attrs,逻辑里必须useAttrs();两者取到的是同一份数据,不要在同一个组件里混用两套访问方式。