Структура и компоненты проекта
Структура проекта
Прежде чем начать полноценную работу, определимся со структурой рабочей директории проекта. Проект у нас относительно небольшой, множества страниц с активным переключением между ними в нём не появится, поэтому рекомендую обратить внимание на 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.
Базовый обзор JSX-компонента
Для начала разберём стартовый файл src/App.tsx, который автоматически сгенерировался вместе с базовым шаблоном React-проекта.
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg";
// Глобальные стили — сброс и типографика. Стили компонентов держим локальными (далее рассмотрим).
import "./App.css";
function App() {
// Локальное состояние счётчика, см. https://react.dev/reference/react/useState
// React повторно вызовет компонент при setCount().
const [count, setCount] = useState(0);
return (
// JSX требует один корневой узел. Фрагмент <></> группирует без лишнего DOM-узла,
// см. https://react.dev/reference/react/Fragment
<>
{/* Статические ассеты в 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">
{/* prev-форма даёт актуальное значение состояния — разбор ниже */}
<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. Оба метода корректны, но в продакшене ведут себя по-разному. Файлы из public/ (наш vite.svg) Vite не обрабатывает и копирует в dist как есть, без хеширования. А ассеты из src/ (например, react.svg) проходят через сборку и получают хешированные имена вида react.abc123.svg с автоматически обновлёнными URL.
Также определённый интерес вызывает функция счётчика. Лично у меня бы возник вопрос, почему его нельзя увеличивать просто как setCount(count + 1)? Основная проблема заключается в том, что мы не можем в таком случае гарантировать, что значение count ещё не успело устареть, так как обновления могут совершаться очень быстро. Поэтому конструкция useState(0) + setCount(prev => prev + 1) — безопасная форма. Она гарантирует, что мы обновляем актуальную версию состояния.
main.tsx, вы там увидите, что App обёрнут в <React.StrictMode>. В режиме разработки он намеренно монтирует компоненты дважды, чтобы поймать возможные сайд-эффекты. В режиме релиза этот двойной вызов отключается.Как было отмечено ранее, несомненное преимущество React — компонентный подход, благодаря чему мы можем делить единую страницу на набор переиспользуемых компонентов. Это не только упрощает жизнь разработчика, но и повышает производительность нашего продукта. При делении элемента на компоненты мы уменьшаем количество лишних перерендеров.
React.memo) происходит перерендер (перерисовка) всего компонента целиком. Путём деления на отдельные компоненты мы уменьшаем область этой перерисовки и сохраняем производительность. А вот useMemo и useCallback работают внутри компонента — они мемоизируют значения и колбэки.Button
Возникает закономерный вопрос — как вынести повторяющиеся или значимые куски в отдельные компоненты, что позволит достичь изоляции ответственности и переиспользовать их в дальнейшем? В качестве примера спроектируем собственную кнопку с небольшим API (Application Programming Interface) с использованием базовых правил и хороших практик.
Структуру её файлов организуем следующим образом:
-
-
-
- Button.tsx
- Button.module.css
- index.ts
-
-
Как мы видим, в названии стилей кнопки появляется приписка .module. Если мы будем продолжать складывать всё в один общий App.css, тогда рано или поздно появится конфликт имён классов: .button в одном месте и .button в другом — это один и тот же глобальный селектор. Избежать этого помогают CSS-модули, где имена классов становятся локальными для файла и преобразуются сборкой в уникальные.
.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 */
.disabled {
opacity: .6;
pointer-events: none;
}После сборки имя button превратится во что-то вроде button__2k4f1.
--color-primary позволяет позже переключать тему на уровне :root в глобальном CSS, а компоненты автоматически подхватят изменения.Стили готовы — теперь сама кнопка. Перед этим также не забудем установить библиотеку clsx, которая используется для объединения нескольких CSS-классов. Установить её можно командой npm i clsx.
import { type ReactNode } from "react";
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")
* - className: позволяет доклеить свои классы (например, от родителя)
*
* Опциональность достигается за счёт указанного рядом с пропсом вопросительного знака.
*/
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> принимал все нативные (базовые) атрибуты <button>. Для этого необходимо расширить набор базовых свойств:
import { type ComponentPropsWithoutRef } from "react";
import clsx from "clsx";
import s from "./Button.module.css";
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", // внутри формы нативный дефолт — submit, задаём button, чтобы клик не отправлял форму
...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 во весь остальной проект, что сделает импорты существенно более аккуратными.
export * from "./Button";index.ts файлы может расширение TypeScript Barrel Generator для VS Code.Дальше возвращаемся в App.tsx и начинаем пользоваться своей кнопкой. Первый вызов оставим без параметров, чтобы увидеть, как работают дефолтные variant и size. Во втором вызове явно переключим оформление и отключим кнопку:
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 (кнопки, поля ввода, темы, иконки), которая ещё пригодится для упрощения кастомизации и создания новых стилизованных компонентов без лишней головной боли. Установить его можно следующим образом:
npm i @mui/icons-material @mui/material @emotion/react @emotion/styledimport { 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:
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. Там разместим функцию для определения инициалов, если пользователь не установил самостоятельно аватарку:
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-модуле.
.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; }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-навигация, состоянием управляет родитель.
<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 */}
<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>
);
}Также добавим короткий реэкспорт, чтобы импортировать компонент и типы одной строкой:
export * from "./Header";
export * from "./header.types";И встроим его на обновлённую главную страницу приложения:
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.