Перейти к содержимому
Структура и компоненты проекта

Структура и компоненты проекта

Структура проекта

Прежде чем начать полноценную работу, давайте определимся со структурой внутри нашей рабочей директории проекта. Так как проект у нас относительно небольшой и много страниц, между которыми надо будет активно переключаться, содержать не будет, рекомендую обратить внимание на folder-by-type (technical slices), где файлы организуются по следующим принципам:

        • (глобальные стили, CSS-переменные, reset/normalize)
        • Button.tsx
        • Button.test.tsx
        • Button.stories.tsx
        • index.ts
        • HomePage.tsx
        • index.ts
      • http.ts (axios/fetch instance, интерцепторы)
      • userApi.ts (getUser, updateUser, …)
      • index.ts
      • date.ts
      • number.ts
      • index.ts
      • useDebounce.ts
      • useLocalStorage.ts
      • index.ts
      • user.store.ts
      • index.ts
      • index.tsx (lazy/imports, guards)
      • env.ts (чтение VITE_* переменных)
      • constants.ts
      • global.d.ts
    • main.tsx (точка входа, ReactDOM.createRoot)
    • App.tsx (корневой layout, провайдеры)

Кратко по слоям:

  • assets/styles/ — статичные ресурсы и глобальные стили, CSS-переменные, reset/normalize.
  • components/ — переиспользуемые презентационные компоненты (без бизнес-логики).
  • pages/ — маршрутные компоненты, «экраны» приложения.
  • api/ — HTTP-клиент, запросы, DTO/adapter’ы.
  • utils/ — чистые функции без зависимостей от React/DOM.
  • hooks/ — переиспользуемые React-хуки.
  • store/ — глобальное состояние (Zustand/Redux), селекторы.
  • routes/ — конфигурация роутера.
  • config/ — конфигурация окружения, флаги, константы.
  • types/ — общие типы/интерфейсы TS.
При желании можете обратить внимание на FSD 2.1. Feature-Sliced Design (FSD) — это практическая архитектура фронтенда, где код организован по слоям (layers) и предметным срезам (slices). Подобная концепция очень хорошо помогает и подходит для роста и удобного масштабирования проекта, но в небольших она зачастую может оказаться избыточна и только усложнить разработку.

Базовый обзор JSX-компонента

Для начала разберём стартовый файл src/App.tsx, который автоматически сгенерировался вместе с базовым шаблоном React-проекта.

src/App.tsx
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg";
// Глобальное подключение CSS-файла: можно использовать для сброса базовых стилей,
// настройки типографики и т.д.
// Для стилизации компонентов лучше использовать локализованные стили (далее рассмотрим).
import "./App.css";

function App() {
  // Локальное состояние (число кликов). React повторно вызовет компонент при setCount().
  // Для его создания используется хук useState — подробнее см.
  // https://react.dev/reference/react/useState
  const [count, setCount] = useState(0);

  return (
    // Каждый JSX-элемент может быть только с ОДНИМ корневым узлом.
    // Фрагмент <></> (https://react.dev/reference/react/Fragment) позволяет
    // сгруппировать JSX-код, не создавая лишнего DOM-узла.
    <>
      {/* Статические ассеты в Vite импортируются как URL */}
      <div>
        <a href="https://vite.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>

      <h1>Vite + React</h1>

      <div className="card">
        {/* Обновление счётчика на странице безопасным способом (подробнее ниже) */}
        <button onClick={() => setCount((prev) => prev + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>

      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  );
}

export default App;

Обратим внимание на несколько нюансов.

Во-первых, посмотрим на то, как организована структура импортов. viteLogo импортируется из "/vite.svg" — это «абсолютный» путь от корня dev-сервера Vite. А для reactLogo используется относительный импорт из ./assets/react.svg. Оба метода корректны, но в продакшене Vite всё равно перепакует их в хешированные файлы (logo.abc123.svg) и подставит обновлённые варианты URL.

Также определённый интерес вызывает функция счётчика. Лично у меня бы возник вопрос, почему его нельзя увеличивать просто как setCount(count + 1)? Основная проблема заключается в том, что мы не можем в таком случае гарантировать, что значение count ещё не успело устареть, так как обновления могут совершаться очень быстро. Поэтому конструкция useState(0) + setCount(prev => prev + 1) — безопасная форма. Она гарантирует, что мы обновляем актуальную версию состояния.

Если заглянуть в main.tsx, вы там увидите, что App обёрнут в <React.StrictMode>. В режиме разработки он намеренно монтирует компоненты дважды, чтобы поймать возможные сайд-эффекты. В режиме релиза этот двойной вызов отключается.

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

При изменении состояния любого элемента в рамках страницы (если не использовались специальные элементы, как, например, useMemo) происходит перерендер (перерисовка) всего компонента целиком. Путём деления на отдельные компоненты мы уменьшаем область этой перерисовки и сохраняем производительность.

Button

Возникает закономерный вопрос — как вынести повторяющиеся или значимые куски в отдельные компоненты, что позволит достичь изоляции ответственности и переиспользовать их в дальнейшем? В качестве примера спроектируем собственную кнопку с небольшим API (Application Programming Interface) с использованием базовых правил и хороших практик.

Структуру её файлов организуем следующим образом:

        • Button.tsx
        • Button.module.css
        • index.ts

Как мы видим, в названии стилей кнопки появляется приписка .module. Если мы будем продолжать складывать всё в один общий App.css, тогда рано или поздно появится конфликт имён классов: .button в одном месте и .button в другом — это один и тот же глобальный селектор. Чтобы этого избежать, появляется концепция CSS-модулей: имена классов становятся локальными для файла и преобразуются сборкой в уникальные.

src/components/Button/Button.module.css
/* Эти стили принадлежат только компоненту Button: одинаковые имена в других
   компонентах не пересекутся, потому что сборка превратит их в уникальные. */

.button {
  padding: 8px 12px;
  border: none;
  border-radius: 10px;
  cursor: pointer;
}

/* Варианты оформления: переключаются пропом variant */
.primary { background: var(--color-primary, #3b82f6); color: #fff; }
.secondary { background: #eee; color: #222; }

/* Размеры: переключаются пропом size */
.sm { font-size: 14px; }
.md { font-size: 16px; }
.lg { font-size: 18px; }

/* Отключаем события кликов по «выключенной» кнопке */
.disabled {
  opacity: .6;
  pointer-events: none;
}

Имена классов в CSS-модулях будут локализоваться (например, button превратится в button__2k4f1), поэтому .button можно повторять в других компонентах — конфликтов имён не будет.

Также здесь приведён пример использования ещё одного хорошего тона — задание параметров через CSS-переменные. Они позволяют менять состояния компонентов при необходимости (переключать размер или цвет при смене темы). Например, использование --color-primary позволяет позже переключать тему на уровне :root в глобальном CSS, а компоненты уже автоматически считают изменения.

Стили готовы — теперь сама кнопка. Перед этим также не забудем установить библиотеку clsx, которая используется для объединения нескольких CSS-классов. Установить её можно прописав в консоли: npm i clsx.

src/components/Button/Button.tsx
import { type ReactNode } from "react";
// Подключаем ранее упомянутую библиотеку clsx
import clsx from "clsx";
// Импорт CSS-модуля даёт объект "s" со свойствами-классами (s.button, s.primary, ...).
import s from "./Button.module.css";

/**
 * Допустимые значения визуального варианта кнопки.
 * Здесь используется операция объединения (union):
 * https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html
 */
type ButtonVariant = "primary" | "secondary";
/** Допустимые размеры кнопки. */
type ButtonSize = "sm" | "md" | "lg";

/**
 * Пропсы кнопки.
 * - children: контент внутри (текст, иконка и пр.)
 * - variant/size: опциональные, с умолчаниями ("primary", "md")
 * - disabled: делает кнопку неактивной
 * - className: позволяет доклеить свои классы (например, от родителя)
 * - onClick: опциональный обработчик клика
 *
 * Опциональность достигается за счёт указанного рядом с пропсом вопросительного знака.
 */
interface ButtonProps {
  children: ReactNode;
  variant?: ButtonVariant;
  size?: ButtonSize;
  disabled?: boolean;
  className?: string;
  onClick?: () => void; // при желании можно сузить до React.MouseEventHandler<HTMLButtonElement>
}

/**
 * Универсальная кнопка.
 * При помощи знака `=` сразу указываем значения «по умолчанию» для части параметров.
 */
export function Button({
  children,
  variant = "primary", // дефолтный вариант
  size = "md",         // дефолтный размер
  disabled = false,    // по умолчанию кнопка активна
  className,
  onClick,
}: ButtonProps) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      className={clsx(
        s.button,
        s[variant],         // s.primary или s.secondary
        s[size],            // s.sm, s.md или s.lg
        disabled && s.disabled,
        className           // внешние пользовательские классы
      )}
    >
      {children}
    </button>
  );
}
Расширенная версия: принимать все нативные атрибуты

Также можно сделать, чтобы <Button> принимал все нативные (базовые) атрибуты <button>. Для этого необходимо расширить набор базовых свойств:

src/components/Button/Button.tsx (расширенная версия)
import { type ComponentPropsWithoutRef } from "react";

type ButtonVariant = "primary" | "secondary";
type ButtonSize = "sm" | "md" | "lg";

// Базовые нативные пропсы кнопки (type, autoFocus, aria-*, и т.д.)
type NativeButtonProps = ComponentPropsWithoutRef<"button">;

interface ButtonProps extends NativeButtonProps {
  variant?: ButtonVariant;
  size?: ButtonSize;
  // className, disabled и children уже есть в NativeButtonProps,
  // дублировать не обязательно
}

export function Button({
  children,
  variant = "primary",
  size = "md",
  type = "button",   // по умолчанию button, но можем переопределить на submit
  ...rest            // ← onClick, disabled, aria-*, data-* и т.д.
}: ButtonProps) {
  return (
    <button
      type={type}
      {...rest}
      className={clsx(
        s.button,
        s[variant],
        s[size],
        rest.disabled && s.disabled,
        rest.className
      )}
    >
      {children}
    </button>
  );
}

Чтобы этот компонент было удобнее импортировать, рядом создадим файл index.ts, который будет отвечать за реэкспорт модулей Button во весь остальной проект, что сделает импорты существенно более аккуратными.

src/components/Button/index.ts
export * from "./Button";
Автоматически генерировать за вас index.ts файлы может расширение TypeScript Barrel для VS Code.

Дальше возвращаемся в App.tsx и начинаем пользоваться своей кнопкой. Первый вызов оставим без параметров, чтобы увидеть, как работают дефолтные variant и size. Во втором вызове явно переключим оформление и отключим кнопку:

src/App.tsx
import { useState } from "react";
// Используем ранее настроенный алиас @ → src
import { Button } from "@/components/Button";
import "./App.css";

function App() {
  const [count, setCount] = useState(0);

  return (
    <main style={{ display: "grid", gap: 16 }}>
      <h1>Vite + React</h1>

      {/* Состояния по умолчанию: variant="primary", size="md" */}
      <Button onClick={() => setCount((c) => c + 1)}>
        Кликнули {count} раз
      </Button>

      {/* Передаём явные значения и включаем блокировку */}
      <Button variant="secondary" size="lg" disabled>
        Большая неактивная
      </Button>
    </main>
  );
}

export default App;

Header

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

Для отображения иконок воспользуемся пакетом MUI — популярной библиотекой готовых UI-компонентов для React (кнопки, поля ввода, темы, иконки), которая дальше будет ещё также задействоваться для упрощения кастомизации и создания новых стилизованных компонентов без лишней головной боли. Установить его можно следующим образом:

Terminal
npm i @mui/icons-material @mui/material @emotion/react @emotion/styled
src/components/Header/header.types.ts
import { type SvgIconComponent } from "@mui/icons-material";

// Базовая информация, которую храним о каждой из навигационных вкладок
export interface NavItem {
  id: string;
  label: string;
  icon?: SvgIconComponent;
  href?: string;
}

// Превью аватарки пользователя
export interface UserBrief {
  name: string;
  avatarUrl?: string;
}

Для настройки конфигурации навигационных вкладок меню используем ранее созданный интерфейс NavItem:

src/components/Header/header.config.ts
import type { NavItem } from "./header.types";
import { ListAltOutlined, EventNoteOutlined, SettingsOutlined } from "@mui/icons-material";

export const DEFAULT_NAV: NavItem[] = [
  { id: "catalog",  label: "Каталог аудиторий",    icon: ListAltOutlined },
  { id: "bookings", label: "Управление бронированием", icon: EventNoteOutlined },
  { id: "settings", label: "Настройки",            icon: SettingsOutlined },
];

Теперь создадим файл с функциями-хелперами, которые тоже никак не завязаны на React. Там разместим функцию для определения инициалов, если пользователь не установил самостоятельно аватарку:

src/components/Header/header.utils.ts
export function getInitials(name: string | undefined) {
  if (!name) return "NN";
  const [a = "", b = ""] = name.trim().split(/\s+/);
  return (a[0] ?? "").concat(b[0] ?? "").toUpperCase();
}

Теперь можно перейти к разметке и объединению логики поведения всего компонента в целом — в Header.tsx. Стили, как и раньше, разместим рядом в CSS-модуле.

src/components/Header/Header.module.css
.header   { position: sticky; top: 0; background: #fff; border-bottom: 1px solid #eef0f3; z-index: 10; }
.row      { display: flex; align-items: center; gap: 16px; padding: 10px 16px; max-width: 1200px; margin: 0 auto; }

.brand    { display: flex; align-items: center; gap: 10px; color: #0f172a; font-weight: 700; }
.logoBox  { width: 32px; height: 32px; border-radius: 10px; background: #1d4ed8; color: #fff; display: grid; place-items: center; }
.logoIc   { width: 18px; height: 18px; }
.app      { font-size: 18px; }

.nav      { display: flex; align-items: center; gap: 8px; margin-left: 8px; }
.tab      { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; border: none; background: transparent; color: #334155; cursor: pointer; }
.tab:focus-visible { outline: 3px solid rgba(99,102,241,.25); outline-offset: 2px; border-radius: 12px; }
.tabActive { background: #1d4ed8; color: #fff; }
.tabIc    { width: 16px; height: 16px; }

.spacer   { flex: 1; }
.right    { display: flex; align-items: center; gap: 12px; }

.iconBtn  { background: transparent; border: none; padding: 6px; border-radius: 10px; color: #334155; cursor: pointer; }
.iconBtn:active { transform: translateY(1px); }

.avatar   { width: 32px; height: 32px; border-radius: 999px; background: #cdd5df; display: grid; place-items: center; color: #334155; font-weight: 700; }
.userNm   { font-size: 14px; color: #334155; }
src/components/Header/Header.tsx
import clsx from "clsx";
import s from "./Header.module.css";
import type { NavItem, UserBrief } from "./header.types";
import { DEFAULT_NAV } from "./header.config";
import { getInitials } from "./header.utils";
import { DomainRounded, NotificationsNoneOutlined } from "@mui/icons-material";

// Публичный API шапки.
// - navItems: массив вкладок; если не передан — берём DEFAULT_NAV
// - activeNavId: id активной вкладки (подсветка + aria-current)
// - onNavigate: колбэк при клике по вкладке
// - user: данные пользователя (для аватара/инициалов)
// - onBellClick: клик по колокольчику (уведомления)
export interface HeaderProps {
  navItems?: NavItem[];
  activeNavId: string;
  onNavigate: (id: string) => void;
  user?: UserBrief;
  onBellClick?: () => void;
}

export function Header({
  navItems = DEFAULT_NAV,
  activeNavId,
  onNavigate,
  user,
  onBellClick,
}: HeaderProps) {
  return (
    <header className={s.header}>
      <div className={s.row}>

        {/* Левая зона: логотип + название приложения */}
        <div className={s.brand}>
          <div className={s.logoBox} aria-hidden>
            <DomainRounded className={s.logoIc} />
          </div>
          <div className={s.app}>Room Booking</div>
        </div>

        {/* Центральная зона: навигация по вкладкам.
            aria-label даёт подпись навигации для скринридеров. */}
        <nav className={s.nav} aria-label="Основная навигация">
          {navItems.map((item) => {
            const active = item.id === activeNavId;

            return (
              // Кнопки вместо <a>: SPA-навигация, состоянием управляет родитель.
              // При желании можно заменить на <NavLink/>
              <button
                key={item.id}
                type="button"
                className={clsx(s.tab, active && s.tabActive)}
                onClick={() => onNavigate(item.id)}
                aria-current={active ? "page" : undefined}
                title={item.label}
              >
                {item.icon && <item.icon className={s.tabIc} />}
                <span>{item.label}</span>
              </button>
            );
          })}
        </nav>

        <div className={s.spacer} />

        {/* Правая зона: действия и пользователь */}
        <div className={s.right}>
          <button
            type="button"
            className={s.iconBtn}
            onClick={onBellClick}
            aria-label="Уведомления"
            title="Уведомления"
          >
            <NotificationsNoneOutlined />
          </button>

          {/* Аватар пользователя.
              Если есть avatarUrl — показываем картинку;
              иначе — инициалы, рассчитанные утилитой getInitials(name). */}
          <div className={s.avatar} title={user?.name || "Гость"}>
            {user?.avatarUrl ? (
              <img
                src={user.avatarUrl}
                alt=""
                width={32}
                height={32}
                style={{ borderRadius: "999px" }}
              />
            ) : (
              <span>{getInitials(user?.name)}</span>
            )}
          </div>
        </div>
      </div>
    </header>
  );
}

Также добавим короткий реэкспорт, чтобы импортировать компонент и типы одной строкой:

src/components/Header/index.ts
export * from "./Header";
export * from "./header.types";

И встроим его на обновлённую главную страницу приложения:

src/App.tsx
import { useState } from "react";
import { Header } from "@/components/Header";
import "./App.css";

function App() {
  const [active, setActive] = useState("catalog");
  return (
    <>
      {/* Передаём в шапку базовые параметры для её настройки */}
      <Header
        activeNavId={active}
        onNavigate={setActive}
        user={{ name: "Мария Петрова" }}
        onBellClick={() => console.log("bell")}
      />
      <main style={{ padding: 16 }}>Контент вкладки: {active}</main>
    </>
  );
}

export default App;

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