Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Випромінювання та користувацькі події у Vue.js». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Emits** - це спосіб дочірнього компонента Vue сповістити батьківський, кинувши кастомну подію. Батько слухає через `@назваПодії` і отримує payload як `$event`. ```vue <!-- Child.vue --> <script setup> const emit = defineEmits(['update']) const save = () => emit('update', { name: 'Alice' }) </script> <!-- Parent.vue --> <Child @update="handleUpdate" /> ``` **Ключове:** завжди оголошуй події через `defineEmits` - це вмикає перевірку типів TypeScript і прибирає зайві dev-попередження Vue.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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`.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.