Skip to content

vue3通过mitt完成事件总线与v-model实现父子数据(Lesson26 2/3) ​

本页对应 Lesson26,共 6 段视频,约 1 小时 24 分。

学习目标 ​

  1. 能安装并配置 mitt,创建事件总线模块,完成跨组件的发布、订阅与取消订阅,并说明路由切换时为何需要调用 off 清理订阅。
  2. 能解释 Vue2 中 v-model 等价于 value 属性与 input 事件的组合,并手写实现父子组件数据同步。
  3. 能解释 Vue3 中 v-model 等价于 modelValue 属性与 update:modelValue 事件的组合,并独立实现父子组件双向同步。
  4. 能使用 Vue3 多个 v-model 及具名 v-model,正确处理对应的 update:xxx 事件并完成多组数据同步。
  5. 能说明 Vue3 废弃 .sync 的原因,能在 Vue2 中使用 .sync 实现属性与 update:xxx 事件同步,并比较其与 Vue3 v-model 的对应关系。
  6. 能区分 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,或参数名写错。

易错点汇总 ​

  1. v-model:userName 不是只传值,它还会监听 update:user-name。
  2. 事件名建议统一用连字符形式,避免大小写混淆。
  3. 子组件不要直接修改 props,必须通过 emit 让父组件改。
  4. Vue3 中不再使用 Vue2 的 .sync,具名 v-model 就承担了类似“同步多个 prop”的角色。
  5. :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

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

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。

常见错误 ​

  1. 点击后控制台没有任何输出

    • 现象:子组件 bus.emit('changenum', 200) 已执行,父组件 bus.on 里的 console.log 不打印。
    • 原因:on 写在 onMounted 里,而 emit 发生在挂载完成之前(例如子组件 setup 期间就 emit),订阅者还没注册。
    • 修法:把 bus.on 提前到 setup 顶层或父组件的 onMounted 中;或者在 emit 前确认订阅方已挂载。
  2. 切换路由再回来,点一次触发两次、三次

    • 现象:控制台重复打印,num 一次加了好几倍。
    • 原因:bus.on 注册的匿名回调没有注销,路由组件卸载后回调仍留在 mitt 的事件表里,重新进入又注册一份。
    • 修法:onUnmounted 中 bus.off('changenum', handler),handler 必须是同一个函数引用(先命名再传),不能 off 一个新建的匿名函数;必要时 bus.all.clear()。
  3. bus.emit('xxx', a, b) 第二个参数丢失

    • 现象:监听回调只收到第一个值,后面的 undefined。
    • 原因:mitt 的 emit 只接受一个 payload。
    • 修法:打包成对象 bus.emit('xxx', { a, b }),回调里解构。
  4. TS 报 unknown 不能赋值给 number

    • 现象:num.value = v 报类型错误。
    • 原因:mitt() 未声明事件表,回调参数默认 unknown。
    • 修法:mitt<{ changenum: number }>() 声明事件映射;临时可用 as number 断言,但优先用泛型。
  5. 子组件直接改 props.modelValue,父组件不更新还报警告

    • 现象:子组件数据变了,父组件不变,控制台出现 "Set operation on key ... failed: target is readonly"。
    • 原因:把 props 当本地状态写。
    • 修法:emit('update:modelValue', 新值);或调用 props['onUpdate:modelValue'](新值),二者等价。
  6. useAttrs() 里拿不到某个属性或事件

    • 现象:$attrs 打印出来少了 count,或少了 onUpdate:modelValue。
    • 原因:该字段已在 defineProps 中声明,props 优先级高于 attrs,会从 attrs 中剔除。
    • 修法:要么不声明、纯靠 attrs 透传;要么声明了就只从 props 取,不要两边都指望。
  7. 多个 v-model 时事件名大小写写错,回调收不到

    • 现象:v-model:userName 已写,子组件 emit('update:username') 无反应。
    • 原因:update:userName 与 update:username 是两个不同事件;props 里 'onUpdate:userName' 的大小写也必须一致。
    • 修法:模板中用 update:user-name(kebab-case)最稳,props 中用 'onUpdate:userName'(camelCase);两边保持同一套命名约定。
  8. Vue3 中写 :userName.sync="userName" 完全无效

    • 现象:子组件 emit update:userName 后父组件数据不变,且无报错。
    • 原因:.sync 在 Vue3 已废弃,修饰符被当成普通属性透传。
    • 修法:改成 v-model:userName="userName";Vue2 旧代码迁移时逐处替换。
  9. Vue2 里 $listeners.click 取到 undefined

    • 现象:this.$listeners.click(1,2,3) 报错。
    • 原因:父组件写的是原生 @click 且子组件没加 .native,原生事件不会进入 $listeners。
    • 修法:父组件加 @click.native,或子组件用 $emit('click') 自行转发。

练习 ​

  1. 总线收发的最小闭环:在 src/utils/index.ts 导出一个带类型声明的 bus,让 Two.vue 点击时发出 { id, value } 结构的消息,One.vue 与 App.vue 同时订阅并各自处理;要求 One.vue 在卸载时正确注销,App.vue 收到后更新 num。

  2. 定位并修复重复订阅:给定一个在路由组件 A.vue 的 onMounted 里写了 bus.on('changenum', () => {...}) 的代码,反复进入/离开该路由 3 次后点击按钮,控制台打印了 4 次。请说明原因并给出两种修复方案(一种用 off,一种用命名函数 + onUnmounted),比较二者差异。

  3. 手写一个 v-model 等价实现:不用 v-model,只用 :modelValue + @update:modelValue 让 Child2.vue 与父组件 userName 双向同步;写完后改成 v-model,并打印子组件的 props 与 $attrs,指出哪些键发生了变化。

  4. 多字段双向绑定:实现 <Child v-model:userName="userName" v-model:age="age" />,子组件点击 userName 追加 !、点击 age 加 1。要求:父组件两个 ref 同步更新;在子组件里用 useAttrs() 打印一次,解释为什么 userName、age、onUpdate:userName 是否出现在 attrs 中。

  5. 透传封装:写一个 MyButton.vue,要求父组件写在 <MyButton> 上的 class、style、@click、自定义属性 data-id 全部原样落到内部 <button> 上,同时组件内部还声明一个 type prop 用于控制按钮类型。说明为什么 type 不会出现在 $attrs 中。

原视频对照 ​

#视频时长
1事件总线-mitt17:17
2v-model实现父子组件数据同步16:18
3v-model实现父子数据同步19:49
4vue3使用多个v-model14:02
5vue3-sync07:56
6vue2之$attrs与$listeners08:39

小结 ​

  1. 选型优先级:父子传值优先 props/emit;需要双向同步用 v-model;跨层级、无直接关系的组件才用 mitt,不要拿总线替代正常的父子通信。
  2. mitt 三条铁律:一个事件只传一个 payload;on 与 off 必须成对且函数引用一致;路由组件中订阅必须随卸载注销,否则回调会累积。
  3. Vue3 的 v-model 就是"一个 prop + 一个事件"的语法糖,modelValue 是默认名,:name 可改名;理解这一点后,props['onUpdate:xxx'] 与 emit('update:xxx') 只是同一件事的两种写法。
  4. .sync 在 Vue3 已彻底移除,迁移时一律等价替换为 v-model:字段名;在 Vue3 里写 .sync 不会报错但完全无效,是最容易被忽略的静默失败。
  5. props 优先级高于 attrs:字段一旦在 defineProps 中声明就从 $attrs 消失,这是 useAttrs() 拿不到数据的首要排查点。
  6. useAttrs() = Vue2 的 $attrs + $listeners,且包含事件(onXxx 形式),Vue3 不再需要 $listeners;Vue2 中 $listeners 不含原生事件,需 .native。
  7. 命名一致性是排查顺序的第一站:update:userName / update:user-name / onUpdate:userName 三者大小写不匹配时,事件会静默丢失,不报错。
  8. 模板里能直接用 $attrs,逻辑里必须 useAttrs();两者取到的是同一份数据,不要在同一个组件里混用两套访问方式。