主题
vue2父向子通过属性传递参数与Vue3父向子通过属性进行参(Lesson26 1/3)
本页对应 Lesson26,共 7 段视频,约 1 小时 49 分。
学习目标
- 能够分别使用 Vue2 的
props和 Vue3 的defineProps完成父组件向子组件传参,并正确配置类型校验、必填项和默认值。 - 能够说明并验证子组件中接收到的
props为只读数据,需通过父组件方法或自定义事件修改状态。 - 能够分别使用 Vue2 的
$emit与 Vue3 的defineEmits实现子组件向父组件触发自定义事件并传递参数。 - 能够运用“状态提升”将多个组件共享的状态放在共同父组件中管理,并通过属性下发、事件上报完成同步更新。
- 能够使用 Vue2 事件总线和 Vue3 的
mitt创建事件总线,完成跨组件发布与订阅通信,并说明 Vue3 中不能直接使用Vue.prototype的原因。
知识点
本节要解决的问题:父组件的数据怎样交给子组件
为什么需要:父组件 App.vue 里保存了 userName、age 等状态,子组件 Child.vue 只负责展示。子组件不能直接读父组件的 data,所以需要一种明确的数据通道。Vue2 里最基础的方式就是“父向子通过属性传递参数”,子组件用 props 接收。
怎么想:父组件在模板中使用子组件标签,把数据以属性形式写在标签上;子组件声明 props,声明哪些属性允许外部传入。父组件负责提供数据,子组件负责接收和展示。
怎么做:先引入并注册子组件,再在模板中写 <Child :userName="userName" :age="age" hobby="1"/>。注意 :userName="userName" 中,左边是子组件接收的属性名,右边是父组件 data 中的变量;不加冒号的 hobby="1" 传过去是字符串 "1"。
易错点:userName="userName" 传的是普通字符串,不是变量;要传变量必须加 :,即 v-bind 简写。属性名和变量名也不一定相同,但初学建议保持一致,减少调试成本。
子组件接收方式一:数组 props
为什么需要:子组件必须先声明接收哪些属性,否则父组件传过来的值不会自动成为可用的 props。
怎么想:如果只关心“接收哪些名字”,不关心类型限制,可以写成数组。数组元素是字符串,字符串就是属性名。
怎么做:
js
props: ["userName", "age"]然后在模板中写 {{ userName }}、{{ age }}。
易错点:数组写法不能做类型校验;属性名写错、大小写不一致时,父组件传了也接收不到。视频里调试时就是先确认父组件能正常显示,再确认子组件能否通过 props 拿到值。
子组件接收方式二:对象 props 做类型限制
为什么需要:数组写法太宽松,父组件如果把数字传成字符串,页面可能仍能显示,但逻辑会埋坑。
怎么想:把 props 写成对象,属性名是接收的属性,属性值是构造函数,用来限制类型。
怎么做:
js
props: {
userName: String,
age: Number
}视频里把父组件的 age 临时改成 "100",也就是传字符串,控制台会出现类型不符合的警告。这正说明对象写法在做类型检查。
易错点:类型限制不是“自动转换”。:age="age" 传数字,age="100" 传字符串。看到警告先检查父组件有没有漏写冒号,或有没有给值加引号。
子组件接收方式三:对象里再写配置对象
为什么需要:除了类型,还可能要“必填”和“默认值”。
怎么想:props 的属性值继续写成对象,用 type 指定类型,用 required 指定必填,用 default 指定默认值。
怎么做:
js
props: {
userName: { type: String },
age: { type: Number },
hobby: { type: String, required: true },
sex: { type: String, default: "男" }
}视频里删掉 hobby 的传值后出现“缺少必填属性”的警告;sex 没有传,但子组件仍显示默认值“男”。
易错点:required: true 表示父组件必须传;default 只在没有传值时生效。不要把 required 写成 require,也不要把 default 写成函数或字符串时混淆。
父组件传参的两种写法:逐个绑定与 v-bind 对象
为什么需要:属性多时逐个写比较啰嗦,Vue 提供了对象批量绑定的语法糖。
怎么想:v-bind="{userName, age, hobby:'1'}" 等价于把对象里的键值分别绑定成属性。对象里的 userName、age 是 ES6 简写,展开就是 userName: userName、age: age。
怎么做:
vue
<Child :userName="userName" :age="age" hobby="1"/>
<Child v-bind="{userName, age, hobby:'1'}" />易错点:v-bind 对象里如果写 hobby: '1',传的是字符串;如果写 hobby: 1,传的是数字。视频里特别强调:v-bind 可以传对象,但对象内部仍然遵循“加冒号传表达式、不加冒号传字符串”的规则。
调试与只读约束:子组件不能直接改 props
为什么需要:props 是父组件的数据通道,如果子组件随便改,数据来源会变得不可追踪。
怎么想:子组件可以 this.userName 读取 props,但不能直接赋值修改。
怎么做:视频里在子组件加按钮,console.log(this.userName, this.age, this.hobby, this.sex) 能打印;再执行 this.userName = 1,控制台报错,提示不能修改只读属性。
易错点:不要用“先改 props 再让父组件同步”的思路。要修改数据,应让父组件改,或后续学习自定义事件通知父组件。本节先把“接收”和“只读”记牢。
完整代码
src/App.vue:
vue
<template>
<div>
<h3>vue2->父向子传递参数通过属性进行参数的传递</h3>
<h3>父组件</h3>
<p>userName:{{userName}}</p>
<p>age:{{age}}</p>
<hr/>
<Child :userName="userName" :age="age" hobby="1"/>
<hr/>
<!-- 上面使用子组件的语法糖,效果相同 -->
<Child v-bind="{userName,age,hobby:'1'}" />
</div>
</template>
<script>
import Child from "@/components/Child";
export default {
name: "App",
components: {Child},
data(){
return {
userName:"zhangsan",
age:10
}
}
}
</script>
<style scoped>
</style>src/components/Child.vue:
vue
<template>
<div>
<h3>子组件Child</h3>
<p>props->userName->{{userName}}</p>
<p>props->age->{{age}}</p>
<p>props->hobby->{{hobby}}</p>
<p>props->sex->{{sex}}</p>
<button @click="fn">点我</button>
</div>
</template>
<script>
export default {
name: "Child",
// 1- 数组,数组内的元素是字符串,字符串即是属性的名字
// props:["userName","age"],
// 2- 对象:对象的属性即是接收的属性。可以对类型进行限制
// props:{
// // 字符串
// userName:String,
// // 数字
// age:Number
// },
// 3- 对象
props:{
userName:{
type:String
},
age:{
type:Number
},
hobby:{
type:String,
required:true
},
sex:{
type:String,
default:"男"
}
},
methods:{
fn(){
// 可以通过this获取属性,且不允许修改
console.log(this.userName,this.age,this.hobby,this.sex);
this.userName= 1;
}
}
}
</script>
<style scoped>
</style>先明确本节要解决的问题
为什么需要:父组件 App 中已经有 userName、age 等状态,子组件 Child 需要展示这些数据。若子组件直接访问父组件数据,组件就耦合了;Vue 的做法是父组件通过属性向下传,子组件声明接收。
怎么想:先看 Vue2 的写法复习:父组件写 :userName="userName",子组件用 props 接收。Vue3 的 <script setup> 中不再写 props 选项,而是用 defineProps 声明。
怎么做:父组件照常绑定属性;子组件用 defineProps 声明。易错点:不要以为父组件传了,子组件模板就能凭空使用;<script setup> 中必须先声明。
父组件 App.vue:传递参数与 Vue2 相同
为什么需要:父组件是数据源,负责把状态交给子组件展示。
怎么想:传参语法仍是 :属性名="表达式"。hobby="学习" 传的是字符串;:age="age" 传的是 ref 解包后的值。下面代码保留本节笔记内容:
vue
<template>
<h3>Vue3父向子通过属性进行参数的传递</h3>
<h3>父组件App</h3>
<p>userName:{{userName}}</p>
<p>age:{{age}}</p>
<hr/>
<Child :userName="userName" :age="age" hobby="学习"/>
<hr/>
<Child v-bind="{userName,age,hobby:'学习'}"/>
</template>
<script lang="ts" setup>
import Child from "@/components/Child.vue";
import {ref} from "vue";
const userName = ref("vue3");
const age = ref(12);
</script>
<style scoped>
</style>怎么做:v-bind="{userName,age,hobby:'学习'}" 是对象批量传参,等价于逐个写 :userName="userName" :age="age" hobby="学习"。易错点:hobby="学习" 不要再加冒号,否则会被当成变量表达式;v-bind 对象里的键名要和子组件声明一致。
子组件 Child.vue:用 defineProps 接收
为什么需要:子组件必须声明“我愿意接收哪些属性”,Vue 才能把父组件传入的值放入 props,并在模板中直接使用。
怎么想:转写稿调试时先出现“子组件模板写了 userName 却没值”的情况,原因是还没声明;在 script 中直接写 userName 也会报未定义。正确做法是 const props = defineProps({...}),模板可直接用属性名,script 中通过 props.xxx 访问。
怎么做:defineProps 不需要 import,可直接使用;支持数组、对象、对象嵌套三种写法。下面保留本节代码:
vue
<template>
<h3>子组件Child</h3>
<p>userName:{{ userName }}</p>
<p>age:{{ age }}</p>
<p>hobby:{{ hobby }}</p>
<p>sex:{{ sex }}</p>
<button @click="fn">点我</button>
</template>
<script lang="ts" setup>
// defineProps有两个特点:
// 1- 不需要引入,可以直接使用
// 2- 指定接收的属性,指定的属性可以在模板中直接使用。
// 在script标签中不能直接用。需要通过defineProps的返回值获取。
// 1- 数组,数组的元素是字符串
// defineProps(["userName", "age"]);
// 2- 对象
// defineProps({
// userName:String,
// age:Number
// })
// 3- 对象,对象的属性值还是对象
const props = defineProps({
userName:{
type:String
},
age:{
type:Number
},
hobby:{
type:String,
required:true
},
sex:{
type:String,
default:"男"
}
});
const fn = function(){
console.log(props.userName);
console.log(props.age);
console.log(props.hobby);
console.log(props.sex);
// 属性是只读的,不允许更改
// props.userName="lisi";
}
</script>
<style scoped>
</style>易错点:数组写法只声明属性名,不校验类型;对象写法可校验 type;对象嵌套写法可加 required、default。hobby 设了 required:true,父组件不传会警告;sex 有默认值“男”。转写稿中还验证了:把 age 传成字符串 "12" 会出现类型警告;props.userName="lisi" 会飘红/报错,因为 props 只读。父组件传了但子组件未声明的属性不会进 props,而会落到 attrs,本节不展开。
需求与整体思路
本节要实现的是:父组件维护 count,子组件不能直接修改父组件数据,而是通过“自定义事件”通知父组件修改。
为什么需要:Vue 的单向数据流要求 props 只读,子组件直接改父组件数据会破坏数据来源,调试时也很难追踪是谁改的。
怎么想:父组件用 @changeCount="changeCount" 监听自定义事件;子组件用 $emit("changeCount", 参数) 触发事件,把“要加多少”交给父组件。
怎么做:保留下面两份代码,先跑通“父组件按钮直接改”,再跑通“子组件按钮通过事件改”。
易错点:@changeCount 监听的是自定义事件,不是原生 DOM 事件;:changeCount="changeCount" 传的是函数 prop,两者同名但语义不同。
父组件 App.vue
vue
<template>
<div>
<h3>vue2->自定义事件</h3>
<h3>父组件</h3>
<button @click="changeCount(2)">{{count}}</button>
<hr/>
<!-- 如果为在父组件中对子组件增加原生事件,子组件是无法获取到事件信息的。点击子组件无效。可以通过增加修饰符.native -->
<!-- <Child @click.native="changeCount(4)" :count="count" :changeCount="changeCount"/>-->
<Child @changeCount="changeCount" :count="count" :changeCount="changeCount"/>
</div>
</template>
<script>
import Child from "@/components/Child";
export default {
name: "App",
components: {Child},
data(){
return {
count:100
}
},
methods:{
changeCount(count){
this.count+=count;
}
}
}
</script>
<style scoped>
</style>为什么需要:父组件是数据拥有者,count 定义在 data 中,修改逻辑也放在父组件的 changeCount 里。
怎么想:父组件既可以直接调用 changeCount(2),也可以把这个方法作为监听函数交给子组件的自定义事件。
怎么做:@changeCount="changeCount" 表示“当子组件触发 changeCount 时,调用我的 changeCount 方法”。
易错点:changeCount 方法参数是“增量”,不是新值;this.count += count 依赖传入数字,若传字符串会变成拼接。
子组件 Child.vue
vue
<template>
<div>
<h3>子组件Child</h3>
<p>count:{{count}}</p>
<!-- 子组件接收父组件传递过来的函数。当点击按钮时调用。 -->
<button @click="changeCount(3)">调用的是接收的函数:{{count}}</button>
<button @click="$emit('changeCount',5)">调用自定义事件:{{count}}</button>
<button @click="fn">调用自定义事件2:{{count}}</button>
</div>
</template>
<script>
export default {
name: "Child",
props:["count","changeCount"],
methods:{
fn(){
this.$emit("changeCount",6)
}
},
mounted(){
console.log(this)
}
}
</script>
<style scoped>
</style>为什么需要:子组件需要把“用户点击了、要加多少”告诉父组件,而不是自己改 count。
怎么想:第一种按钮调用 props 里的 changeCount 函数;第二、三个按钮用 $emit 触发自定义事件。
怎么做:$emit("changeCount",5) 中,第一个参数是事件名,第二个参数开始是父组件监听函数收到的参数。
易错点:props 是只读的,子组件里写 this.count++ 会报错;mounted 里的 console.log(this) 可用来观察实例上的 props 和 $emit,但不要靠它改数据。
执行链路与调试过程
点击父组件按钮:执行父组件 changeCount(2),count 从 100 变 102。
点击子组件“调用的是接收的函数”:子组件执行 this.changeCount(3),实际调用的是父组件传下来的函数,父组件 count 加 3。
点击“调用自定义事件”:子组件执行 $emit('changeCount',5),父组件模板上的 @changeCount 收到通知,调用 changeCount(5),父组件 count 加 5。
点击“调用自定义事件2”:fn 中执行 this.$emit("changeCount",6),效果同上。
易错点:如果父组件没写 @changeCount,子组件 $emit 不会报错,但父组件不会处理;调试时先看父组件模板是否监听,再看子组件事件名是否一致。
原生事件与 .native
笔记中注释了:
vue
<Child @click.native="changeCount(4)" :count="count" :changeCount="changeCount"/>为什么需要:有时父组件想监听子组件根元素的原生点击事件。
怎么想:在组件标签上写 @click,Vue2 默认把它当成“自定义 click 事件”监听,等子组件 $emit('click');但子组件没有触发,所以点击无效。
怎么做:加 .native,表示监听子组件根元素的原生 DOM click。
易错点:.native 监听的是根元素,不是子组件内部任意按钮;子组件内部按钮点击是否冒泡到根元素要注意结构。不要把它和自定义事件混用。
props 传函数方案不是自定义事件
子组件第一个按钮 @click="changeCount(3)" 调用的是父组件通过 :changeCount="changeCount" 传下来的函数。
为什么需要:这也是一种父子通信方式,能实现“子组件请求父组件改数据”。
怎么想:父组件把方法当属性传,子组件接收后调用。
怎么做:props:["count","changeCount"],模板中直接 changeCount(3)。
易错点:这不是自定义事件,而是函数 prop;面试或代码 review 时要能区分“传函数”和“$emit 自定义事件”。事件名建议使用小写或短横线形式,避免 HTML 模板大小写解析问题。
易错点清单
- 子组件不能直接修改
props,只能通知父组件改。 $emit的事件名必须与父组件@事件名一致。@changeCount是监听自定义事件;:changeCount是传 prop,二者可同名但不要混淆。- 组件标签上的原生事件在 Vue2 中需要
.native。 - 父组件监听函数收到的是
$emit第二个参数开始的数据。 - 调试时先看控制台报错,再看父组件模板监听、子组件
$emit名称和参数。
先看本节要解决的问题
Vue3 里父组件给子组件传函数、子组件再通知父组件改数据,是“自定义事件”的典型场景。本节笔记用 count 演示:父组件持有 count,子组件既可以通过 props 直接调用父函数,也可以通过 defineEmits 触发自定义事件,让父组件执行 setCount。转写稿里反复强调:Vue3 没有 .native 修饰符,父组件写在子组件上的事件监听,对子组件而言会作为属性处理,例如父写 @click,子组件侧对应 onClick;父写 @setCount,子组件侧对应 onSetCount。这就是理解本节代码的入口。
父组件 App.vue:监听与传参
为什么需要:count 的状态在父组件里,子组件不能直接改 props,所以父组件要把“修改能力”和“监听入口”一起交给子组件。
怎么想:父组件一方面用 :count="count" 把数据传下去,另一方面用 :setCount="setCount" 把函数作为属性传下去;同时用 @setCount="setCount" 监听子组件触发的自定义事件。@click="setCount" 则用于观察“事件监听也会映射成 onClick 属性”这一现象。
怎么做:保留笔记代码如下。
vue
<template>
<h3>Vue3父向子通过属性进行参数的传递</h3>
<h3>父组件App</h3>
<button @click="count++">{{count}}</button>
<button @click="setCount(2)">{{count}}</button>
<hr/>
<Child @setCount="setCount" @click="setCount" :count="count" :setCount="setCount"/>
</template>
<script lang="ts" setup>
// 注意:在vue3中没有了修饰符.native
// 父组件向子组件设置的事件,对于子组件而言都会作为属性来处理。
// 父@click---->子onClick
// 父@setCount---->子onSetCount
import Child from "@/components/Child.vue";
import {ref} from "vue";
const count = ref(100);
const setCount = function(n:number){
console.log(1)
count.value += n;
}
</script>
<style scoped>
</style>易错点:不要写 this.$emit;setup 中没有 this。@setCount 是监听自定义事件,:setCount 是传函数属性,两者名字相同但语义不同。转写稿里还提醒,Vue3 没有 .native,不要指望用 .native 监听子组件根元素原生事件。
子组件 Child.vue:声明事件与触发
为什么需要:子组件要主动通知父组件“请执行 setCount”,就要用 defineEmits 声明事件,拿到触发函数;同时为了演示属性映射,代码还用 defineProps 接收了 onClick、onSetCount。
怎么想:defineEmits(["setCount"]) 声明一个名为 setCount 的自定义事件,返回值命名为 $emits。模板里可以 $emits('setCount',100),脚本里可以 $emits("setCount",2)。props.setCount(3) 和 props.onSetCount(5) 则是直接调用父组件传下来的函数。
怎么做:保留笔记代码如下。
vue
<template>
<h3 @click="onSetCount(5)">子组件Child</h3>
<button @click="setCount(3)">{{count}}</button>
<button @click="$emits('setCount',100)">{{count}}</button>
</template>
<script lang="ts" setup>
import {onMounted} from "vue";
// 通过调用defineEmits可以实现vue2中的this.$emit
const props = defineProps(["count","setCount","onClick","onSetCount"]);
const $emits = defineEmits(["setCount"]);
onMounted(()=>{
// props.onClick(4);
$emits("setCount",2)
})
</script>
<style scoped>
</style>易错点:$emits 是给 defineEmits 返回值起的变量名,不是 Vue 内置的 $emit;内置写法是 $emit,但本节代码统一用 $emits。事件名必须与父组件 @setCount 一致。defineProps 里的 onSetCount、onClick 对应父组件的 @setCount、@click 映射,不要误写成 setcount 大小写不一致。TS 下如果事件参数需要类型约束,可以进一步给 defineEmits 写类型,转写稿也提到不声明时可能标红。
调试过程:点击后到底走了哪条路
为什么需要:这段代码同时保留了“函数属性调用”和“自定义事件触发”两条路,不调试容易混。
怎么想:父组件 setCount 里有 console.log(1),所以只要父函数被调用,控制台就会打印 1,并且 count 增加。
怎么做:运行后观察:点击子组件 h3,执行 onSetCount(5),等价于调用父函数,count 加 5;点击第一个按钮,执行 props.setCount(3),count 加 3;点击第二个按钮,执行 $emits('setCount',100),父组件 @setCount 收到事件,执行 setCount(100);子组件挂载时 onMounted 又触发 $emits("setCount",2),所以初始会再加 2。
易错点:不要把 $emits('setCount',100) 写成 $emits('setcount',100);不要直接改 props.count,props 只读。转写稿里还演示了父组件 @click 会映射成子组件 onClick 属性,但原生事件监听和自定义事件监听要分清:自定义事件靠 defineEmits 触发,属性函数靠 props 调用。
状态提升要解决什么问题
为什么需要:One、Two 和 App 三个组件都要显示同一个 num,而且 One 点一次要加 2,Two 点一次要加 3,App 自己也能加 1。如果把 num 定义在 One 里,Two 和 App 就拿不到;定义在 Two 里,One 和 App 也拿不到。转写稿里反复强调的判断是:数据定义在哪个子组件,另一个子组件和父组件都“够不着”。
怎么想:找这些组件的共同父级。App 是 One 和 Two 的共同父级,所以把共享状态 num 放到 App 中,这叫“状态提升”:将状态放置在共有的父级中。子组件通过 props 读取,想修改时不能直接改,而是通过自定义事件通知父级来改。
怎么做:App 定义 ref(0) 和修改函数 setNum;One、Two 接收 num,点击时 emit("setNum", 增量);App 监听同一个事件并复用 setNum。
易错点:子组件不能直接改 props;事件名要前后一致;emit 传参不要漏;父组件的 setNum 要接收参数,否则增量会丢失。
父组件 App.vue:把共享状态放在共同父级
为什么需要:App 是共同父级,只有它持有 num,One 和 Two 才能同时读到同一份数据。
怎么想:父级负责“持有状态”和“真正修改状态”;子级只负责“请求修改”。
怎么做:保留笔记代码如下:
vue
<template>
<h3>App组件</h3>
<button @click="num++">{{num}}</button>
<hr/>
<One @setNum="setNum" :num="num"/>
<hr/>
<Two @setNum="setNum" :num="num"/>
</template>
<script lang="ts" setup>
import One from "@/components/One";
import Two from "@/components/Two";
import {ref} from "vue";
const num = ref(0);
const setNum = function(v:number=1){
num.value += v;
}
</script>
<style scoped>
</style>易错点:模板里 num++ 可以用,是因为模板中 ref 会自动解包;脚本里必须写 num.value += v。setNum 的默认参数 v:number=1 只在没有传参时生效,子组件传了 2 或 3 就按传入值加。
子组件 One.vue:只读 props,用 emit 请求修改
为什么需要:One 要显示 num,但它不是状态拥有者,不能直接改父级数据。
怎么想:用 defineProps(["num"]) 接收显示值;用 defineEmits(["setNum"]) 声明可以向上发的事件;点击按钮时发事件并带增量 2。
怎么做:保留笔记代码如下:
vue
<template>
<h3>One</h3>
<button @click="changeNum">{{num}}</button>
</template>
<script setup lang="ts">
defineProps(["num"]);
const $emit = defineEmits(["setNum"]);
const changeNum = function(){
$emit("setNum",2)
}
</script>
<style scoped>
</style>易错点:不能写 num++ 直接改 props;$emit("setNum",2) 的事件名必须和父组件 @setNum 对上;defineEmits 声明后,$emit 只是这里定义的变量名,不要误以为必须叫 $emit。
子组件 Two.vue:同一模式不同增量
为什么需要:Two 和 One 共享同一个 num,但点击后希望加 3,所以事件处理模式相同,增量不同。
怎么想:继续用 props 读、emit 请求改,父组件复用 setNum。
怎么做:保留笔记代码如下:
vue
<template>
<h3>Two</h3>
<button @click="changeNum">{{num}}</button>
</template>
<script setup lang="ts">
defineProps(["num"])
const $emit = defineEmits(["setNum"]);
const changeNum = function(){
$emit("setNum",3)
}
</script>
<style scoped>
</style>易错点:复制 One 后不要忘记把 2 改成 3;事件名仍要是 setNum;父组件两个子组件都写 @setNum="setNum",不要漏掉其中一个。
调试过程与验证
转写稿里的调试思路是:先在子组件里尝试直接修改属性,会发现属性是只读的,不允许修改;于是改成“子组件发事件,父组件改状态”。验证时看三条路径:点 App 按钮,num 加 1;点 One 按钮,One 发出 setNum 并带 2,父级 setNum 执行 num.value += 2;点 Two 按钮,带 3,父级加 3。因为 One、Two 的 num 都来自同一个父级 ref,所以任意一处修改后,App、One、Two 的显示会同步更新。易错点是:父组件监听事件写成 @setNum="setNum";子组件 emit 第二个参数才是增量;不要在子组件里直接给 num 赋值。
小结
状态提升的核心是:共享状态往上放,父级持有子级读;子级想改写,用事件通知父级改;props 只读,事件上行。Vue3 中用 defineProps、defineEmits 完成同样的数据流,和 Vue2 的 props、this.$emit 思路一致。
为什么 Vue3 还需要事件总线
Vue2 里常见做法是把事件总线挂到原型上,例如 Vue.prototype.$bus = new Vue(),组件里通过 this.$bus.$emit、this.$bus.$on 通信。到了 Vue3,Vue 不再是一个带 prototype 的构造函数,转写稿里也明确说“Vue3 中没有 Vue2 中的 Vue.prototype,所以在 Vue3 中无事件总线”。这里的“无”不是不能做通信,而是不能照搬 Vue2 的原型挂载方式。
为什么需要它:当两个组件不是直接父子关系,或者层级较深时,用 props 一层层传、再用自定义事件一层层回传会比较绕。转写稿里也提到“属性一层层传还行,事件也得一层层往下传”,这时可以用事件总线做发布订阅。怎么想:创建一个独立、共享的 bus 实例,谁发布就 emit,谁订阅就 on,只要大家用的是同一个 bus 实例即可。怎么做:安装 mitt,在 src/utils/index.ts 中导出单例。易错点:不要继续找 this.$bus;不要把 bus 创建在某个组件内部,否则别的组件拿不到同一个实例。
安装与创建全局总线
先安装依赖:
shell
cnpm i mitt然后新建 src/utils/index.ts,导出一个共享的 bus:
ts
import mitt from "mitt";
export const bus = mitt();为什么这样做:mitt() 返回一个带 emit、on、off 等方法的 emitter。把它放在 utils 中导出,其他组件 import { bus } from "@/utils" 时拿到的是同一个实例。转写稿调试时强调“只有相同实例中的发布者与订阅者才可以通信”,所以单例很关键。怎么做:所有组件都从 @/utils 引入 bus,不要各自 mitt()。易错点:路径别名 @ 要配置正确;事件名要统一,比如本节统一用 "changenum",不要一处写 changenum、另一处写 changeNum。
发布事件:Two.vue
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>为什么需要:Two 是事件发布方,点击时把 200 或 300 发出去。怎么想:emit(事件名, 参数) 的第二个参数就是订阅方收到的值。怎么做:第一个 h3 调用 changeNum,内部 bus.emit("changenum",200);第二个 h3 直接在模板中 bus.emit('changenum',300)。易错点:模板中能访问 bus,是因为 <script setup> 中导入了它;事件名必须和订阅方一致;参数类型在 TS 中可能被推断为 unknown,接收方要做类型处理。
订阅事件:One.vue 与 App.vue
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>App.vue 也订阅,并修改自己的 num:
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>为什么这样写:One 只负责监听和打印,App 监听后把 num 改成事件传来的值,从而验证总线通信。怎么想:订阅要发生在发布之前,否则点击时还没有监听者,消息会丢。转写稿调试时也强调“先有订阅者,后有发布者”,所以放在 onMounted 中注册。怎么做:bus.on("changenum", callback),回调参数 v 就是 emit 传来的 200 或 300。易错点:如果 bus.on 多次执行而没 off,会重复触发;bus.off("changenum") 可取消,但注意不要误删其他组件的监听;v as number 是类型断言,运行时要保证发布方传的确实是数字;不要在 Vue3 里写 this.$bus。
调试过程与易错点
转写稿里的调试思路可以还原为:先确认 bus 是同一个实例,再确认订阅在发布之前。若点击 Two 没反应,先看 Two 是否真的执行了 bus.emit,再看 App、One 是否在 onMounted 里执行了 bus.on,最后看事件名是否完全一致。mitt 的 on 可以接收多个参数,转写稿也提到“传对象/传多个参数”都可以,但本节代码只传一个值,接收方按一个参数处理即可。易错点还包括:在组件卸载时没有 off,反复进入页面会叠加监听;把 bus 当成 Vue2 的 this.$bus;父子组件明明可以用 props/自定义事件,却全部塞进总线,导致数据流向难追踪。实际项目中,跨层级、兄弟组件临时通信可用 mitt,但要有清晰的事件名和取消订阅习惯。
课堂画面






代码示例
代码一:Vue3 父向子通过属性传参(defineProps)
src/App.vue(父组件)
vue
<template>
<h3>Vue3父向子通过属性进行参数的传递</h3>
<h3>父组件App</h3>
<p>userName:{{userName}}</p>
<p>age:{{age}}</p>
<hr/>
<Child :userName="userName" :age="age" hobby="学习"/>
<hr/>
<Child v-bind="{userName,age,hobby:'学习'}"/>
</template>
<script lang="ts" setup>
import Child from "@/components/Child.vue";
import {ref} from "vue";
const userName = ref("vue3");
const age = ref(12);
</script>src/components/Child.vue(子组件)
vue
<template>
<h3>子组件Child</h3>
<p>userName:{{ userName }}</p>
<p>age:{{ age }}</p>
<p>hobby:{{ hobby }}</p>
<p>sex:{{ sex }}</p>
<button @click="fn">点我</button>
</template>
<script lang="ts" setup>
// defineProps有两个特点:
// 1- 不需要引入,可以直接使用
// 2- 指定接收的属性,指定的属性可以在模板中直接使用。
// 在script标签中不能直接用。需要通过defineProps的返回值获取。
const props = defineProps({
userName:{
type:String
},
age:{
type:Number
},
hobby:{
type:String,
required:true
},
sex:{
type:String,
default:"男"
}
});
const fn = function(){
console.log(props.userName);
console.log(props.age);
console.log(props.hobby);
console.log(props.sex);
// 属性是只读的,不允许更改
// props.userName="lisi";
}
</script>要点解释与易错点:
defineProps是编译器宏,不需要 import,只能在<script setup>中使用;它声明的属性在模板里可直接用,但在<script>里必须通过返回值props.xxx取,直接写userName会报 “未定义”。- props 三种写法:数组
defineProps(["userName","age"])(只声明名字,不限制类型)、对象(userName:String,限制类型)、对象套对象(type/required/default),笔记中第三种最常用。 - 传值写法差异:
:age="age"是表达式传 Number;若写成age="12"传的是字符串,type:Number会给出类型警告,这是最常见的易错点。 hobby是required:true且没有默认值,父组件不传就会告警;sex有default:"男",不传也能正常渲染。v-bind="{userName,age,hobby:'学习'}"与逐条:userName :age hobby完全等价(ES6 简写展开成对象),注意hobby:'学习'是字符串值,userName是变量简写。- props 是只读的,子组件里
props.userName = "lisi"会被警告拦截;要改数据必须由父组件改(或用自定义事件通知父组件)。
代码二:Vue3 自定义事件 + 状态提升
src/App.vue(共同父级,持有共享状态)
vue
<template>
<h3>App组件</h3>
<button @click="num++">{{num}}</button>
<hr/>
<One @setNum="setNum" :num="num"/>
<hr/>
<Two @setNum="setNum" :num="num"/>
</template>
<script lang="ts" setup>
import One from "@/components/One";
import Two from "@/components/Two";
import {ref} from "vue";
const num = ref(0);
const setNum = function(v:number=1){
num.value += v;
}
</script>src/components/One.vue
vue
<template>
<h3>One</h3>
<button @click="changeNum">{{num}}</button>
</template>
<script setup lang="ts">
defineProps(["num"]);
const $emit = defineEmits(["setNum"]);
const changeNum = function(){
$emit("setNum",2)
}
</script>src/components/Two.vue
vue
<template>
<h3>Two</h3>
<button @click="changeNum">{{num}}</button>
</template>
<script setup lang="ts">
defineProps(["num"])
const $emit = defineEmits(["setNum"]);
const changeNum = function(){
$emit("setNum",3)
}
</script>要点解释与易错点:
- 状态提升:
num被 One、Two、App 三处共用,所以状态放在共同父级 App 中;放在 One 里 Two 拿不到,放在 Two 里 App 拿不到。 - 子组件不能直接改
num(props 只读),只能$emit("setNum", 2)通知父组件;父组件用@setNum="setNum"监听,setNum的形参v就是 emit 传出来的值。 defineEmits同样不需要 import,用来替代 Vue2 的this.$emit;不声明就直接$emit会有警告。- 事件名建议与父组件
@setNum保持一致(setNum/set-num都行,但要前后统一),大小写不一致是最常见的“点了没反应”原因。 - Vue3 里没有
.native修饰符:父组件写在子组件上的事件(包括@click)对子组件而言都会作为属性处理,即父@click→ 子onClick、父@setNum→ 子onSetNum。 setNum(v:number = 1)给了默认值,所以父组件自己@click="num++"时不传参也不会变成 NaN。
代码三:Vue3 用 mitt 实现事件总线
安装:cnpm i mitt
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>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>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>要点解释与易错点:
- Vue3 里
Vue是一个普通对象,没有Vue.prototype,所以 Vue2 那种Vue.prototype.$bus = new Vue()的事件总线写法不可用,官方建议用 mitt 这类第三方库。 bus是模块级单例:utils/index.ts里export const bus = mitt(),所有组件import {bus} from "@/utils"拿到的都是同一个实例,这是能跨组件通信的前提。- 发布
bus.emit("changenum", 200),订阅bus.on("changenum", (v)=>{...}),事件名要严格一致;v的类型在 TS 下是unknown,所以要v as number才能赋给num.value。 - 订阅方要在发布之前注册:App 与 One 都在
onMounted中bus.on,而 Two 的 emit 由点击触发,顺序上没问题;若在 setup 顶层同步 emit 就可能收不到。 - 注意取消订阅
bus.off("changenum")(笔记中先注释掉),组件卸载后不移除监听会造成重复回调/内存泄漏。 - 对比代码二:属性 + 自定义事件在层级深时要一层层往下传函数,很繁琐;事件总线可以让任意两个组件直接通信,App 收到
changenum后改num,再通过 props 下发,One/Two 的显示会同步更新。
常见错误
Vue3 子组件 script 里直接写
userName报 “is not defined”- 原因:
defineProps声明的属性只在模板中可直接用,<script setup>里没有这个变量。 - 修法:
const props = defineProps({...}),脚本内一律走props.userName。
- 原因:
模板里写
$emits('setCount', 100)报错或事件不触发- 原因:
defineEmits的返回值不是全局魔法变量,必须赋值给变量才能用(笔记里$emits只是自定义变量名,不是内置 API)。 - 修法:
const emit = defineEmits(['setCount']),模板写@click="emit('setCount', 100)"。
- 原因:
子组件里
this.userName = 1/props.userName = 'lisi'报只读警告- 原因:props 是单向数据流,只读。
- 修法:子组件
emit('update', 新值)让父组件改;确实需要本地编辑时用ref(props.x)拷一份本地状态。
父组件传
age="12"(漏冒号),子组件type: Number报警告- 原因:不加
:传的是字符串"12";同理:hobby="1"会把字符串变成数字。 - 修法:数字/布尔/对象/数组一律加
:;纯字符串才用静态写法。
- 原因:不加
父组件给子组件加
@click想监听点击,点了没反应- 原因:组件上的
@click默认是自定义事件,不是原生事件;Vue3 中它会以onClick形式落到子组件的 props/attrs 里。 - 修法:Vue2 加
.native;Vue3 在子组件内部@click="$emit('click')"显式抛出,或用v-bind="$attrs"透传到根元素。
- 原因:组件上的
hobby声明了required: true但父组件没传,控制台一串警告- 原因:必填校验生效,缺省不会自动补。
- 修法:父组件补传;或去掉
required,改用default(两者别混用)。
mitt 写了
bus.on但发布后收不到- 原因:订阅晚于发布(在
onMounted里订阅,兄弟组件更早就 emit 了);或卸载时没off导致重复回调/内存泄漏。 - 修法:订阅提到
onMounted之前或确保时序,onUnmounted里bus.off('事件名'),并全项目共用utils/index.ts导出的同一个 bus 实例。
- 原因:订阅晚于发布(在
v-bind="{userName, age}"传过去子组件收不到- 原因:对象简写要求 key 与变量同名,子组件按 key 名匹配 props。
- 修法:保证 key 与 props 名一致,否则显式写
{userName: name}并在子组件按userName接收。
练习
把
App.vue里:age="age"改成age="12",观察控制台警告文案,再用两种方式修复(改模板 / 改 props 校验),并说明哪种更合理。给
Child.vue新增title(String,默认"提示")和visible(Boolean,必填)两个 prop,父组件只传其中一个,把警告补全到零。用状态提升实现:
One.vue点一次 +2,Two.vue点一次 −3,App.vue显示当前值,父组件自己也能 +1。要求子组件内不出现任何直接修改 props 的代码。用
defineEmits实现:子组件里放一个<input>,每次输入把值 emit 给父组件,父组件展示“当前输入:xxx”。再对比第 3 题,说明什么场景用 emit、什么场景用状态提升。用 mitt 改造第 3 题:
One和Two不再通过App中转,直接向 bus 发布事件,App订阅并更新。要求补上组件卸载时的off,并用console.log证明没有重复订阅。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | vue2父向子通过属性传递参数 | 19:35 |
| 2 | Vue3父向子通过属性进行参数的传递 | 18:53 |
| 3 | 注意 | 06:51 |
| 4 | vue2自定义事件 | 15:21 |
| 5 | vue3自定义事件 | 18:03 |
| 6 | 状态提升 | 09:43 |
| 7 | 事件总线 | 21:13 |
小结
- 传参优先用 props,改数据优先用 emit,方向不要反:props 下行、事件上行。
- props 校验一律用对象式,
required与default二选一,别给同一个属性同时写。 - Vue3 的
defineProps/defineEmits免引入但必须接收返回值,模板可用、脚本必须走返回值。 - Vue3 组件上的
@xxx对子组件而言就是onXxx属性,.native已不存在,透传要靠$attrs。 - 兄弟组件共享状态先想“状态提升到最近公共父级”,这比任何总线方案都好调试。
- mitt 只在跨层级、跨路由、无共同父级时才用,且必须成对
on/off,bus 实例全项目唯一。 - 子组件永远不要直接改 props,需要本地编辑就
ref拷贝一份,再通过 emit 回写。 - 所有类型/必填警告都应在开发阶段清零,不要带着黄字上线。