Skip to main content

Випромінювання та користувацькі події у Vue.js

Emits - це спосіб, яким дочірні компоненти Vue надсилають кастомні події вгору до батьківських, виконуючи зворотній напрямок до props.

Теорія

TL;DR

  • Дочірній компонент схожий на кнопку Submit у формі: він не знає, що батько зробить після натискання, він просто кричить "відправлено!"
  • Props течуть вниз (батько контролює дані); emits течуть вгору (дитина ініціює сповіщення)
  • Emits - для зв'язку дитина-батько; Pinia - для сиблінгів; provide/inject - для глибоких дерев компонентів
  • defineEmits у <script setup> оголошує події і вмикає перевірку TypeScript
  • v-model на компоненті компілюється в :modelValue + @update:modelValue під капотом

Швидкий приклад

vue
<!-- Child.vue --> <script setup> const emit = defineEmits(['count-up']) const handleClick = () => emit('count-up', 1) </script> <template> <button @click="handleClick">+1</button> </template>
vue
<!-- 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-синтаксис:

vue
<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:

vue
<!-- 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>
vue
<!-- Parent.vue --> <template> <CustomInput v-model="username" /> <!-- те саме що: <CustomInput :modelValue="username" @update:modelValue="username = $event" /> --> </template>

Декілька v-model на одному компоненті теж можливі. Кожен потребує свій named prop і відповідний emit update:назваProp:

vue
<!-- UserForm.vue --> <script setup> defineProps<{ firstName: string; lastName: string }>() defineEmits<{ 'update:firstName': [value: string] 'update:lastName': [value: string] }>() </script>
vue
<!-- 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
// Погано - Vue 3.3+ попереджає "Extraneous non-emits event listeners" const emit = () => {} // без defineEmits // Добре const emit = defineEmits(['save', 'cancel'])

Без defineEmits втрачаєш TypeScript-валідацію і dev-попередження Vue стають галасливими.

Мутувати prop напряму в дочірньому компоненті:

vue
// Погано - Vue попередить, і зміна відкотиться при наступному рендері батька props.user.name = 'Bob' // Добре - кинь зміну, нехай батько оновить emit('update:user', { ...props.user, name: 'Bob' })

Забути $event в inline-обробнику:

Переглядаючи Vue-код на практиці, ця помилка зустрічається частіше за всі інші. Подія кидається, обробник запускається, але payload зникає без жодного повідомлення.

vue
<!-- Погано - payload загублено --> <Child @save="console.log('saved')" /> <!-- Добре - $event містить payload --> <Child @save="handleSave($event)" /> <!-- або передай посилання на функцію напряму --> <Child @save="handleSave" />

Emit у циклі:

vue
// Погано - кидає N окремих подій, кожна може тригерити перерендер items.forEach(item => emit('add', item)) // Добре - батчинг в одному emit emit('add-batch', items)

CamelCase назви подій у шаблонах:

ts
// Оголошено 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 є чистішим варіантом.

Приклади

Базовий: Компонент лічильника

Кнопка, яка дозволяє батьківському компоненту вирішувати, що робити з лічильником:

vue
<!-- 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>
vue
<!-- 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:

vue
<!-- 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>
vue
<!-- 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:

vue
<!-- 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>
vue
<!-- 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.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?