Skip to content

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,但要有清晰的事件名和取消订阅习惯。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

代码一: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 的显示会同步更新。

常见错误 ​

  1. Vue3 子组件 script 里直接写 userName 报 “is not defined”

    • 原因:defineProps 声明的属性只在模板中可直接用,<script setup> 里没有这个变量。
    • 修法:const props = defineProps({...}),脚本内一律走 props.userName。
  2. 模板里写 $emits('setCount', 100) 报错或事件不触发

    • 原因:defineEmits 的返回值不是全局魔法变量,必须赋值给变量才能用(笔记里 $emits 只是自定义变量名,不是内置 API)。
    • 修法:const emit = defineEmits(['setCount']),模板写 @click="emit('setCount', 100)"。
  3. 子组件里 this.userName = 1 / props.userName = 'lisi' 报只读警告

    • 原因:props 是单向数据流,只读。
    • 修法:子组件 emit('update', 新值) 让父组件改;确实需要本地编辑时用 ref(props.x) 拷一份本地状态。
  4. 父组件传 age="12"(漏冒号),子组件 type: Number 报警告

    • 原因:不加 : 传的是字符串 "12";同理 :hobby="1" 会把字符串变成数字。
    • 修法:数字/布尔/对象/数组一律加 :;纯字符串才用静态写法。
  5. 父组件给子组件加 @click 想监听点击,点了没反应

    • 原因:组件上的 @click 默认是自定义事件,不是原生事件;Vue3 中它会以 onClick 形式落到子组件的 props/attrs 里。
    • 修法:Vue2 加 .native;Vue3 在子组件内部 @click="$emit('click')" 显式抛出,或用 v-bind="$attrs" 透传到根元素。
  6. hobby 声明了 required: true 但父组件没传,控制台一串警告

    • 原因:必填校验生效,缺省不会自动补。
    • 修法:父组件补传;或去掉 required,改用 default(两者别混用)。
  7. mitt 写了 bus.on 但发布后收不到

    • 原因:订阅晚于发布(在 onMounted 里订阅,兄弟组件更早就 emit 了);或卸载时没 off 导致重复回调/内存泄漏。
    • 修法:订阅提到 onMounted 之前或确保时序,onUnmounted 里 bus.off('事件名'),并全项目共用 utils/index.ts 导出的同一个 bus 实例。
  8. v-bind="{userName, age}" 传过去子组件收不到

    • 原因:对象简写要求 key 与变量同名,子组件按 key 名匹配 props。
    • 修法:保证 key 与 props 名一致,否则显式写 {userName: name} 并在子组件按 userName 接收。

练习 ​

  1. 把 App.vue 里 :age="age" 改成 age="12",观察控制台警告文案,再用两种方式修复(改模板 / 改 props 校验),并说明哪种更合理。

  2. 给 Child.vue 新增 title(String,默认 "提示")和 visible(Boolean,必填)两个 prop,父组件只传其中一个,把警告补全到零。

  3. 用状态提升实现:One.vue 点一次 +2,Two.vue 点一次 −3,App.vue 显示当前值,父组件自己也能 +1。要求子组件内不出现任何直接修改 props 的代码。

  4. 用 defineEmits 实现:子组件里放一个 <input>,每次输入把值 emit 给父组件,父组件展示“当前输入:xxx”。再对比第 3 题,说明什么场景用 emit、什么场景用状态提升。

  5. 用 mitt 改造第 3 题:One 和 Two 不再通过 App 中转,直接向 bus 发布事件,App 订阅并更新。要求补上组件卸载时的 off,并用 console.log 证明没有重复订阅。

原视频对照 ​

#视频时长
1vue2父向子通过属性传递参数19:35
2Vue3父向子通过属性进行参数的传递18:53
3注意06:51
4vue2自定义事件15:21
5vue3自定义事件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 回写。
  • 所有类型/必填警告都应在开发阶段清零,不要带着黄字上线。