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

5. Компоненты и автоимпорт

Nuxt 3 полностью избавляет от необходимости вручную импортировать компоненты. Создал .vue файл в нужной папке — компонент автоматически доступен во всём приложении. Это не магия, это умная система конвенций.


components/
├── AppHeader.vue → <AppHeader>
├── AppFooter.vue → <AppFooter>
├── Button.vue → <Button>
└── ui/
├── Card.vue → <UiCard>
├── Badge.vue → <UiBadge>
└── form/
├── Input.vue → <UiFormInput>
└── Select.vue → <UiFormSelect>

Правило именования: путь папки + имя файла = имя компонента:

components/Modal/Header.vue → <ModalHeader>
components/Blog/PostCard.vue → <BlogPostCard>
components/Admin/User/Table.vue → <AdminUserTable>

components/ui/Button.vue
<script setup lang="ts">
interface Props {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
loading: false,
disabled: false,
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
</script>
<template>
<button
:class="['btn', \`btn-\${variant}\`, \`btn-\${size}\`]"
:disabled="disabled || loading"
@click="emit('click', $event)"
>
<span v-if="loading" class="spinner" />
<slot />
</button>
</template>

Использование без импорта:

pages/index.vue
<template>
<!-- UiButton доступен автоматически! -->
<UiButton variant="primary" @click="doSomething">
Нажми меня
</UiButton>
</template>

Компоненты в components/global/ доступны везде, включая layouts и плагины:

components/
└── global/
├── Icon.vue → <Icon> — везде доступен
├── Modal.vue → <Modal>
└── Toast.vue → <Toast>

Или в nuxt.config.ts:

export default defineNuxtConfig({
components: {
dirs: [
{
path: '~/components/global',
global: true,
},
'~/components',
]
}
})

Для компонентов, которые не нужны немедленно:

<template>
<!-- LazyMyModal — загружается только когда нужен -->
<LazyMyModal v-if="showModal" @close="showModal = false" />
<!-- LazyHeavyChart — загружается при появлении -->
<LazyHeavyChart v-if="chartVisible" :data="chartData" />
<!-- LazyBlogComments — ленивая загрузка по клику -->
<LazyBlogComments v-if="commentsLoaded" />
</template>
<script setup>
const showModal = ref(false)
const chartVisible = ref(false)
const commentsLoaded = ref(false)
</script>

Nuxt не скачивает чанк с LazyMyModal пока showModal не стало true. Отлично для:

  • 📊 Тяжёлых чартов и виджетов
  • 💬 Комментариев и сложных форм
  • 🗺️ Карт (Leaflet, Google Maps)
  • 📝 Rich text редакторов

<script setup>
import { resolveComponent } from 'vue'
const currentTab = ref('overview')
// Динамический выбор компонента
const currentComponent = computed(() => {
const components = {
overview: resolveComponent('TabOverview'),
details: resolveComponent('TabDetails'),
settings: resolveComponent('TabSettings'),
}
return components[currentTab.value]
})
</script>
<template>
<div>
<nav>
<button @click="currentTab = 'overview'">Обзор</button>
<button @click="currentTab = 'details'">Детали</button>
<button @click="currentTab = 'settings'">Настройки</button>
</nav>
<!-- Динамический компонент -->
<component :is="currentComponent" />
</div>
</template>

nuxt.config.ts
export default defineNuxtConfig({
components: [
// Дефолтная директория
'~/components',
// Дополнительные директории
{
path: '~/modules/blog/components',
prefix: 'Blog',
},
{
path: '~/modules/shop/components',
prefix: 'Shop',
},
]
})
modules/blog/components/
├── Card.vue → <BlogCard>
└── List.vue → <BlogList>
modules/shop/components/
├── Cart.vue → <ShopCart>
└── Product.vue → <ShopProduct>

components/ui/Input.vue
<script setup lang="ts">
// Отключаем наследование атрибутов на корневой элемент
defineOptions({ inheritAttrs: false })
defineProps<{
label?: string
error?: string
}>()
</script>
<template>
<div class="input-wrapper">
<label v-if="label">{{ label }}</label>
<!-- Явно передаём $attrs на input, а не на div -->
<input v-bind="$attrs" class="input" />
<span v-if="error" class="error">{{ error }}</span>
</div>
</template>

Использование — все нативные атрибуты попадут на <input>:

<UiInput
label="Email"
type="email"
placeholder="[email protected]"
required
v-model="email"
:error="errors.email"
/>

components/SearchInput.vue
<script setup lang="ts">
const inputRef = ref<HTMLInputElement>()
const query = ref('')
const focus = () => inputRef.value?.focus()
const clear = () => { query.value = '' }
const getValue = () => query.value
// Экспортируем методы для родительского компонента
defineExpose({ focus, clear, getValue })
</script>
pages/search.vue
<script setup lang="ts">
const searchInput = ref<InstanceType<typeof SearchInput>>()
onMounted(() => {
// Вызываем метод дочернего компонента
searchInput.value?.focus()
})
</script>
<template>
<SearchInput ref="searchInput" />
</template>

components/DataTable.vue
<script setup lang="ts">
defineProps<{
items: any[]
columns: { key: string; label: string }[]
}>()
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">
<!-- Именованный слот для кастомного заголовка -->
<slot :name="\`header-\${col.key}\`" :column="col">
{{ col.label }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id">
<td v-for="col in columns" :key="col.key">
<!-- Scoped слот с данными строки -->
<slot :name="\`cell-\${col.key}\`" :item="item" :value="item[col.key]">
{{ item[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<!-- Использование с кастомными ячейками -->
<DataTable :items="users" :columns="columns">
<template #cell-avatar="{ item }">
<img :src="item.avatar" :alt="item.name" />
</template>
<template #cell-status="{ value }">
<UiBadge :variant="value === 'active' ? 'success' : 'warning'">
{{ value }}
</UiBadge>
</template>
</DataTable>

<!-- components/Dropdown.vue — Provider -->
<script setup lang="ts">
const isOpen = ref(false)
const toggle = () => { isOpen.value = !isOpen.value }
// Предоставляем контекст дочерним компонентам
provide('dropdown', { isOpen, toggle })
</script>
<template>
<div class="dropdown">
<slot />
</div>
</template>
components/DropdownTrigger.vue
<script setup>
const { toggle } = inject('dropdown')
</script>
<template>
<button @click="toggle">
<slot />
</button>
</template>

✅ Структура компонентов:
components/
├── app/ ← Компоненты приложения (AppHeader, AppNav)
├── ui/ ← Переиспользуемые UI элементы
│ ├── Button.vue
│ ├── Card.vue
│ └── form/
│ ├── Input.vue
│ └── Select.vue
├── sections/ ← Секции страниц (HeroSection, FeaturesSection)
├── widgets/ ← Виджеты (WeatherWidget, NewsWidget)
└── global/ ← Глобальные компоненты
✅ Именование:
- PascalCase для имён файлов
- Описательные имена: UserProfileCard, not Card2
- Префикс для группировки: UiButton, BlogPostCard
❌ Избегай:
- Компоненты без TypeScript
- Слишком крупные компоненты (>300 строк)
- Бизнес-логику в UI компонентах