Appearance
ADR-3: Разделение компонентов на контейнерные и презентационные
Статус
Принято (04.09.2025)
🎯 Цель
Снизить связность фронтенд-кода, упростить поддержку и масштабирование.
При росте проекта «толстые» компоненты начинают знать и про UI, и про store, и про router, и про API. Это приводит к:
- дублированию бизнес-логики,
- трудности при тестировании,
- запутанной вложенности и «спагетти»-коду.
📐 Решение
Вводим чёткое разделение компонентов на два типа:
Контейнеры (умные)
- знают про store, router, API;
- управляют бизнес-логикой;
- прокидывают данные и колбэки вниз в «тупые» компоненты.
Презентационные компоненты (тупые)
- отвечают только за отрисовку;
- не знают про store и router;
- принимают данные через
props; - сообщают о действиях через
emitилиprovide/inject.
🚫 Антипаттерн (как делать нельзя)
vue
<!-- SlideNavigation.vue -->
<script setup>
import { useLessonStore } from '@/stores/LessonStore';
import { useRouter } from 'vue-router';
const store = useLessonStore();
const router = useRouter();
const addSlide = async () => {
await store.createSlide();
router.push({ name: 'SlideEditor' });
};
</script>
<template>
<button @click="addSlide">Новый слайд</button>
</template>❌ Здесь компонент одновременно:
- вызывает store,
- управляет навигацией,
- меняет бизнес-данные.
→ Он перестаёт быть переиспользуемым и тестируемым.
✅ Правильный вариант
Тупой компонент
vue
<!-- SlideNavigation.vue -->
<script setup>
defineProps<{ slides: Slide[] }>();
const emit = defineEmits<{ (e: 'create-slide'): void }>();
</script>
<template>
<ul>
<li v-for="slide in slides" :key="slide._id">{{ slide.title }}</li>
</ul>
<button @click="emit('create-slide')">Новый слайд</button>
</template>Контейнер
vue
<!-- LessonPage.vue -->
<script setup>
import { useLessonStore } from '@/stores/LessonStore';
import SlideNavigation from './SlideNavigation.vue';
import { useRouter } from 'vue-router';
import { storeToRefs } from 'pinia';
const store = useLessonStore();
const { slides } = storeToRefs(store);
const router = useRouter();
const createSlide = async () => {
const slide = await store.createSlide();
router.push({ name: 'SlideEditor', params: { id: slide._id } });
};
</script>
<template>
<SlideNavigation :slides="slides" @create-slide="createSlide" />
</template>📋 Правило для код-ревью
- Тупой компонент никогда не использует store или router.
- Любая бизнес-операция (создание, удаление, редактирование) идёт через store.
- Если компонент растёт и начинает смешивать UI + бизнес-логику → выделить контейнер.
🔍 Как проверять
- Быстрый чек: если в компоненте есть
useStoreилиuseRouter, значит это контейнер, и он не должен лежать вcomponents/, а вviews/илиpages/. - Props/Emits: у «тупого» компонента есть чётко описанные входы и выходы.
- Тестируемость: тупой компонент можно отрендерить в isolation, подставив фейковые props.
📌 Итог
- UI-слой прост, декларативен и легко тестируется.
- Бизнес-логика централизована в store/контейнерах.
- Уменьшаем связность, упрощаем рефакторинг и масштабирование.
Хочешь, я подготовлю ещё и **`README.md` для `/docs/adr`**, где соберу список всех твоих решений (БЭМ, ссылки, контейнеры/презентационные компоненты) как оглавление?