Props, emits, слоты, provide/inject, динамические и асинхронные компоненты, v-model на компонентах, composables
Компоненты — строительные блоки Vue-приложений. В этой теме разберём продвинутые механизмы передачи данных: props, emits, slots, provide/inject, и паттерны для создания переиспользуемых компонентов.
Props — это механизм передачи данных от родительского компонента к дочернему. Данные текут вниз (parent → child), это называется «однонаправленный поток данных».
В <script setup> props объявляются через макрос defineProps:
<!-- Button.vue — дочерний компонент -->
<template>
<button
:class="['btn', `btn-${variant}`, { 'btn-disabled': disabled }]"
:disabled="disabled"
@click="$emit('click', $event)"
>
<slot>{{ label }}</slot>
</button>
</template>
<script setup>
const props = defineProps({
label: {
type: String,
default: 'Нажми меня'
},
variant: {
type: String,
default: 'primary',
validator(value) {
// Валидация: допустимые значения
return ['primary', 'secondary', 'danger'].includes(value)
}
},
disabled: {
type: Boolean,
default: false
},
size: {
type: String,
default: 'medium'
}
})
</script>С TypeScript синтаксис становится ещё чище и типобезопасным:
<script setup lang="ts">
interface Props {
label?: string
variant?: 'primary' | 'secondary' | 'danger'
disabled?: boolean
count?: number
}
const props = withDefaults(defineProps<Props>(), {
label: 'Нажми',
variant: 'primary',
disabled: false,
count: 0
})
</script>Использование в родительском компоненте:
<!-- ParentComponent.vue -->
<template>
<div>
<Button label="Сохранить" variant="primary" />
<Button label="Удалить" variant="danger" :disabled="isProcessing" />
<!-- Можно передавать переменные через : -->
<Button :label="buttonLabel" :variant="buttonVariant" />
</div>
</template>
<script setup>
import Button from './Button.vue'
import { ref } from 'vue'
const isProcessing = ref(false)
const buttonLabel = ref('Подтвердить')
const buttonVariant = ref('primary')
</script>Важное правило: не мутируйте props в дочернем компоненте. Props только для чтения. Если нужно изменяемое значение, создайте локальную копию через ref или computed.
Emits — механизм общения от дочернего компонента к родительскому. Дочерний генерирует событие, родитель подписывается через @:
<!-- SearchInput.vue -->
<template>
<div class="search-wrapper">
<input
v-model="query"
@input="onInput"
@keydown.enter="onSearch"
placeholder="Поиск..."
>
<button @click="onSearch">Найти</button>
<button v-if="query" @click="onClear">Очистить</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const emit = defineEmits({
// С валидацией payload
search: (query) => {
if (typeof query !== 'string') {
console.warn('search: ожидается строка')
return false
}
return true
},
// Без валидации
clear: null,
// С типами через TypeScript
input: (value) => typeof value === 'string'
})
const query = ref('')
function onInput() {
emit('input', query.value)
}
function onSearch() {
if (query.value.trim()) {
emit('search', query.value.trim())
}
}
function onClear() {
query.value = ''
emit('clear')
}
</script>В родителе подписываемся на события:
<template>
<div>
<SearchInput
@search="handleSearch"
@clear="handleClear"
@input="handleInput"
/>
<p>Результатов: {{ resultCount }}</p>
</div>
</template>
<script setup>
import SearchInput from './SearchInput.vue'
import { ref } from 'vue'
const resultCount = ref(0)
async function handleSearch(query) {
const results = await searchApi(query)
resultCount.value = results.length
}
function handleClear() {
resultCount.value = 0
}
function handleInput(value) {
console.log('Пользователь вводит:', value)
}
</script>Слоты позволяют передавать шаблонный контент в компонент. Это ключевой механизм для создания гибких, переиспользуемых компонентов-обёрток.
Default slot — просто вставляем контент внутрь компонента:
<!-- Card.vue -->
<template>
<div class="card">
<div class="card-header">
<!-- Named slot для заголовка -->
<slot name="header">
<span>Заголовок по умолчанию</span>
</slot>
</div>
<div class="card-body">
<!-- Default slot для основного контента -->
<slot>
<p>Контент не задан</p>
</slot>
</div>
<div class="card-footer" v-if="$slots.footer">
<!-- Условный рендеринг: рендерим footer только если слот передан -->
<slot name="footer" />
</div>
</div>
</template>Использование карточки с именованными слотами:
<template>
<Card>
<!-- Именованный слот через v-slot или # -->
<template #header>
<h2>Мой заголовок</h2>
</template>
<!-- Default slot — без директивы -->
<p>Основной текст карточки с любым содержимым.</p>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
<template #footer>
<button @click="close">Закрыть</button>
</template>
</Card>
</template>Scoped slots — слоты, которые получают данные из дочернего компонента. Это мощный паттерн для компонентов-списков:
<!-- DataList.vue — передаёт данные в слот -->
<template>
<div>
<div v-if="isLoading">Загрузка...</div>
<div v-else-if="error">Ошибка: {{ error }}</div>
<ul v-else>
<li v-for="item in items" :key="item.id">
<!-- Передаём item и методы в слот -->
<slot :item="item" :remove="() => removeItem(item.id)" />
</li>
</ul>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
defineProps({ url: String })
const items = ref([])
const isLoading = ref(false)
const error = ref(null)
onMounted(async () => {
isLoading.value = true
try {
const res = await fetch(props.url)
items.value = await res.json()
} catch (e) {
error.value = e.message
} finally {
isLoading.value = false
}
})
function removeItem(id) {
items.value = items.value.filter(i => i.id !== id)
}
</script><!-- Родитель получает данные из scoped slot -->
<template>
<DataList url="/api/users" v-slot="{ item, remove }">
<div class="user-row">
<span>{{ item.name }}</span>
<button @click="remove">Удалить</button>
</div>
</DataList>
</template>provide и inject решают проблему «prop drilling» — когда нужно передать данные через несколько уровней вложенности компонентов. Вместо этого предок предоставляет данные, а любой потомок может их инжектировать:
<!-- App.vue или любой компонент-предок -->
<script setup>
import { ref, provide, readonly } from 'vue'
const theme = ref('dark')
const user = ref({ name: 'Иван', role: 'admin' })
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}
// Предоставляем данные потомкам
// readonly предотвращает мутацию из потомков
provide('theme', readonly(theme))
provide('toggleTheme', toggleTheme)
provide('currentUser', readonly(user))
</script><!-- DeepNestedComponent.vue — любой потомок -->
<template>
<div :class="`theme-${theme}`">
<p>Пользователь: {{ currentUser.name }}</p>
<button @click="toggleTheme">Сменить тему</button>
</div>
</template>
<script setup>
import { inject } from 'vue'
// Второй аргумент inject — значение по умолчанию
const theme = inject('theme', 'light')
const currentUser = inject('currentUser', { name: 'Гость', role: 'user' })
const toggleTheme = inject('toggleTheme', () => {})
</script>Рекомендация: используйте Symbol-ключи для provide/inject в реальных проектах, чтобы избежать конфликтов имён:
// keys.js — экспортируем ключи
export const THEME_KEY = Symbol('theme')
export const USER_KEY = Symbol('currentUser')Динамические компоненты позволяют рендерить разные компоненты в одном месте, переключаясь между ними:
<template>
<div>
<!-- Вкладки -->
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
:class="{ active: activeTab === tab.id }"
>
{{ tab.label }}
</button>
</div>
<!-- KeepAlive сохраняет состояние компонентов при переключении -->
<KeepAlive>
<component :is="currentComponent" v-bind="currentProps" />
</KeepAlive>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import ProfileTab from './ProfileTab.vue'
import SettingsTab from './SettingsTab.vue'
import SecurityTab from './SecurityTab.vue'
const activeTab = ref('profile')
const tabs = [
{ id: 'profile', label: 'Профиль' },
{ id: 'settings', label: 'Настройки' },
{ id: 'security', label: 'Безопасность' }
]
const components = {
profile: ProfileTab,
settings: SettingsTab,
security: SecurityTab
}
const currentComponent = computed(() => components[activeTab.value])
const currentProps = computed(() => ({ userId: 123 }))
</script><KeepAlive> — важный компонент-обёртка: он сохраняет состояние компонентов при переключении вместо их уничтожения и создания заново. Удобно для вкладок с формами или загруженными данными.
Асинхронные компоненты загружаются лениво — только когда нужны. Это уменьшает размер начального бандла:
<script setup>
import { defineAsyncComponent } from 'vue'
// Простая ленивая загрузка
const HeavyChart = defineAsyncComponent(() =>
import('./HeavyChart.vue')
)
// С настройками загрузки и ошибки
const AsyncModal = defineAsyncComponent({
loader: () => import('./Modal.vue'),
// Показываем пока грузится
loadingComponent: LoadingSpinner,
delay: 200, // Задержка перед показом loadingComponent (мс)
// Показываем при ошибке
errorComponent: ErrorDisplay,
timeout: 10000 // Таймаут в мс
})
</script>
<template>
<div>
<!-- Используем как обычный компонент -->
<Suspense>
<HeavyChart :data="chartData" />
<template #fallback>
<div>Загрузка графика...</div>
</template>
</Suspense>
</div>
</template>Async components идеально сочетаются с <Suspense> — экспериментальным компонентом Vue для управления асинхронными зависимостями.
v-model на компонентах — это сахар для паттерна «props + emit». По умолчанию v-model передаёт prop modelValue и ожидает событие update:modelValue:
<!-- CustomInput.vue -->
<template>
<div class="custom-input">
<label v-if="label">{{ label }}</label>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
v-bind="$attrs"
>
<span v-if="error" class="error">{{ error }}</span>
</div>
</template>
<script setup>
defineProps({
modelValue: String,
label: String,
error: String
})
defineEmits(['update:modelValue'])
</script>Теперь можно использовать v-model как с обычным <input>:
<template>
<CustomInput v-model="email" label="Email" :error="emailError" />
<!-- Эквивалентно: -->
<!-- <CustomInput :modelValue="email" @update:modelValue="email = $event" ... /> -->
</template>Несколько v-model с разными именами на одном компоненте:
<!-- RangeSlider.vue -->
<script setup>
defineProps({
min: Number, // v-model:min
max: Number // v-model:max
})
defineEmits(['update:min', 'update:max'])
</script><!-- Родитель -->
<RangeSlider v-model:min="minValue" v-model:max="maxValue" />Composables — это функции с префиксом use, которые инкапсулируют реактивную логику. Главное преимущество перед миксинами: нет конфликтов имён, явные зависимости, лёгкое тестирование.
Пример продвинутого composable для работы с формами:
// composables/useForm.js
import { ref, reactive, computed } from 'vue'
export function useForm(initialValues, validationRules) {
const values = reactive({ ...initialValues })
const errors = reactive({})
const touched = reactive({})
const isSubmitting = ref(false)
const isValid = computed(() =>
Object.keys(errors).length === 0
)
function validate(field) {
const rule = validationRules[field]
if (!rule) return true
const error = rule(values[field], values)
if (error) {
errors[field] = error
} else {
delete errors[field]
}
return !error
}
function validateAll() {
let valid = true
for (const field of Object.keys(validationRules)) {
touched[field] = true
if (!validate(field)) valid = false
}
return valid
}
async function handleSubmit(onSubmit) {
if (!validateAll()) return
isSubmitting.value = true
try {
await onSubmit(values)
} finally {
isSubmitting.value = false
}
}
function reset() {
Object.assign(values, initialValues)
Object.keys(errors).forEach(k => delete errors[k])
Object.keys(touched).forEach(k => delete touched[k])
}
return {
values,
errors,
touched,
isValid,
isSubmitting,
validate,
handleSubmit,
reset
}
}Использование composable в компоненте:
<template>
<form @submit.prevent="handleSubmit(saveUser)">
<input v-model="values.name" @blur="validate('name')">
<span v-if="touched.name && errors.name">{{ errors.name }}</span>
<input v-model="values.email" @blur="validate('email')">
<span v-if="touched.email && errors.email">{{ errors.email }}</span>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? 'Сохранение...' : 'Сохранить' }}
</button>
</form>
</template>
<script setup>
import { useForm } from '@/composables/useForm'
const { values, errors, touched, isSubmitting, validate, handleSubmit } = useForm(
{ name: '', email: '' },
{
name: (v) => !v ? 'Имя обязательно' : v.length < 2 ? 'Минимум 2 символа' : null,
email: (v) => !v ? 'Email обязателен' : !v.includes('@') ? 'Некорректный email' : null
}
)
async function saveUser(formData) {
await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
})
}
</script>Продвинутые компоненты Vue 3 открывают возможности для создания гибкой и масштабируемой архитектуры. Props и emits обеспечивают однонаправленный поток данных. Слоты делают компоненты гибкими контейнерами. provide/inject позволяет избежать prop drilling. Динамические и асинхронные компоненты оптимизируют загрузку. А composables — ключевой механизм переиспользования логики в Vue 3.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.