Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Продвинутые компоненты
vue_components_advanced

Продвинутые компоненты

Props, emits, слоты, provide/inject, динамические и асинхронные компоненты, v-model на компонентах, composables

Продвинутые компоненты Vue 3

Компоненты — строительные блоки Vue-приложений. В этой теме разберём продвинутые механизмы передачи данных: props, emits, slots, provide/inject, и паттерны для создания переиспользуемых компонентов.

#Props: определение, типы, валидация, default значения

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: определение событий, передача данных вверх

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, named, scoped

Слоты позволяют передавать шаблонный контент в компонент. Это ключевой механизм для создания гибких, переиспользуемых компонентов-обёрток.

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 — передача данных через дерево

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')

#Dynamic components: component :is

Динамические компоненты позволяют рендерить разные компоненты в одном месте, переключаясь между ними:

<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> — важный компонент-обёртка: он сохраняет состояние компонентов при переключении вместо их уничтожения и создания заново. Удобно для вкладок с формами или загруженными данными.

#Async components: defineAsyncComponent

Асинхронные компоненты загружаются лениво — только когда нужны. Это уменьшает размер начального бандла:

<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 на кастомных компонентах

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 — переиспользование логики

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>

Далее: TypeScript с Vue 3