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

8. Компоненты

Компонент — это как LEGO кубик: самостоятельный, переиспользуемый, с чётким интерфейсом. Маленькие компоненты собираются в большие, большие — в страницы, страницы — в приложение. Научись делать правильные компоненты — и твой код будет радовать тебя годами! 🎉


components/UserCard.vue
<template>
<!-- Шаблон — может иметь НЕСКОЛЬКО корневых элементов (Fragments!) -->
<article class="user-card">
<img :src="user.avatar" :alt="user.name" class="avatar" />
<div class="info">
<h3>{{ user.name }}</h3>
<p>{{ user.bio }}</p>
</div>
</article>
</template>
<script setup lang="ts">
// TypeScript + Composition API
interface User {
id: number
name: string
bio: string
avatar: string
}
const props = defineProps<{ user: User }>()
const emit = defineEmits<{ click: [user: User] }>()
</script>
<style scoped>
.user-card {
display: flex;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
</style>

// main.ts — доступны везде в приложении
import { createApp } from 'vue'
import App from './App.vue'
import BaseButton from '@/components/common/BaseButton.vue'
import BaseInput from '@/components/common/BaseInput.vue'
import BaseCard from '@/components/common/BaseCard.vue'
const app = createApp(App)
// Регистрируем глобальные компоненты
app.component('BaseButton', BaseButton)
app.component('BaseInput', BaseInput)
app.component('BaseCard', BaseCard)
app.mount('#app')
// Автоматическая регистрация всех Base* компонентов
// (работает в Vite)
const components = import.meta.glob('./components/Base*.vue', { eager: true })
Object.entries(components).forEach(([path, component]) => {
const name = path.split('/').pop()?.replace('.vue', '') || ''
app.component(name, (component as any).default)
})
<script setup lang="ts">
// В <script setup> достаточно просто импортировать — авто-регистрация!
import UserCard from '@/components/UserCard.vue'
import ProductList from '@/components/ProductList.vue'
import { ref } from 'vue'
const users = ref([...])
</script>
<template>
<UserCard v-for="user in users" :key="user.id" :user="user" />
<ProductList />
</template>

✅ Правила именования компонентов:
SFC файлы: PascalCase
→ UserCard.vue
→ ProductList.vue
→ BaseButton.vue
В шаблоне: PascalCase ИЛИ kebab-case
→ <UserCard /> или <user-card />
→ PascalCase — рекомендуется, легче найти компонент
Соглашения:
Base* / App* / The* — базовые/единственные компоненты
→ BaseButton.vue, BaseInput.vue
→ TheHeader.vue, TheSidebar.vue (только один в приложении!)
Префикс фичи для связанных компонентов:
→ TodoList.vue, TodoItem.vue, TodoAddForm.vue
→ UserList.vue, UserCard.vue, UserAvatar.vue

<template>
<!-- Динамически меняем компонент -->
<component :is="currentComponent" v-bind="componentProps" />
</template>
<script setup lang="ts">
import { ref, computed, shallowRef } from 'vue'
import TabHome from '@/components/TabHome.vue'
import TabProfile from '@/components/TabProfile.vue'
import TabSettings from '@/components/TabSettings.vue'
const tabs = [
{ name: 'home', label: 'Главная', component: TabHome },
{ name: 'profile', label: 'Профиль', component: TabProfile },
{ name: 'settings', label: 'Настройки', component: TabSettings }
]
// shallowRef для компонентов — не делаем reactive из компонента!
const currentTab = ref('home')
const currentComponent = computed(
() => tabs.find(t => t.name === currentTab.value)?.component
)
</script>

<template>
<!-- Без KeepAlive — компонент пересоздаётся при каждом показе -->
<TabHome v-if="activeTab === 'home'" />
<!-- С KeepAlive — компонент сохраняет состояние (кешируется) -->
<KeepAlive>
<component :is="activeComponent" />
</KeepAlive>
<!-- include/exclude — кешировать только определённые компоненты -->
<KeepAlive include="TabHome,TabProfile">
<component :is="activeComponent" />
</KeepAlive>
<!-- max — максимум кешированных компонентов -->
<KeepAlive :max="10">
<component :is="activeComponent" />
</KeepAlive>
</template>
<script setup lang="ts">
import { onActivated, onDeactivated } from 'vue'
// Lifecycle hooks для KeepAlive компонентов
onActivated(() => {
// Вызывается при активации (вместо onMounted при повторном показе)
console.log('Компонент активирован из кеша')
refreshData()
})
onDeactivated(() => {
// Вызывается при деактивации (вместо onUnmounted при скрытии)
console.log('Компонент деактивирован и закеширован')
})
</script>

<template>
<!-- Одиночный элемент/компонент -->
<Transition name="fade">
<div v-if="isVisible" class="box">Исчезающий блок</div>
</Transition>
<!-- Transition между компонентами -->
<Transition name="slide" mode="out-in">
<component :is="activeComponent" :key="activeTab" />
</Transition>
</template>
<style scoped>
/* fade переход */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* slide переход */
.slide-enter-active,
.slide-leave-active {
transition: all 0.3s ease;
}
.slide-enter-from {
transform: translateX(20px);
opacity: 0;
}
.slide-leave-to {
transform: translateX(-20px);
opacity: 0;
}
</style>
<template>
<TransitionGroup name="list" tag="ul" class="todo-list">
<li v-for="item in items" :key="item.id" class="todo-item">
{{ item.text }}
</li>
</TransitionGroup>
</template>
<style scoped>
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.list-leave-to {
opacity: 0;
transform: translateX(20px);
}
/* Плавное движение соседних элементов */
.list-move {
transition: transform 0.3s ease;
}
</style>

import { defineAsyncComponent } from 'vue'
// Простая загрузка — lazy loading!
const HeavyChart = defineAsyncComponent(
() => import('@/components/HeavyChart.vue')
)
// С настройками загрузки и ошибок
const AsyncUserProfile = defineAsyncComponent({
loader: () => import('@/components/UserProfile.vue'),
// Компонент-заглушка пока грузится
loadingComponent: LoadingSpinner,
// Задержка перед показом LoadingSpinner (мс)
delay: 200,
// Компонент при ошибке
errorComponent: ErrorDisplay,
// Таймаут загрузки
timeout: 3000
})
<!-- Использование с Suspense -->
<template>
<Suspense>
<template #default>
<AsyncUserProfile :userId="userId" />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>

// Когда нужны полные TypeScript типы в Options API
import { defineComponent, PropType } from 'vue'
interface Product {
id: number
name: string
price: number
category: string
}
export const ProductCard = defineComponent({
name: 'ProductCard',
props: {
product: {
type: Object as PropType<Product>,
required: true
},
showActions: {
type: Boolean,
default: true
}
},
emits: {
addToCart(product: Product) {
return product.id > 0
}
},
setup(props, { emit }) {
const handleAddToCart = () => {
emit('addToCart', props.product)
}
return { handleAddToCart }
}
})

components/BaseButton.vue
<template>
<button
:class="['btn', `btn-${variant}`, { 'btn-loading': loading }]"
:disabled="disabled || loading"
v-bind="$attrs"
@click="$emit('click', $event)"
>
<span v-if="loading" class="spinner">⏳</span>
<slot />
</button>
</template>
<script setup lang="ts">
defineProps<{
variant?: 'primary' | 'secondary' | 'danger'
loading?: boolean
disabled?: boolean
}>()
defineEmits<{ click: [event: MouseEvent] }>()
// Не наследуем attrs автоматически (передаём вручную)
defineOptions({ inheritAttrs: false })
</script>
<!-- UserListContainer.vue — Smart компонент (логика) -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import UserList from './UserList.vue'
const users = ref([])
const loading = ref(true)
onMounted(async () => {
users.value = await fetchUsers()
loading.value = false
})
</script>
<template>
<UserList :users="users" :loading="loading" @delete="deleteUser" />
</template>
<!-- UserList.vue — Dumb компонент (только отображение) -->
<script setup lang="ts">
defineProps<{ users: User[]; loading: boolean }>()
defineEmits<{ delete: [userId: number] }>()
</script>

<template>
<!-- Атрибуты передаются в корневой элемент автоматически (fallthrough) -->
<!-- class="extra" id="main" данные из родителя — прилетят сюда -->
<div class="my-component">...</div>
</template>
<script setup lang="ts">
import { useAttrs } from 'vue'
// Получаем все переданные attrs (включая class, style, event listeners)
const attrs = useAttrs()
// Отключаем автоматическое наследование — управляем вручную
defineOptions({ inheritAttrs: false })
</script>
<template>
<!-- Теперь сами решаем куда передать attrs -->
<div class="wrapper">
<input v-bind="attrs" /> <!-- attrs прилетят именно на input, не на wrapper -->
</div>
</template>

Vue компоненты:

  • SFC (.vue) — шаблон + скрипт + стили в одном файле
  • Глобальная регистрацияapp.component(), доступны везде
  • Локальная регистрация — импортируй в <script setup>, авто-регистрация
  • <component :is> — динамически выбираемый компонент
  • <KeepAlive> — кеширование состояния между скрытиями
  • <Transition> — CSS анимации появления/исчезновения
  • defineAsyncComponent() — lazy loading компонентов
  • Паттерн Container/Presentational — разделение логики и отображения

Следующий урок — Props и Emits! 📡