Skip to content

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`**, где соберу список всех твоих решений (БЭМ, ссылки, контейнеры/презентационные компоненты) как оглавление?