Випромінювання та користувацькі події у Vue.js
Emits - це спосіб, яким дочірні компоненти Vue надсилають кастомні події вгору до батьківських, виконуючи зворотній напрямок до props.
Теорія
TL;DR
- Дочірній компонент схожий на кнопку Submit у формі: він не знає, що батько зробить після натискання, він просто кричить "відправлено!"
- Props течуть вниз (батько контролює дані); emits течуть вгору (дитина ініціює сповіщення)
- Emits - для зв'язку дитина-батько; Pinia - для сиблінгів;
provide/inject- для глибоких дерев компонентів defineEmitsу<script setup>оголошує події і вмикає перевірку TypeScriptv-modelна компоненті компілюється в:modelValue+@update:modelValueпід капотом
Швидкий приклад
<!-- Child.vue -->
<script setup>
const emit = defineEmits(['count-up'])
const handleClick = () => emit('count-up', 1)
</script>
<template>
<button @click="handleClick">+1</button>
</template><!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
const total = ref(0)
</script>
<template>
<Child @count-up="total += $event" />
<p>Total: {{ total }}</p>
</template>Натискаєш кнопку - total збільшується на 1. Дочірній компонент не торкається total напряму. Він кидає сигнал. Батько вирішує, що з ним робити.
Props вниз, події вгору
Props задають один напрямок: батько надсилає дані, дитина їх читає. Emits ідуть у зворотний бік. Коли дочірньому компоненту треба, щоб батько щось зробив, він кидає подію з payload-ом. Батько обробляє її. Це тримає компоненти незалежними - той самий дочірній компонент працює з будь-яким батьком, який вміє обробляти його події.
Коли використовувати emits
- Форма у дочірньому компоненті відправлена, батько зберігає через API: emit
saveз даними форми - Кнопка закрити в модалці, батько приховує overlay: emit
close - Вибір рядка в таблиці, батько оновлює стан вибору: emit
selection-change - Два сиблінги мають спільний стан: Pinia, не emits
- Глибоко вкладений компонент оновлює кореневий стан: спочатку розглянь
provide/injectабо store
TypeScript декларації (оголошення типів)
У TypeScript-проектах оголошуй точні типи payload через generic-синтаксис:
<script setup lang="ts">
const emit = defineEmits<{
save: [profile: { name: string; avatar: string }]
cancel: []
search: [query: string, page: number]
}>()
emit('save', { name: 'Alice', avatar: '/img.jpg' }) // перевірка типів
emit('save', 'wrong') // помилка типів
emit('cancel') // без payload
</script>IDE ловить неправильні типи аргументів під час написання, а не в рантаймі. Для простих проектів масивний синтаксис defineEmits(['save', 'cancel']) теж краще, ніж нічого.
v-model під капотом
v-model на компоненті компілюється в пару prop плюс emit. Prop називається modelValue, emit - update:modelValue:
<!-- CustomInput.vue -->
<script setup>
defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<input
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template><!-- Parent.vue -->
<template>
<CustomInput v-model="username" />
<!-- те саме що: <CustomInput :modelValue="username" @update:modelValue="username = $event" /> -->
</template>Декілька v-model на одному компоненті теж можливі. Кожен потребує свій named prop і відповідний emit update:назваProp:
<!-- UserForm.vue -->
<script setup>
defineProps<{ firstName: string; lastName: string }>()
defineEmits<{
'update:firstName': [value: string]
'update:lastName': [value: string]
}>()
</script><!-- Parent.vue -->
<template>
<UserForm v-model:firstName="first" v-model:lastName="last" />
</template>Як Vue обробляє emits
Коли ти викликаєш emit('save', payload), Vue шукає слухача, оголошеного як @save на vnode компонента. Він передає payload як $event до обробника. DOM-події тут немає - це власна система подій Vue, не addEventListener. Тому emits не спливають (bubble) по дереву компонентів. Подія, кинута в онуку, не дійде до дідуся, якщо не прокинути її вручну або не використати store.
Поширені помилки
Не оголошувати emits через defineEmits:
// Погано - Vue 3.3+ попереджає "Extraneous non-emits event listeners"
const emit = () => {} // без defineEmits
// Добре
const emit = defineEmits(['save', 'cancel'])Без defineEmits втрачаєш TypeScript-валідацію і dev-попередження Vue стають галасливими.
Мутувати prop напряму в дочірньому компоненті:
// Погано - Vue попередить, і зміна відкотиться при наступному рендері батька
props.user.name = 'Bob'
// Добре - кинь зміну, нехай батько оновить
emit('update:user', { ...props.user, name: 'Bob' })Забути $event в inline-обробнику:
Переглядаючи Vue-код на практиці, ця помилка зустрічається частіше за всі інші. Подія кидається, обробник запускається, але payload зникає без жодного повідомлення.
<!-- Погано - payload загублено -->
<Child @save="console.log('saved')" />
<!-- Добре - $event містить payload -->
<Child @save="handleSave($event)" />
<!-- або передай посилання на функцію напряму -->
<Child @save="handleSave" />Emit у циклі:
// Погано - кидає N окремих подій, кожна може тригерити перерендер
items.forEach(item => emit('add', item))
// Добре - батчинг в одному emit
emit('add-batch', items)CamelCase назви подій у шаблонах:
// Оголошено camelCase
defineEmits(['updateUser'])
// Конвенція шаблонів - kebab-case
// @update-user може не відповідати 'updateUser' у всіх випадкахДотримуйся kebab-case або паттерну update:назваProp послідовно.
Де зустрічається
- Vuetify
v-text-fieldкидаєupdate:modelValueпри кожному натисканні клавіші - Element Plus
el-tableкидаєselection-changeпри виборі рядків - Компоненти модалок у Nuxt кидають
closeдля контролю видимості з батьківської сторінки - Кастомні форми кидають
submitз провалідованими даними до батьківських сторінок - Інтеграція з Pinia: компоненти кидають
filter-update, батько передає це до дій store
Питання на співбесіді
Q: Яка різниця між defineEmits у <script setup> і this.$emit в Options API?
A: defineEmits оголошує події на етапі компіляції і дає TypeScript-валідацію та автодоповнення в IDE. this.$emit в Options API - тільки рантайм, без перевірки типів, якщо не налаштувати окремо.
Q: Чи спливають (bubble) Vue emits по дереву компонентів, як DOM-події?
A: Ні. Vue emit досягає тільки прямого батька. Якщо онук має сповістити корінь, треба передавати emit через кожен проміжний компонент або використовувати спільний store.
Q: Як defineModel() з Vue 3.4 пов'язаний з emits?
A: defineModel() - це макрос компілятора, який автоматично створює пару prop плюс emit. Під капотом він все одно кидає update:modelValue, але прибирає необхідність писати defineProps і defineEmits окремо.
Q: Чи працюють emits при SSR так само, як на клієнті?
A: При server-side rendering emits ігноруються - немає DOM і немає взаємодії користувача. Emits мають значення тільки на клієнті після гідратації. Якщо обробник у батька залежить від монтування, загорни його в onMounted.
Q: Чи можна кинути подію до компонента на два рівні вище без ручного прокидання?
A: З emits напряму - ні. Для зв'язку з предком використовуй provide/inject. Для всього, що перетинає кілька шарів компонентів, Pinia store є чистішим варіантом.
Приклади
Базовий: Компонент лічильника
Кнопка, яка дозволяє батьківському компоненту вирішувати, що робити з лічильником:
<!-- CounterButton.vue -->
<script setup>
const emit = defineEmits<{ increment: [amount: number] }>()
</script>
<template>
<button @click="emit('increment', 1)">+1</button>
<button @click="emit('increment', 10)">+10</button>
</template><!-- App.vue -->
<script setup>
import { ref } from 'vue'
import CounterButton from './CounterButton.vue'
const count = ref(0)
</script>
<template>
<CounterButton @increment="count += $event" />
<p>Count: {{ count }}</p>
</template>У CounterButton немає стану лічильника. Він сигналізує, скільки додати. Батько вирішує, що це означає.
Середній: Редактор профілю
Картка профілю, де збереження і скасування передаються вгору до dashboard:
<!-- ProfileCard.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
save: [profile: { name: string; avatar: string }]
cancel: []
}>()
const profile = ref({ name: 'Alice', avatar: '' })
</script>
<template>
<div>
<input v-model="profile.name" placeholder="Ім'я" />
<input v-model="profile.avatar" placeholder="URL аватара" />
<button @click="emit('save', profile)">Зберегти</button>
<button @click="emit('cancel')">Скасувати</button>
</div>
</template><!-- Dashboard.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import ProfileCard from './ProfileCard.vue'
const profiles = ref<{ name: string; avatar: string }[]>([])
const handleSave = (newProfile: { name: string; avatar: string }) => {
profiles.value.push(newProfile)
}
</script>
<template>
<ProfileCard
@save="handleSave"
@cancel="() => console.log('Скасовано')"
/>
<ul>
<li v-for="p in profiles" :key="p.name">{{ p.name }}</li>
</ul>
</template>Збереження додає до списку. Скасування логує повідомлення. ProfileCard нічого не знає про profiles - він просто звітує, що сталося.
Складний: Підтвердження паролю з кількома v-model
Два поля, прив'язані до різних v-model props, кожен зі своїм emit:
<!-- PasswordFields.vue -->
<script setup lang="ts">
const props = defineProps<{
modelValue: string
confirmValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:confirmValue': [value: string]
}>()
</script>
<template>
<div>
<input
:value="props.modelValue"
placeholder="Пароль"
type="password"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
<input
:value="props.confirmValue"
placeholder="Підтвердити"
type="password"
@input="emit('update:confirmValue', ($event.target as HTMLInputElement).value)"
/>
</div>
</template><!-- SignupForm.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import PasswordFields from './PasswordFields.vue'
const password = ref('')
const confirm = ref('')
watch([password, confirm], ([p, c]) => {
if (p && c) console.log(p === c ? 'Паролі збігаються' : 'Не збігаються')
})
</script>
<template>
<PasswordFields
v-model="password"
v-model:confirmValue="confirm"
/>
</template>Кожне поле оновлює свій ref незалежно. Watch ловить розбіжності в реальному часі. Ключовий момент у назвах: v-model:confirmValue відповідає prop confirmValue і emit update:confirmValue.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.