阶段1:上报汇报
目标:理解为什么需要 emit
概念: 还记得 Props 的铁律吗?"爸爸给的只能看,千万不能动手改"。 那如果子组件真的想改数据(比如想花钱)怎么办? 唯一的办法:给爸爸打电话(发送事件),让爸爸自己改。
傻瓜口令:"儿子不能改,必须要汇报"
阶段2:注册喇叭 (声明)
目标:使用 defineEmits 注册我要喊什么
说明:
想喊话,先得去领个喇叭,并登记你要喊什么口号。
注意:defineEmits 也是宏,不需要 import。
子组件 (Son.vue) 代码:
<script setup>
// 声明:我要申请一个喇叭,专门喊 'pay' 这个口号
// 返回值 emit 就是那个喇叭
const emit = defineEmits(['pay'])
</script>
傻瓜口令:"领个大喇叭,登记喊什么"
阶段3:开始喊话 (触发)
目标:在 JS 中触发事件
说明:
拿到 emit (喇叭) 后,随时可以喊。
子组件 (Son.vue) 代码:
<script setup>
const emit = defineEmits(['pay'])
const buyToy = () => {
console.log('我要开始喊了!')
// 📢 喊口号:'pay'
emit('pay')
}
</script>
<`template`>
<button @click="buyToy">买玩具</button>
</template>
傻瓜口令:"按下发射键,信号传出去"
阶段4:爸爸听到 (监听)
目标:父组件用 @ 监听子组件的喊话
说明:
爸爸在用子组件标签时,要竖起耳朵(@),听到对应的口号(pay),就执行操作。
父组件 (App.vue) 代码:
<script setup>
const giveMoney = () => {
console.log('爸爸听到了:这就打钱!')
}
</script>
<`template`>
<!-- @pay 意思是:监听到 'pay' 事件时,执行 giveMoney -->
<Son @pay="giveMoney" />
</template>
傻瓜口令:"爸爸竖耳朵,听到马上动"
阶段5:携带情报 (Payload)
目标:喊话的时候捎带数据(参数)
说明: 光喊 "pay" 不行,还得告诉爸爸要多少钱。
子组件 (Son.vue):
// 📢 喊:pay,带参数:100
emit('pay', 100)
父组件 (App.vue):
<script setup>
// 这里的 money 会自动接收子组件传来的 100
const giveMoney = (money) => {
console.log(`收到请求,转账 ${money} 元`)
}
</script>
<`template`>
<Son @pay="giveMoney" />
</template>
傻瓜口令:"喊话带包裹,情报送得准"
阶段6:当场喊话 ($emit)
目标:在模板中直接用 $emit,不写 JS 函数
场景:
如果逻辑很简单(比如只是单纯转发一下),不用特意写个 buyToy 函数。
在 HTML 模板里,系统内置了一个 $emit 变量,跟 emit 函数一样用。
子组件 (Son.vue) 代码:
<`template`>
<!-- 直接写在 @click 里,方便快捷 -->
<button @click="$emit('pay', 50)">
直接买个便宜的
</button>
</template>
注意:虽然模板里直接用了,script setup 里最好还是要 defineEmits 声明一下,虽然不声明在 Vue3 也能跑(Attributes Fallthrough),但声明了才是规范的好孩子。
傻瓜口令:"不用写脚本,标签直接喊"
阶段7:升级表格 (Object Syntax)
目标:使用对象语法替代数组语法
说明:
基础阶段我们用数组 ['pay'],这很简单。
但如果你想对事件做更多控制(比如验证参数),就得把它变成对象。
代码:
<script setup>
// 以前:defineEmits(['pay'])
// 现在:属性名是事件名
const emit = defineEmits({
// 先写 null,表示我不做检查,跟数组写法一样
pay: null,
delete: null
})
</script>
傻瓜口令:"数组换对象,功能更强大"
阶段8:安检通道 (Validator)
目标:给事件添加验证函数
说明: 并不是无论什么数据都能往外扔。 比如:可以抛出 'pay' 事件,但金额必须大于 0。
代码:
<script setup>
const emit = defineEmits({
// pay 事件触发时,会运行这个函数
// money 就是你 emit('pay', 100) 传进来的 100
pay: (money) => {
// 检查:如果金额小于等于0,拦截!
if (money <= 0) {
return false // ❌ 验证失败
}
return true // ✅ 验证通过
}
})
</script>
傻瓜口令:"出口设安检,参数查一查"
阶段9:放行条 (Return True)
目标:理解验证函数的返回值必须是 Boolean
说明:
Vue 规定:验证函数必须返回 true(通过)或 false(不通过)。
如果你什么都不返回(undefined),Vue 会认为验证无效。
代码:
submit: (payload) => {
// 必须显式 return true
return true
}
傻瓜口令:"通过回个真,才能往下走"
阶段10:警报响了 (Validation Failed)
目标:理解验证失败会发生什么
操作:
触发一个不合法的事件,比如 emit('pay', -50)。
结果:
你可能会以为事件根本发不出去?错!
Vue 不会阻止事件发送,父组件依然能收到。
但是,Vue 会在控制台给你一个黄色警告:Invalid event arguments: event validation failed for event "pay".
意义: 这是一个开发时的辅助工具,提醒你代码逻辑写错了,而不是运行时的防火墙。
傻瓜口令:"检查不通过,后台亮红灯"
阶段11:烤串命名 (Naming Convention)
目标:事件名尽量使用 kebab-case
说明:
虽然 emits 支持驼峰 submitForm,但在 HTML 模板里监听时可能会引起混淆。
为了不给自己找麻烦,所有事件名统一用烤串(小写+横杠)。
代码:
// ✅ 推荐
emit('login-success') --> @login-success
// ❌ 不推荐 (虽然能用)
emit('loginSuccess')
傻瓜口令:"事件像烤串,小写加横杠"
阶段12:连环炮 (Multiple Arguments)
目标:一次传递多个参数
操作:
// 子组件:扔出三个包裹
emit('update-info', 'Dyl', 18, 'Teacher')
父组件:
<!-- 必须按顺序接住 -->
<Son @update-info="(name, age, job) => handle(name, age, job)" />
傻瓜口令:"一次传多个,逗号隔开写"
阶段13:暗号对接 (update:xxx)
目标:学习 Vue 社区约定俗成的命名模式
说明:
当你希望通过事件通知父组件更新某个 Props 时,事件名最好叫 update:prop名字。
这不是强制的,但这是一个非常强烈的暗示(也是 v-model 的原理)。
代码:
// 告诉父组件:请把 title 更新一下
emit('update:title', '新标题')
父组件看到 update: 开头,就知道你是想改数据。
傻瓜口令:"更新加冒号,格式要记牢"
阶段14:空头支票 (Null Validator)
目标:对象语法的混合使用
说明:
如果你用了对象语法(为了验证某些事件),但对于其他简单事件不想验证,直接给 null 即可。
代码:
defineEmits({
// 严格检查
check: (v) => v > 10,
// 随便,不检查
close: null
})
傻瓜口令:"只想占个位,不做检查官"
阶段15:水桶接力 (Forwarding)
目标:解决"Vue 事件不冒泡"的问题
场景:
孙子组件(GrandSon)喊了一声 "help"。
父组件(Son)能听到。
爷爷组件(GrandPa)听不到!因为 Vue 的自定义事件不像 DOM 事件那样自动往上传。
解决: 父组件必须听到后,再喊一遍。
代码 (Son.vue):
<`template`>
<!-- 听到孙子喊 help,我也喊 help -->
<GrandSon @help="$emit('help')" />
</template>
傻瓜口令:"孙子喊话爸爸传,爷爷才能听得见"
阶段16:自动握手 (v-model Principle)
目标:理解 v-model 到底帮我们做了什么
说明:
父组件写 <Son v-model="count" />。
实际上等于写了:
<Son :modelValue="count" @update:modelValue="val => count = val" />
子组件写法:
<script setup>
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const update = () => {
// 必须发这个名字的事件
emit('update:modelValue', 999)
}
</script>
傻瓜口令:"双向绑定最省心,不用手写收发信"
阶段17:优雅中介 (Writable Computed)
目标:封装 v-model 的最佳实践
痛点:
Props 不能改,每次都要写 emit 很麻烦。
解法:创建一个"可写计算属性",读的时候拿 Props,写的时候发 Emit。
代码:
<script setup>
import { computed } from 'vue'
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
// 中介
const proxy = computed({
get() {
return props.modelValue // 读爸爸的
},
set(val) {
emit('update:modelValue', val) // 写的时候通知爸爸
}
})
</script>
<`template`>
<!-- 直接绑定这个中介,舒服! -->
<input v-model="proxy" />
</template>
傻瓜口令:"计算属性做代理,读写分离最优雅"
阶段18:八爪鱼 (Multiple v-models)
目标:同时绑定多个数据
场景:
一个组件要同时修改标题和内容。
<Son v-model:title="t" v-model:content="c" />
子组件:
<script setup>
defineProps(['title', 'content'])
const emit = defineEmits(['update:title', 'update:content'])
const changeAll = () => {
emit('update:title', '新标题')
emit('update:content', '新内容')
}
</script>
傻瓜口令:"多个数据都能绑,冒号后面加名字"
阶段19:开机信号 (Lifecycle Emit)
目标:组件加载完毕主动汇报
场景: 地图组件加载很慢,加载好了需要告诉父组件"我准备好了"。
代码:
<script setup>
import { onMounted } from 'vue'
const emit = defineEmits(['ready'])
// 组件挂载完成后触发
onMounted(() => {
emit('ready')
})
</script>
傻瓜口令:"组件加载发信号,通知爸爸准备好"
阶段20:自动汇报 (Watch Emit)
目标:不需要用户点按钮,数据变了就自动汇报
场景: 用户在输入框打字,我想实时把内容传给父组件(不用 v-model 的情况下)。
代码:
<script setup>
import { ref, watch } from 'vue'
const text = ref('')
const emit = defineEmits(['change'])
// 只要 text 变了,就自动 emit
watch(text, (newVal) => {
emit('change', newVal)
})
</script>
傻瓜口令:"数据一变自动发,不用按钮去触发"
阶段21:广播拆除 (No EventBus)
目标:了解 Vue 3 的重大变化——移除了 $on, $off, $once
说明:
在 Vue 2 里,我们可以搞一个全局的 EventBus(大喇叭),任何组件都能喊,任何组件都能听。
但在 Vue 3 里,这个功能被删除了!
官方建议:
- 父子通信用 Props/Emit。
- 跨级通信用 Provide/Inject。
- 全局状态用 Pinia。
- 实在想要 EventBus,请用第三方库
mitt。
傻瓜口令:"全村广播已取消,私聊或者用状态"
阶段22:幽灵点击 (Native vs Custom)
目标:解决 @click 到底是在监听谁的问题
场景:
父组件:<Son @click="log" />
子组件:defineEmits(['change']) (注意:没有声明 click)
结果:
Vue 会把这个 @click 当作原生 DOM 事件,直接监听子组件根元素(div)的点击。
这可能不是你想要的!你可能只想在子组件内部某处触发 emit('click')。
修正:
一旦在子组件里写了 defineEmits(['click']),父组件的 @click 就只听子组件喊话,不再监听根元素的点击了。
傻瓜口令:"点击事件要声明,否则就是点盒子"
阶段23:全能胶水 ($attrs includes listeners)
目标:Vue 3 的重大合并——$listeners 没了
说明:
在 Vue 2,属性在 $attrs,事件监听器在 $listeners。
在 Vue 3,所有东西可以合并了!
父组件传来的 @change,在子组件看来就是一个叫 onChange 的函数,也放在 $attrs 里。
应用:
当你写 <button v-bind="$attrs"> 时,不仅传递了 class/style,连父组件绑定的 @click、@mouseover 都一起传过去了。
傻瓜口令:"属性监听不分家,都在 attrs 是一家"
阶段24:魔法道具 (v-model Modifiers)
目标:实现自定义的 v-model 修饰符,如 v-model.capitalize (自动首字母大写)
原理:
Vue 会把修饰符打包成一个 props 传给你,名字固定叫 modelModifiers。
代码:
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: { default: () => ({}) } // 📦 接收修饰符包裹
})
const emit = defineEmits(['update:modelValue'])
const emitValue = (val) => {
// 拆开包裹看看,有没有 .capitalize
if (props.modelModifiers.capitalize) {
val = val.charAt(0).toUpperCase() + val.slice(1)
}
emit('update:modelValue', val)
}
</script>
傻瓜口令:"修饰符是小道具,收到之后自己变"
阶段25:专属道具 (Named Modifiers)
目标:给具名 v-model 加修饰符,如 v-model:title.trim
原理:
Props 的名字变了。
v-model:title 对应的修饰符 Prop 叫 titleModifiers。
(规律:arg + Modifiers)
代码:
defineProps({
title: String,
titleModifiers: { default: () => ({}) } // 📦 专属包裹
})
傻瓜口令:"指定名字加修饰,道具名字跟着变"
阶段26:函数快递 (Callback Props)
目标:另一种通信风格——通过 Prop 传函数
说明:
除了 emit,我们其实可以直接把父组件的一个函数作为 Prop 传给子组件,子组件直接调用它。
这在 React 中很常见。Vue 也支持,有时为了更强的类型检查,会有人这么写。
代码:
<!-- 父组件 -->
<Son :on-success="handleSuccess" />
<!-- 子组件 Script -->
const props = defineProps(['onSuccess'])
// 直接调函数,跟 emit 效果一样,但没有事件机制
props.onSuccess('data')
傻瓜口令:"不喊话也能办事,函数传进 Props 里"
阶段27:即时反应 (Synchronous)
目标:理解 emit 是同步的
误区:
有人以为 emit 像发短信,发完我就不管了,继续干我的事。
真相:
emit 像打电话。你这边 emit 一响,父组件那边的函数立刻就把电话接起来执行了。
等父组件函数执行完,子组件 emit 后面的代码才会继续跑。
验证:
console.log('1. 子喊话')
emit('call')
console.log('3. 子结束')
父组件:
handleCall() { console.log('2. 爸在忙') }
输出顺序:1 -> 2 -> 3
傻瓜口令:"喊话就是打电话,爸爸说完我再说"
阶段28:老式电话 (Setup Context)
目标:应对非 <script setup> 的老代码
场景:
如果你看别人的源码,发现用了 setup() 函数。
代码:
export default {
emits: ['pay'],
// context 里装着 emit
setup(props, context) {
function buy() {
context.emit('pay') // 👈 在这里
}
return { buy }
}
}
傻瓜口令:"老式写法哪里找,上下文里翻一翻"
阶段29:严格安检 (TS Typing)
目标:(选修) TypeScript 下的严格写法
说明: 为了更安全,我们可以用 TS 语法严格规定:'change' 事件必须带一个 string 参数,带别的就报错。
代码:
// <script setup lang="ts">
const emit = defineEmits<{
(e: 'change', id: number): void
(e: 'update', value: string): void
}>()
这能极大减少写错参数的概率。
傻瓜口令:"类型检查更严格,一字不差才放行"