Skip to main content

Як ефективно передавати дані між компонентми?

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

Теорія

TL;DR

  • Props = дані течуть вниз, read-only у дочірньому. Emit = події течуть вгору, батько їх обробляє.
  • Props як пошта від головного офісу до філій; emit - зворотній конверт із відповіддю.
  • Напряму мутувати props у дочірньому не можна: Vue попередить, і зміна не дійде до батька.
  • Більше 2 рівнів глибини або сусідні компоненти? Pinia, а не ланцюжок props.
  • v-model - скорочення для props + emit: :modelValue вниз, update:modelValue вгору.

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

vue
<!-- Child.vue --> <script setup> const props = defineProps(['count']); const emit = defineEmits(['update']); const increment = () => emit('update', props.count + 1); </script> <template> <button @click="increment">{{ props.count }}</button> </template>
vue
<!-- Parent.vue --> <script setup> import { ref } from 'vue'; const count = ref(0); const handleUpdate = (newCount) => { count.value = newCount; }; </script> <template> <Child :count="count" @update="handleUpdate" /> </template>

Дочірній показує значення батька. Клік запускає emit('update', newValue). Батько оновлює свій ref. Дочірній перерендерюється з новим значенням. Це і є весь патерн.

Головна різниця

Props створюють однонаправлений реактивний зв'язок від батька до дочірнього. Коли значення у батька змінюється, дочірній оновлюється автоматично. Але дочірній не може записувати назад: props read-only всередині компонента. Це робить потік даних передбачуваним.

Emit йде у зворотному напрямку. Дочірній запускає іменовану подію з payload. Батько слухає і вирішує, що з цим робити. Батько завжди контролює свій власний стан; дочірній лише сповіщає.

Коли що використовувати

  • Передача конфігурації або даних для відображення вниз (тема, список задач, профіль) → props
  • Дія у дочірньому має щось змінити у батьку (відправка форми, кнопка видалення, закриття модалки) → emit
  • Двостороння синхронізація, як кастомне поле вводу → v-model (:modelValue + update:modelValue)
  • Дані треба передати через кілька рівнів дерева компонентів → provide/inject
  • Сусідні компоненти або глобальний стан застосунку → Pinia

Практичне правило: якщо props передається через компонент, який їх не використовує, просто щоб донести дані глибше, це prop drilling. Час брати Pinia.

Таблиця порівняння

АспектPropsEmit
НапрямокБатько до дочірньогоДочірній до батька
МутабельністьRead-only у дочірньомуЗапускає обробник батька
РеактивністьАвтоматично при зміні батькаВручну через payload події
ОголошенняdefineProps(['name'])defineEmits(['event-name'])
Коли використовуватиКонфіг, дані для відображенняДії, колбеки, результати

Як Vue обробляє це всередині

Компілятор Vue перетворює <Child :count="count" @update="handleUpdate" /> на виклик createVNode(Child, { count: count, onUpdate: handleUpdate }). Props стають реактивними геттерами на proxy-об'єкті дочірнього компонента. Будь-яка зміна у батька автоматично тригерить ефект рендерингу дочірнього.

Emit працює інакше. Він реєструється через defineEmits і диспатчиться під час patch-циклу дочірнього. Обробник батька - це просто властивість onUpdate у vnode, не справжня DOM-подія браузера.

Особисто я найчастіше бачу проблему з async emit на першому ж Nuxt-проєкті: коли emit викликається всередині async-функції, payload - це звичайний JavaScript-об'єкт, а не реактивне значення. Батько сам має присвоїти його до ref.

Типові помилки

Пряма мутація props:

vue
<script setup> const props = defineProps(['count']); // Неправильно: Vue попередить, зміна не дійде до батька props.count++; </script>

Props у Vue 3 - це readonly proxy. Запис у них не оновлює батька і кидає runtime-попередження. Замість цього надсилай подію через emit.

Пропуск оголошень у defineEmits:

vue
<script setup> const emit = defineEmits(['update']); // Неправильно: 'typo-event' не оголошено, ігнорується без помилки emit('typo-event', data); </script>

Без оголошення події в defineEmits помилка у назві події непомітна. Оголошуй усі події, які використовуєш. У Vue 3.3+ можна додати валідаційну функцію для перевірки payload.

Функція як prop замість emit:

vue
<!-- Неправильно --> <Child :on-submit="handleSubmit" />

Технічно працює, якщо дочірній викликає props.onSubmit() напряму. Але це ламає однонаправлену модель подій і робить API компонента незрозумілим. Правильно: @submit="handleSubmit".

Мутація вкладеного об'єкта в props:

vue
<!-- Небезпечно --> <Child :task="task" /> <!-- Всередині Child: props.task.title = 'new' також змінює об'єкт батька -->

Об'єкти передаються за посиланням. Зміна вкладеного поля у дочірньому мовчки мутує дані батька. Огортай prop у readonly() або передавай копію: v-bind="{ ...task }".

Де використовується

  • TodoMVC (офіційний приклад Vue): props передають задачі до рядків списку; emit обробляє toggle і delete назад до батька.
  • Element Plus форми: кожен input використовує v-model, який компілюється в :modelValue + emit('update:modelValue').
  • Nuxt UI модалки: props передають початкові дані форми; emit('close', result) повертає результат.
  • Quasar QDialog: налаштовується через props, відправляє дані форми через emit('ok', formData).
  • Pinia у великих застосунках: коли Header і Cart обидва потребують даних користувача, Pinia замінює 3+ рівні prop drilling.

Follow-up питання

Q: Що таке prop drilling і як його уникнути?
A: Передача props через проміжні компоненти, які їх не використовують, щоб донести дані глибше. Уникай через provide/inject для статичних залежностей або Pinia для реактивного спільного стану.

Q: Як v-model працює всередині у Vue 3?
A: Компілюється в :modelValue="value" prop та @update:modelValue="value = $event" emit. На одному компоненті можна мати кілька v-model з іменованими аргументами: v-model:title="form.title".

Q: Яка різниця між $emit та defineEmits?
A: defineEmits - синтаксис <script setup>, оголошує події і генерує TypeScript-типи. $emit - runtime-метод з Options API. В <script setup> виклик const emit = defineEmits([...]) дає ту саму функцію.

Q: Що станеться, якщо дочірній emit-не до того, як батько підключив слухача?
A: Подія загубиться. Vue не ставить emit у чергу. Якщо є проблеми з тимінгом, використовуй nextTick.

Q: Чи можуть часті emit, наприклад 100 на секунду, впливати на продуктивність?
A: Сам dispatch дешевий, O(1). Проблема в тому, що батько робить у обробнику. Якщо кожен emit тригерить великий ре-рендер, це накопичується. Батч через nextTick, профілюй у flamegraph Vue DevTools.

Приклади

Базовий: лічильник з дочірньою кнопкою

vue
<!-- CounterButton.vue --> <script setup> const props = defineProps({ count: Number }); const emit = defineEmits(['increment']); </script> <template> <button @click="emit('increment')"> Натиснуто {{ props.count }} разів </button> </template>
vue
<!-- App.vue --> <script setup> import { ref } from 'vue'; import CounterButton from './CounterButton.vue'; const clicks = ref(0); </script> <template> <CounterButton :count="clicks" @increment="clicks++" /> </template>

Стан живе у батьку. Дочірній - чистий компонент відображення, який тригерить події. Це базовий патерн для майже кожного UI-компонента у Vue.

Середній: форма задачі з emit до батька

vue
<!-- TaskForm.vue --> <script setup> import { ref } from 'vue'; const emit = defineEmits(['add-task']); const title = ref(''); const submit = () => { if (!title.value.trim()) return; emit('add-task', { id: Date.now(), title: title.value, done: false }); title.value = ''; // Скидаємо локальний стан після emit }; </script> <template> <form @submit.prevent="submit"> <input v-model="title" placeholder="Нова задача" /> <button type="submit">Додати</button> </form> </template>
vue
<!-- TaskList.vue --> <script setup> import { ref } from 'vue'; import TaskForm from './TaskForm.vue'; const tasks = ref([]); </script> <template> <TaskForm @add-task="(task) => tasks.push(task)" /> <ul> <li v-for="task in tasks" :key="task.id">{{ task.title }}</li> </ul> </template>

Форма зберігає локальний стан поля вводу. TaskList зберігає масив задач. Emit - міст між ними. Такий самий підхід використовується в TodoMVC, офіційному демо-застосунку Vue.

Просунутий: async emit і реактивність

vue
<!-- DataLoader.vue --> <script setup> const emit = defineEmits(['data-loaded']); const load = async () => { const data = await fetch('/api/tasks').then(r => r.json()); // data - звичайний JS-масив, не реактивний emit('data-loaded', data); }; </script> <template> <button @click="load">Завантажити задачі</button> </template>
vue
<!-- Parent.vue --> <script setup> import { ref } from 'vue'; import DataLoader from './DataLoader.vue'; const tasks = ref([]); const onDataLoaded = (data) => { tasks.value = data; // Присвоєння до ref повертає реактивність }; </script> <template> <DataLoader @data-loaded="onDataLoaded" /> <ul> <li v-for="task in tasks" :key="task.id">{{ task.title }}</li> </ul> </template>

Payload emit - завжди звичайне JavaScript-значення. Реактивна обгортка через подію не передається. Батько сам відповідає за те, щоб покласти дані у ref або reactive. Розробники, які очікують, що emit автоматично зберігає реактивність, отримають список, який відмовляється оновлюватися. Не зберігає.

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

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

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

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