Перейти к содержимому

9. Props и Emits

Компоненты — это изолированные острова. Как острова общаются между собой? Через мосты! В Vue: props — данные текут СВЕРХУ ВНИЗ от родителя к ребёнку, emits — события летят СНИЗУ ВВЕРХ от ребёнка к родителю. Этот однонаправленный поток делает приложение предсказуемым и лёгким для дебага! 🌊


Родитель
│ props (данные вниз ⬇️)
Ребёнок
│ emit (события вверх ⬆️)
Родитель
Никогда напрямую НЕ меняй props!
props.value = 'новое' — ❌ нарушение однонаправленного потока!

<script setup lang="ts">
// Способ 1: Простой интерфейс
const props = defineProps<{
title: string
count: number
isVisible?: boolean // ? = опциональный
items: string[]
user: {
id: number
name: string
email: string
}
}>()
// Способ 2: Импортированный интерфейс (Vue 3.3+)
import type { User, Product } from '@/types'
const props = defineProps<{
user: User
products: Product[]
selectedId?: number
}>()
</script>
<script setup lang="ts">
import { withDefaults } from 'vue'
interface ButtonProps {
label: string
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
icon?: string
}
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary', // строка
size: 'md', // строка
disabled: false, // boolean
loading: false, // boolean
icon: undefined // явно undefined
// ⚠️ Объекты и массивы — ВСЕГДА фабрика!
// items: () => [],
// config: () => ({ timeout: 3000 })
})
// TypeScript знает точные типы (без undefined):
// props.variant — 'primary' | 'secondary' | 'danger' | 'ghost'
// props.size — 'sm' | 'md' | 'lg'
</script>
// Для проектов без TypeScript или с runtime валидацией
const props = defineProps({
// Простой тип
title: String,
// Несколько типов
id: [String, Number],
// С опциями
user: {
type: Object,
required: true
},
count: {
type: Number,
default: 0,
validator(value) {
return value >= 0 // только неотрицательные числа
}
},
status: {
type: String,
default: 'active',
validator(value) {
return ['active', 'inactive', 'pending'].includes(value)
}
}
})

<script setup lang="ts">
import { computed, toRefs } from 'vue'
const props = defineProps<{
firstName: string
lastName: string
age: number
}>()
// ✅ Читаем через props.xxx
console.log(props.firstName)
// ✅ toRefs для деструктуризации с реактивностью
const { firstName, lastName } = toRefs(props)
// ✅ Вычисляем на основе props
const fullName = computed(() => `${props.firstName} ${props.lastName}`)
const isAdult = computed(() => props.age >= 18)
// ❌ НИКОГДА не изменяй props!
// props.firstName = 'Иван' // Warning + игнорируется Vue
// ✅ Если нужно изменяемое локальное состояние — копируй!
import { ref } from 'vue'
const localCount = ref(props.age) // локальная копия
</script>
<template>
<!-- Props доступны в шаблоне напрямую -->
<h2>{{ firstName }} {{ lastName }}</h2>
<p>Возраст: {{ age }}</p>
<p>{{ fullName }} — {{ isAdult ? 'совершеннолетний' : 'несовершеннолетний' }}</p>
</template>

<!-- Родитель -->
<template>
<!-- class, id, data-* автоматически передаются в корневой элемент дочернего -->
<UserCard
:user="user"
class="featured"
data-testid="user-card-main"
/>
</template>
<!-- UserCard.vue — класс 'featured' прилетит в корневой div -->
<template>
<div class="user-card"> <!-- ← получит class="user-card featured" -->
<p>{{ user.name }}</p>
</div>
</template>
<!-- Отключить наследование + ручное управление -->
<script setup lang="ts">
import { useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
</script>
<template>
<div class="wrapper">
<!-- Передаём attrs только на нужный элемент -->
<input v-bind="attrs" />
</div>
</template>

<script setup lang="ts">
// Современный callable синтаксис (Vue 3.3+) — рекомендуется
const emit = defineEmits<{
// событие без данных
click: []
// событие с данными (именованные параметры)
change: [value: string]
update: [id: number, data: Partial<User>]
delete: [userId: number]
// kebab-case событие
'item-selected': [item: MenuItem]
'update:modelValue': [value: string] // для v-model!
}>()
// Использование
const handleSave = () => {
emit('click')
emit('change', newValue)
emit('update', user.id, { name: 'Новое имя' })
emit('update:modelValue', inputValue.value)
}
</script>

v-model — это синтаксический сахар над prop + emit:

<!-- Что пишем -->
<MyInput v-model="searchText" />
<!-- Что Vue разворачивает в -->
<MyInput
:modelValue="searchText"
@update:modelValue="searchText = $event"
/>
MyInput.vue
<template>
<div class="input-wrapper">
<label v-if="label">{{ label }}</label>
<input
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
v-bind="$attrs"
/>
</div>
</template>
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
defineProps<{
modelValue: string
label?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>

<!-- Родитель -->
<template>
<UserForm
v-model:firstName="user.firstName"
v-model:lastName="user.lastName"
v-model:email="user.email"
/>
</template>
<!-- UserForm.vue — несколько v-model -->
<template>
<div>
<input
:value="firstName"
@input="emit('update:firstName', $event.target.value)"
/>
<input
:value="lastName"
@input="emit('update:lastName', $event.target.value)"
/>
<input
:value="email"
@input="emit('update:email', $event.target.value)"
/>
</div>
</template>
<script setup lang="ts">
defineProps<{
firstName: string
lastName: string
email: string
}>()
const emit = defineEmits<{
'update:firstName': [value: string]
'update:lastName': [value: string]
'update:email': [value: string]
}>()
</script>

<!-- Встроенные модификаторы -->
<input v-model.trim="text" /> <!-- обрезать пробелы -->
<input v-model.number="count" /> <!-- преобразовать в number -->
<input v-model.lazy="search" /> <!-- обновлять по blur -->
<!-- Кастомный модификатор -->
<MyInput v-model.capitalize="text" />
<!-- MyInput.vue — поддержка кастомного модификатора -->
<script setup lang="ts">
const props = defineProps<{
modelValue: string
modelModifiers?: { capitalize?: boolean; trim?: boolean }
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const handleInput = (event: Event) => {
let value = (event.target as HTMLInputElement).value
// Применяем модификаторы
if (props.modelModifiers?.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
if (props.modelModifiers?.trim) {
value = value.trim()
}
emit('update:modelValue', value)
}
</script>
<template>
<input :value="modelValue" @input="handleInput" />
</template>

// Полная валидация props (runtime синтаксис)
const props = defineProps({
// Валидатор с сообщением
age: {
type: Number,
required: true,
validator: (value: number) => {
if (value < 0) {
console.warn(`age не может быть отрицательным: ${value}`)
return false
}
if (value > 150) {
console.warn(`age кажется нереальным: ${value}`)
return false
}
return true
}
},
// Enum validator
status: {
type: String,
default: 'active',
validator: (value: string) =>
['active', 'inactive', 'pending', 'banned'].includes(value)
},
// Объект с обязательными полями
config: {
type: Object,
default: () => ({ timeout: 3000, retries: 3 }),
validator: (value: object) => 'timeout' in value
}
})

RegistrationForm.vue
<template>
<form @submit.prevent="handleSubmit" class="form">
<BaseInput
v-model="form.email"
label="Email"
type="email"
:error="errors.email"
required
/>
<BaseInput
v-model="form.password"
label="Пароль"
type="password"
:error="errors.password"
required
/>
<BaseCheckbox
v-model="form.acceptTerms"
label="Принимаю условия использования"
:error="errors.acceptTerms"
/>
<BaseButton
type="submit"
:loading="isSubmitting"
:disabled="!isValid"
>
Зарегистрироваться
</BaseButton>
</form>
</template>
<script setup lang="ts">
import { reactive, computed } from 'vue'
const emit = defineEmits<{
success: [userId: number]
error: [message: string]
}>()
const form = reactive({
email: '',
password: '',
acceptTerms: false
})
const isSubmitting = ref(false)
const errors = computed(() => {
const e: Record<string, string> = {}
if (!form.email.includes('@')) e.email = 'Некорректный email'
if (form.password.length < 8) e.password = 'Минимум 8 символов'
if (!form.acceptTerms) e.acceptTerms = 'Необходимо принять условия'
return e
})
const isValid = computed(() => Object.keys(errors.value).length === 0)
const handleSubmit = async () => {
if (!isValid.value) return
isSubmitting.value = true
try {
const { userId } = await register(form)
emit('success', userId)
} catch (e) {
emit('error', e.message)
} finally {
isSubmitting.value = false
}
}
</script>

Props и Emits:

  • defineProps<T>() — типизированные входные данные
  • withDefaults() — значения по умолчанию для generic props
  • Props readonly! — никогда не изменяй props напрямую
  • defineEmits<T>() — типизированные исходящие события
  • v-modelmodelValue prop + update:modelValue emit
  • Несколько v-modelv-model:firstName, v-model:lastName
  • Кастомные модификаторыmodelModifiers prop
  • Однонаправленный поток — props вниз ⬇️, events вверх ⬆️

Следующий урок — Слоты! 🎰