3.2 emit 儿子给老爸发信号

6 阅读6分钟

阶段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 里,这个功能被删除了! 官方建议:

  1. 父子通信用 Props/Emit。
  2. 跨级通信用 Provide/Inject。
  3. 全局状态用 Pinia。
  4. 实在想要 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
}>()

这能极大减少写错参数的概率。

傻瓜口令:"类型检查更严格,一字不差才放行"