Как правильно структурировать веб-проект

Практическое руководство по организации веб-проекта: от небольшой файловой структуры до модульной архитектуры, которая не разваливается при добавлении новых функций.

Почти каждый новый проект начинается одинаково: создаём несколько компонентов, добавляем папку utils, складываем запросы к серверу в api, а изображения — в assets. Первое время всё выглядит вполне аккуратно.

Потом появляется авторизация, каталог, фильтры, личный кабинет и несколько модальных окон. В папке components уже лежит 70 файлов, в utils — функции от форматирования даты до проверки прав пользователя, а компонент ProductPage каким-то образом знает и про корзину, и про аналитику, и про структуру ответа сервера.

Именно в этот момент обычно возникает желание «сделать нормальную архитектуру». Иногда разработчик берёт структуру большого проекта с GitHub и переносит её целиком. Вместе с десятком каталогов, смысл которых пока не понимает никто, включая самого разработчика.

Правильная структура проекта работает немного иначе. Она не должна выглядеть сложно. Её задача — помочь быстро ответить на три вопроса:

  • где находится нужный код;
  • за что отвечает конкретный модуль;
  • от каких частей приложения он зависит.

Если ответы приходится искать через глобальный поиск, структура уже начала давать трещины.

Идеальной структуры не существует

Структура зависит от размера проекта, состава команды и сложности предметной области. Лендинг, интернет-магазин и корпоративная CRM не стоит организовывать одинаково.

Для небольшого сайта вполне достаточно простой структуры:

src/
├── assets/
├── components/
├── pages/
├── scripts/
├── styles/
└── main.js

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

Но есть и обратная сторона. Если проект уже содержит десятки страниц, сложные пользовательские сценарии и несколько разработчиков, одной папки components будет мало. В ней довольно быстро перемешаются кнопки, формы, карточки товаров, элементы личного кабинета и компоненты конкретных страниц.

Поэтому структуру лучше усложнять постепенно — вслед за самим приложением.

Сначала разделяем код по ответственности

Самый понятный первый шаг — отделить разные типы кода друг от друга. Например, страницы не должны лежать рядом с переиспользуемыми кнопками, а функции для работы с API — рядом с изображениями.

Для среднего React-проекта базовая структура может выглядеть так:

src/
├── app/
│   ├── router/
│   ├── providers/
│   └── App.jsx
├── pages/
│   ├── HomePage/
│   ├── CatalogPage/
│   └── ProfilePage/
├── components/
│   ├── Header/
│   ├── ProductCard/
│   └── Pagination/
├── services/
│   ├── apiClient.js
│   ├── productsApi.js
│   └── usersApi.js
├── hooks/
├── utils/
├── assets/
├── styles/
└── main.jsx

Папка app содержит настройку приложения: маршрутизацию, глобальные провайдеры, подключение хранилища и другие вещи, которые запускают приложение как единое целое.

В pages находятся компоненты страниц. В components — переиспользуемые части интерфейса. services отвечает за взаимодействие с внешними системами, например с REST API. В hooks лежат общие React-хуки, а в utils — небольшие функции без привязки к интерфейсу или бизнес-сценарию.

Для начала такой структуры обычно достаточно. Главное — не воспринимать названия папок как стандарт, который нельзя менять. Если в проекте нет глобальных хуков, не нужно создавать пустой каталог hooks просто для красоты.

Почему папка components быстро превращается в склад

Допустим, в интернет-магазине есть карточка товара, корзина и избранное. Сначала разработчик раскладывает компоненты так:

components/
├── ProductCard.jsx
├── ProductPrice.jsx
├── AddToCartButton.jsx
├── WishlistButton.jsx
├── CartItem.jsx
└── CartTotal.jsx

Проблема становится заметной не сразу. Через несколько месяцев появляются быстрый просмотр товара, разные варианты карточек, промокоды, рекомендации и отдельная мобильная корзина. Теперь все файлы технически являются компонентами, но относятся к совершенно разным частям продукта.

На практике удобнее группировать код не только по техническому типу, но и по смыслу:

features/
├── cart/
│   ├── api/
│   ├── model/
│   └── ui/
├── wishlist/
│   ├── api/
│   ├── model/
│   └── ui/
└── product-filter/
    ├── lib/
    ├── model/
    └── ui/

Теперь всё, что относится к корзине, находится внутри cart, а код избранного — внутри wishlist. Если функцию нужно удалить или переделать, не приходится собирать её по всему проекту.

Это называется организацией по функциональности. Для растущих приложений она обычно удобнее, чем разделение всего проекта на глобальные каталоги components, hooks, services и types.

Пример структуры растущего приложения

Когда проект становится крупнее, его можно разделить на несколько уровней:

src/
├── app/
│   ├── providers/
│   ├── router/
│   ├── styles/
│   └── App.jsx
├── pages/
│   ├── CatalogPage/
│   ├── ProductPage/
│   └── ProfilePage/
├── widgets/
│   ├── Header/
│   ├── ProductGrid/
│   └── ProfileSidebar/
├── features/
│   ├── add-to-cart/
│   ├── add-to-wishlist/
│   ├── product-filter/
│   └── user-login/
├── entities/
│   ├── product/
│   ├── cart/
│   └── user/
└── shared/
    ├── api/
    ├── config/
    ├── lib/
    ├── types/
    └── ui/

Здесь app отвечает за запуск и глобальную конфигурацию приложения. pages собирает полноценные страницы, а widgets — крупные самостоятельные блоки интерфейса.

В features находятся пользовательские действия: добавить товар в корзину, войти в аккаунт, применить фильтр. Папка entities описывает основные сущности предметной области — товар, пользователя, заказ или корзину. В shared помещается общий код, который не знает о конкретной бизнес-функции.

Эта схема напоминает Feature-Sliced Design, но слепо повторять всю методологию необязательно. Можно взять сам принцип: разделять приложение по смыслу и контролировать направление зависимостей.

Например, страница может использовать виджеты, функции и сущности. Функция добавления в корзину может работать с сущностями товара и корзины. Но общий компонент Button из shared/ui не должен импортировать код из features/cart.

Иначе нижний универсальный слой неожиданно начинает зависеть от верхнего бизнес-слоя. Вот здесь архитектура обычно и превращается в клубок.

Модуль должен иметь понятную границу

Хороший модуль не заставляет другие части приложения знать о его внутреннем устройстве. Внешний код должен импортировать только то, что модуль официально предоставляет.

Предположим, функция корзины имеет такую структуру:

features/
└── add-to-cart/
    ├── api/
    │   └── addProduct.js
    ├── model/
    │   └── useAddToCart.js
    ├── ui/
    │   └── AddToCartButton.jsx
    └── index.js

Файл index.js становится публичным интерфейсом модуля:

// features/add-to-cart/index.js

export { AddToCartButton } from './ui/AddToCartButton';
export { useAddToCart } from './model/useAddToCart';

В другом месте приложения импорт будет выглядеть так:

import { AddToCartButton } from '@/features/add-to-cart';

А не так:

import { AddToCartButton }
  from '@/features/add-to-cart/ui/AddToCartButton';

На первый взгляд разница небольшая. Но во втором случае внешний код зависит от внутренней файловой структуры модуля. Стоит перенести компонент в другой каталог — и придётся исправлять импорты по всему проекту.

Публичный интерфейс позволяет менять внутреннее устройство модуля, не затрагивая его потребителей.

Где хранить работу с API

Запросы к серверу часто размещают прямо внутри компонентов. Для небольшого прототипа это допустимо, но по мере роста проекта компоненты начинают одновременно отображать интерфейс, загружать данные, преобразовывать ответы сервера и обрабатывать ошибки.

Получается примерно так:

function ProductPage({ productId }) {
  const [product, setProduct] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch(`/api/products/${productId}`)
      .then((response) => {
        if (!response.ok) {
          throw new Error('Не удалось загрузить товар');
        }

        return response.json();
      })
      .then(setProduct)
      .catch(setError);
  }, [productId]);

  // Остальная логика страницы
}

Сам запрос работает, но страница уже знает слишком много деталей: адрес API, способ отправки запроса и формат обработки ответа.

Лучше вынести обращение к серверу в модуль сущности или функции:

// entities/product/api/getProduct.js

import { apiClient } from '@/shared/api';

export async function getProduct(productId) {
  return apiClient.get(`/products/${productId}`);
}

А загрузку и управление состоянием оставить специальному хуку или библиотеке запросов:

// entities/product/model/useProduct.js

import { useEffect, useState } from 'react';
import { getProduct } from '../api/getProduct';

export function useProduct(productId) {
  const [product, setProduct] = useState(null);
  const [error, setError] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    let isActive = true;

    setIsLoading(true);

    getProduct(productId)
      .then((data) => {
        if (isActive) setProduct(data);
      })
      .catch((requestError) => {
        if (isActive) setError(requestError);
      })
      .finally(() => {
        if (isActive) setIsLoading(false);
      });

    return () => {
      isActive = false;
    };
  }, [productId]);

  return { product, error, isLoading };
}

Теперь компонент страницы работает с понятными состояниями и не зависит от деталей HTTP-запроса. Если позже появится другой API-клиент или изменится адрес метода, переделывать страницу не потребуется.

В реальном проекте для серверного состояния чаще используют специализированные решения вроде TanStack Query или встроенные механизмы конкретного фреймворка. Но принцип остаётся тем же: сетевой транспорт не должен быть размазан по компонентам.

Не превращайте utils в чёрную дыру

Папка utils выглядит безобидно, поэтому в неё постепенно начинают попадать все функции, для которых не нашлось более подходящего места:

utils/
├── formatDate.js
├── calculateCartTotal.js
├── canUserEditOrder.js
├── validateEmail.js
└── transformProductResponse.js

Но эти функции имеют разную ответственность. formatDate действительно может быть общей утилитой. Расчёт суммы корзины относится к корзине, проверка прав — к пользователю или заказу, а преобразование ответа API — к товару.

Практичное правило довольно простое: если функция содержит знания о предметной области, она должна находиться рядом с этой предметной областью.

Например:

shared/lib/formatDate.js
entities/cart/lib/calculateCartTotal.js
entities/order/lib/canUserEditOrder.js
entities/product/api/transformProductResponse.js

После такого разделения название каталога уже подсказывает, где искать нужную логику.

Стили тоже нуждаются в структуре

Со стилями происходит то же самое, что и с JavaScript. Сначала есть один файл style.css, потом он вырастает до нескольких тысяч строк, а изменение класса .button неожиданно ломает форму в личном кабинете.

Общие переменные, сброс стилей и типографику можно держать на уровне приложения:

app/styles/
├── reset.css
├── variables.css
├── typography.css
└── global.css

Стили конкретного компонента лучше хранить рядом с ним:

shared/ui/Button/
├── Button.jsx
├── Button.module.css
└── index.js

Так компонент можно перенести или удалить вместе с его стилями. Не приходится искать связанные селекторы в одном огромном CSS-файле.

Если используется Tailwind CSS, физическая организация файлов меняется, но границы ответственности никуда не исчезают. Длинная строка классов внутри компонента не исправляет плохое разделение самого компонента.

Что делать с типами, константами и конфигурацией

Глобальные каталоги types и constants тоже способны превратиться в склад.

Если тип описывает товар, его логичнее хранить рядом с сущностью товара:

entities/product/model/types.ts

Если константа используется только фильтром каталога, ей место внутри модуля фильтра:

features/product-filter/model/constants.ts

Глобальными стоит оставлять только действительно общие вещи: настройки окружения, маршруты приложения, базовые типы ответа API или дизайн-токены.

Чем ближе код расположен к месту использования, тем проще понять контекст и безопаснее вносить изменения.

Типичные ошибки при организации проекта

Одна из самых распространённых ошибок — чрезмерная архитектура на старте. Разработчик создаёт десятки уровней вложенности, хотя приложение пока состоит из трёх страниц. В результате добавление обычной кнопки требует открытия пяти каталогов.

Вторая крайность — отсутствие границ вообще. Все компоненты могут импортировать всё, что угодно. Такая свобода удобна первые несколько недель, но затем любое изменение начинает вызывать цепочку неожиданных поломок.

Ещё одна проблема — слишком крупные файлы. Компонент на 600 строк обычно не становится плохим только из-за количества строк, но размер часто показывает, что внутри смешаны отображение, загрузка данных, валидация, расчёты и обработка пользовательских действий.

Не стоит впадать и в обратную крайность, создавая отдельный файл для каждой функции из трёх строк. Хорошая структура уменьшает когнитивную нагрузку, а не увеличивает количество переключений между вкладками.

Наконец, опасно использовать общие модули для бизнес-логики. Если в универсальном Button появляется проверка роли администратора, значит граница проведена неправильно. Кнопка должна уметь отображаться и обрабатывать нажатие, а решение о доступности действия принимает более высокий уровень.

Как понять, что структуру пора менять

Перестройка нужна не потому, что в интернете появилась новая архитектурная методология. Обычно проект сам подаёт довольно заметные сигналы.

Вы постоянно думаете, в какую папку положить новый файл. Одинаковая логика копируется между несколькими страницами. Изменение одной функции требует правок в несвязанных частях приложения. Названия вроде helpers2.js, common.ts и sharedUtils.js начинают встречаться всё чаще. Циклические зависимости становятся привычной частью сборки.

В такой ситуации не обязательно сразу переписывать весь проект. Безопаснее выбрать одну функциональность — например, корзину — и собрать её код в отдельный модуль. После этого можно оценить, стало ли приложение понятнее, и постепенно переносить остальные части.

Большие архитектурные переделки редко удаётся закончить за один подход. Обычно они зависают где-то посередине, и команда месяцами поддерживает две структуры одновременно. Небольшие последовательные изменения работают надёжнее.

Практические правила хорошей структуры

Правильную архитектуру проще проверять не по названиям каталогов, а по нескольким принципам.

Код, который меняется вместе, желательно хранить вместе. Бизнес-логика должна находиться рядом с соответствующей функцией или сущностью. Общие модули не должны зависеть от конкретных страниц. Внутренности модуля лучше скрывать за публичным интерфейсом. А уровень сложности структуры должен соответствовать текущему размеру проекта.

Есть ещё один хороший тест: новый разработчик должен примерно за несколько минут понять, где находятся авторизация, корзина и работа с товарами. Если для этого нужна экскурсия на полтора часа, возможно, структура отражает историю создания проекта, а не его текущую логику.

Куда двигаться дальше

После базового разделения проекта полезно изучить модульную архитектуру, принципы слабого связывания и разделение бизнес-логики от интерфейса. Для крупных frontend-приложений можно посмотреть в сторону Feature-Sliced Design, Clean Architecture или вертикального разделения по функциональности.

Но использовать методологию стоит как инструмент, а не как религию. Если команда понимает границы модулей, контролирует зависимости и быстро находит нужный код, структура уже выполняет свою задачу — даже если она не соответствует популярной схеме до последнего каталога.

Вывод

Правильно структурировать проект — не значит создать максимально подробное дерево папок. Хорошая структура делает изменения предсказуемыми: корзина меняется внутри модуля корзины, работа с товаром находится рядом с товаром, а общая кнопка ничего не знает о бизнес-правилах приложения.

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

И главное — периодически пересматривайте структуру. Проект развивается, и архитектура должна развиваться вместе с ним. Папка, которая была удобной полгода назад, не обязана оставаться удобной навсегда. Это нормально. Ненормально — годами складывать всё в components и надеяться, что глобальный поиск когда-нибудь спасёт ситуацию.

Последние статьи