Skip to main content

defineProps, defineEmits та defineExpose у Vue.js

defineProps, defineEmits та defineExpose - це макроси компілятора у <script setup> Vue 3. Вони оголошують що компонент приймає, що він сигналізує назовні, і що батько може викликати ззовні. Без жодних імпортів.

Теорія

TL;DR

  • Уяви компонент як квартиру: defineProps - вхідні двері (що входить), defineEmits - дзвоник (що сигналить назовні), defineExpose відчиняє конкретне вікно (до чого сусід може дотягтись)
  • Головна відмінність від Options API: макроси дають TypeScript-інференс автоматично, без ручного об'єкта props: {}
  • defineProps - коли батько передає дані. defineEmits - для будь-якої події або v-model. defineExpose - тільки коли батькові треба викликати метод дитини напряму
  • У <script setup> нічого не є публічним за замовчуванням, тому defineExpose - виняток, а не правило

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

vue
<script setup lang="ts"> // Типізовані пропси - без import const props = defineProps<{ title: string; count: number }>() // Типізовані події const emit = defineEmits<{ (e: 'update:count', value: number): void }>() const localCount = ref(props.count) function increment() { localCount.value++ emit('update:count', localCount.value) // сигнал батьку } // Відкриваємо тільки те, що батько реально потребує defineExpose({ increment }) </script>

Батько викликає childRef.value.increment() через template ref. Все інше залишається приватним.

Ключова відмінність від Options API

Options API оголошує props, emits та expose як звичайні об'єкти всередині defineComponent. Макроси роблять те саме під час збірки, але TypeScript-плагін Vue підхоплює generic-типи і дає повний інференс у шаблоні та батьківських компонентах. Різниці у продуктивності між двома підходами немає.

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

  • Батько передає дані → defineProps (завжди першим у <script setup>)
  • Дитина повідомляє батька або підтримує v-model → defineEmits
  • Батько хоче викликати метод дитини напряму → defineExpose (рідко; для потоку даних краще використовуй події)
  • Дефолтні значення для опціональних типізованих пропсів → withDefaults(defineProps<...>(), { count: 0 })

Як компілятор це обробляє

SFC-плагін Vite перетворює макроси під час збірки. defineProps() стає валідованим прив'язуванням __props; defineEmits() реєструє валідатори подій в опціях компонента; defineExpose() встановлює exposeProxy на екземплярі, тому батько через ref.value бачить тільки те, що в списку. У браузер не потрапляє жоден виклик функції defineProps - він повністю зникає після компіляції.

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

1. Масив рядків замість типів

ts
// Неправильно - TypeScript не знає типів, складні структури стають `any` defineProps(['title', 'count']) // Правильно defineProps<{ title: string; count: number }>()

2. Масив або об'єкт як дефолт-літерал, а не фабрика

ts
// Неправильно - всі екземпляри ділять одне посилання на масив withDefaults(defineProps<{ items?: string[] }>(), { items: [] }) // Правильно withDefaults(defineProps<{ items?: string[] }>(), { items: () => [] })

Кожен екземпляр отримує свій масив. Та сама проблема була в data у Vue 2, і досі ловить людей зненацька.

3. Пряма мутація пропсу

ts
// Неправильно - Vue попереджає в dev-режимі, реактивність пропускає оновлення props.config.theme = 'dark' // Правильно - емітуй зміну emit('update:config', { ...props.config, theme: 'dark' })

Пропси доступні тільки для читання. Мутація вкладених об'єктів обходить систему реактивності і викликає попередження Vue в режимі розробки.

4. Відкриття реактивного стану замість методів

ts
// Ризиковано - батько може читати І писати внутрішній стан defineExpose({ count, data }) // Краще - батько може тільки запускати дії defineExpose({ increment, reset })

Якщо відкрити ref, батько може його мутувати. Це ламає інкапсуляцію і ускладнює пошук багів.

5. Забути withDefaults для опціональних типізованих пропсів

ts
// Неправильно - опціональний пропс буде `undefined`, якщо не передати defineProps<{ count?: number }>() // props.count → undefined, не 0 // Правильно withDefaults(defineProps<{ count?: number }>(), { count: 0 })

Де зустрічається в реальних проектах

  • Element Plus: defineEmits для подій рядків у ElTable (update:selection)
  • Nuxt Content: defineProps для пропсів документів у компонентах на основі Markdown
  • Модальні вікна: defineExpose({ open, close }) щоб батько міг керувати модалкою напряму
  • Vitest: мокування подій через vi.fn() у тестах компонента

Питання на співбесіді

Q: Яка TypeScript-перевага defineProps<{ foo: string }>()?
A: Generic-форма дає точні типи для props.foo у шаблоні та у функції setup. Оголошення через масив рядків залишає все як any.

Q: Як defineEmits валідує події?
A: На рівні TypeScript-плагіна перевіряється що emit('назва', payload) відповідає оголошеній сигнатурі. Під час виконання Vue перевіряє назви подій і попереджає, якщо щось незадекларовано.

Q: Коли defineExpose реально потрібен?
A: Тільки коли батько тримає template ref і хоче викликати метод або прочитати значення напряму. У <script setup> екземпляр компонента закритий за замовчуванням - без defineExpose батько не побачить нічого через ref.value.

Q: Що defineModel (Vue 3.4+) замінює?
A: Він автоматично генерує defineProps({ modelValue }) плюс defineEmits(['update:modelValue']). Замість двох макросів для v-model пропсу пишеш const model = defineModel<string>() і прив'язуєш v-model="model" у шаблоні.

Приклади

Базовий: кнопка з лічильником і v-model

vue
<template> <button @click="increment">{{ props.count }}</button> </template> <script setup lang="ts"> const props = defineProps<{ count: number }>() const emit = defineEmits<{ (e: 'update:count', value: number): void }>() function increment() { emit('update:count', props.count + 1) } </script>

Батько використовує <CountButton v-model:count="total" />. Дитина не власник лічильника - тільки сигналізує що він має змінитись.

Середній рівень: редактор контенту з кількома подіями

vue
<template> <div class="editor"> <textarea v-model="content" /> <button @click="publish">Опублікувати</button> </div> </template> <script setup lang="ts"> interface Post { id: string; content: string; published: boolean } const props = withDefaults( defineProps<{ post: Post }>(), { post: () => ({ id: '', content: '', published: false }) } ) const emit = defineEmits<{ (e: 'update:post', post: Post): void (e: 'publish', id: string): void }>() const content = ref(props.post.content) watch(content, (val) => emit('update:post', { ...props.post, content: val })) function publish() { emit('publish', props.post.id) } </script>

Компонент тримає локальний стан textarea, але кожну зміну передає батьку. Цей патерн зустрічається в редакторах на основі Nuxt Content.

Просунутий рівень: модальне вікно з defineExpose

vue
<!-- Modal.vue --> <template> <div v-if="visible" class="modal"> <slot /> <button @click="close">Закрити</button> </div> </template> <script setup lang="ts"> const visible = ref(false) function open() { visible.value = true } function close() { visible.value = false } // Відкриваємо тільки методи, а не сам ref `visible` defineExpose({ open, close }) </script>
vue
<!-- Parent.vue --> <script setup lang="ts"> import Modal from './Modal.vue' const modal = ref<InstanceType<typeof Modal> | null>(null) function showModal() { modal.value?.open() // типобезпечно завдяки defineExpose } </script> <template> <Modal ref="modal"> <p>Вміст тут</p> </Modal> <button @click="showModal">Відкрити</button> </template>

Батько може викликати open і close, але не може читати чи записувати visible напряму. Саме для цього відкривають методи, а не стан.

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

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

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

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