К списку уроков

Урок 11. Роутинг и архитектура: SPA, React Router и Feature-Sliced Design

Онлайн и в Б-734, 2 марта (понедельник), 15:00

Предисловие

Для вашего удобства текстовый курс перенесен на отдельную платформу. Ссылка для поступления: тык

Мы настоятельно рекомендуем пользоваться именно этой платформой по ходу курса. Ссылка на текущее занятие: тык

⚠️Прежде чем переходить к заданиям - обязательно зарегистрируйтесь на курс. Иначе вас закинет на общий поток и вы не сможете взаимодействовать с сверстниками во втором модуле курса. Это не смертельно и вполне решаемо, но тем не менее.

Роутинг и архитектура: SPA, React Router и Feature-Sliced Design

Многостраничный сайт и SPA

Сайт из первого модуля состоял из HTML-страниц. Каждая ссылка вела на новый документ: браузер отправлял запрос на сервер, получал HTML и строил страницу заново. При этом пропадало всё, что было на старой странице: введённый текст, открытые окна, загруженные данные.

SPA (single-page application, одностраничное приложение) устроено иначе. Браузер загружает HTML-документ один раз. Дальше JavaScript сам меняет содержимое страницы и адрес в адресной строке, а данные получает через API. ITAM Board — одностраничное приложение.

У SPA есть преимущества:

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

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

  • сервер отдаёт только данные, а интерфейс целиком строится в браузере.

При этом SPA должно вести себя как обычный сайт: у каждого раздела свой адрес, ссылку на раздел можно отправить другу или открыть в новой вкладке, а кнопки «Назад» и «Вперёд» браузера работают.

Посмотрите на это во вкладке Network любого современного веб-приложения. При переходах между разделами не появляется запросов типа document, только запросы fetch к API. Адрес в строке браузера меняется, но страница не перезагружается.

Почитать ещё

Адрес без перезагрузки

Менять адрес без перезагрузки страницы позволяет History API браузера. React Router использует его внутри, но полезно понимать, как он работает.

history.pushState(null, '', '/cards/42');
console.log(location.pathname);

window.addEventListener('popstate', () => {
  console.log('Пользователь перешёл назад или вперёд:', location.pathname);
});
  • history.pushState(состояние, заголовок, адрес) добавляет в историю браузера новую запись с указанным адресом. Адрес в строке меняется, но браузер не отправляет запрос и не загружает новую страницу.

  • location.pathname содержит путь текущего адреса.

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

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

Если перезагрузить страницу или открыть ссылку в новой вкладке, браузер отправит на сервер обычный запрос по этому адресу. Как сделать так, чтобы сервер не ответил на него ошибкой 404, разберём в разделе про страницу 404.

Почитать ещё

  • Дока: window.history, window.location

Страница и компонент

В React-приложении с маршрутизацией страница — это компонент, который выбирается по адресу. В ITAM Board четыре страницы:

Адрес

Страница

/

Доска с колонками карточек

/cards/:cardId

Редактирование карточки

/users/:userId

Профиль пользователя и его карточки

любой другой

«Страница не найдена»

Страница отличается от остальных компонентов:

  • у страницы есть адрес, на неё можно дать ссылку и открыть её в новой вкладке;

  • страница получает параметры из адреса, например cardId, и по ним находит нужные данные;

  • страница в основном собирает экран из других компонентов и почти не содержит собственной разметки.

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

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

Почитать ещё

React Router

React Router — библиотека маршрутизации для React. Она сопоставляет адрес в строке браузера с компонентом и показывает нужную страницу.

npm install react-router

Маршруты описывают компонентами:

import { BrowserRouter, Route, Routes } from 'react-router';
import { BoardPage } from './pages/BoardPage';
import { CardPage } from './pages/CardPage';
import { NotFoundPage } from './pages/NotFoundPage';
import { UserPage } from './pages/UserPage';

export function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route index element={<BoardPage />} />
        <Route path="cards/:cardId" element={<CardPage />} />
        <Route path="users/:userId" element={<UserPage />} />
        <Route path="*" element={<NotFoundPage />} />
      </Routes>
    </BrowserRouter>
  );
}
  • BrowserRouter оборачивает приложение и следит за адресом в строке браузера.

  • Routes выбирает среди вложенных маршрутов тот, который подходит к текущему адресу, и показывает его компонент.

  • Route описывает один маршрут: path — путь, element — что показать.

  • index — маршрут для корневого адреса /.

  • path="*" подходит к любому адресу, для которого не нашлось другого маршрута. На нём показывают страницу 404.

Порядок маршрутов внутри Routes не важен: React Router выбирает самый точный подходящий маршрут, а не первый по списку. Поэтому маршрут * сработает, только если никакой другой не подошёл.

В старых статьях встречается пакет react-router-dom. В современных версиях для веб-приложений достаточно одного пакета react-router.

Почитать ещё

Параметры маршрута

Часть пути может быть параметром. Параметр записывают через двоеточие: маршрут cards/:cardId подходит к адресам /cards/42, /cards/abc и любым другим адресам того же вида.

Значение параметра страница получает хуком useParams:

import { useParams } from 'react-router';
import { mockCards } from '../mock-cards';

export function CardPage() {
  const { cardId = '' } = useParams();
  const card = mockCards.find((item) => item.id === cardId);

  if (!card) {
    return <p>Карточка не найдена. Возможно, её удалили.</p>;
  }

  return <h1>Редактирование карточки «{card.title}»</h1>;
}
  • useParams возвращает объект, в котором ключи — имена параметров из пути.

  • Значения параметров — строки. Если в адресе /cards/42, в cardId будет строка '42', а не число.

  • Тип значения — string | undefined: TypeScript не знает, что компонент показан именно на маршруте с cardId. Поэтому при деструктуризации задают значение по умолчанию '', и дальше работают со строкой.

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

Кроме параметров пути, адрес может содержать параметры запроса, например /?sort=top. Их читают хуком useSearchParams. В ITAM Board параметры запроса не используются, но этот хук пригодится, если вы захотите сохранять фильтры в адресе.

Почитать ещё

Общий макет страниц

У всех страниц ITAM Board одинаковая шапка. Чтобы не вставлять её в каждую страницу, маршруты оборачивают в макет — маршрут, который рисует общую часть и место для вложенной страницы.

export function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route element={<Layout />}>
          <Route index element={<BoardPage />} />
          <Route path="cards/:cardId" element={<CardPage />} />
          <Route path="users/:userId" element={<UserPage />} />
          <Route path="*" element={<NotFoundPage />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}
import { Outlet } from 'react-router';
import { Header } from './Header';
import styles from './Layout.module.css';

export function Layout() {
  return (
    <>
      <Header />
      <main className={styles.main}>
        <Outlet />
      </main>
    </>
  );
}
  • Внешний Route без path, но с element={<Layout />}, называется маршрутом-макетом. Он не добавляет ничего к адресу, а только оборачивает вложенные маршруты.

  • Компонент Outlet показывает страницу вложенного маршрута, который подошёл к адресу.

  • При переходе между страницами Layout остаётся на месте и не монтируется заново: меняется только содержимое Outlet.

В макет выносят всё, что общее для страниц: шапку, подвал, боковое меню. На пятнадцатом занятии в макет ITAM Board добавится проверка токена: если сервер не узнал пользователя, вместо Outlet появится сообщение об ошибке.

Макеты можно вкладывать друг в друга. Например, раздел настроек может иметь своё боковое меню внутри общего макета приложения.

Почитать ещё

Ссылки

Переходы внутри приложения делают компонентом Link:

import { Link } from 'react-router';

export function Header() {
  return (
    <header>
      <Link to="/">ITAM Board</Link>
      <Link to="/users/user-1">Мой профиль</Link>
    </header>
  );
}

Link отображается как обычная ссылка <a href>. Поэтому с ней работает всё, что умеют ссылки: открытие в новой вкладке, копирование адреса, переход по нажатию колёсика мыши. Но обычный переход по нажатию Link перехватывает: вместо загрузки новой страницы React Router меняет адрес через History API и показывает нужный маршрут.

Обычная ссылка <a href="/users/user-1"> внутри приложения перезагрузит страницу. Приложение загрузится заново, и всё его состояние пропадёт. Поэтому для переходов внутри приложения используют Link, а <a> оставляют для ссылок на внешние сайты.

Для навигационного меню есть компонент NavLink. Он работает как Link, но знает, активна ли ссылка, то есть совпадает ли её адрес с текущим:

import { NavLink } from 'react-router';

<NavLink to="/" className={({ isActive }) => (isActive ? 'nav__link nav__link--active' : 'nav__link')}>
  Доска
</NavLink>;

В className передают функцию, которая получает isActive и возвращает классы. Так текущий раздел выделяют в меню.

Почитать ещё

Адреса страниц в одном месте

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

Адреса собирают в одном файле:

// src/shared/config/routes.ts
export const routes = {
  board: () => '/',
  card: (cardId: string) => `/cards/${cardId}`,
  user: (userId: string) => `/users/${userId}`,
};
<Link to={routes.board()}>← К доске</Link>
<Link to={routes.user(card.author.id)}>{card.author.name}</Link>
  • Каждый адрес — функция. Параметры адреса становятся параметрами функции, и TypeScript не даст забыть идентификатор.

  • Если путь страницы изменится, например /users/:userId превратится в /people/:userId, достаточно поправить одну строку в routes и путь маршрута в App.

Пути маршрутов в App записываются шаблонами с двоеточием (cards/:cardId), а функции в routes возвращают конкретные адреса (/cards/42). Держите их рядом, чтобы при изменении не забыть поправить обе записи.

Почитать ещё

Переходы из кода

Иногда переход должен произойти не по нажатию на ссылку, а в результате действия: после сохранения формы, после удаления карточки, при нажатии на кнопку «Редактировать» в модальном окне. Для этого есть хук useNavigate.

import { useNavigate } from 'react-router';
import { routes } from '@/shared/config';

export function CardModal({ cardId, onClose }: Props) {
  const navigate = useNavigate();

  return (
    <Modal title="Карточка" onClose={onClose}>
      <Button onClick={() => navigate(routes.card(cardId))}>Редактировать</Button>
    </Modal>
  );
}
  • useNavigate возвращает функцию navigate.

  • navigate(адрес) переходит по адресу так же, как Link: без перезагрузки и с новой записью в истории браузера.

  • navigate(-1) возвращает на предыдущую запись истории, как кнопка «Назад».

  • navigate(адрес, { replace: true }) заменяет текущую запись истории вместо добавления новой. Так делают, например, после удаления карточки: возвращаться кнопкой «Назад» на страницу удалённой карточки бессмысленно.

Используйте Link везде, где пользователь просто переходит на другую страницу. navigate нужен только для переходов, которые происходят после выполнения какого-то действия. Кнопка с navigate не открывается в новой вкладке и не показывает адрес при наведении, как ссылка.

Почитать ещё

  • React Router: useNavigate (англ.)

Страница 404 и публикация SPA

Маршрут с path="*" показывает страницу для адресов, которых нет в приложении:

import { Link } from 'react-router';
import { routes } from '@/shared/config';

export function NotFoundPage() {
  return (
    <div>
      <h1>Страница не найдена</h1>
      <p>Такой страницы нет. Возможно, ссылка устарела.</p>
      <Link to={routes.board()}>← К доске</Link>
    </div>
  );
}

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

Кроме адресов, которых нет вовсе, бывают адреса, которые есть, но данные по ним не найдены: /cards/несуществующий-id. Для них страница карточки сама показывает сообщение «Карточка не найдена», потому что маршрут подошёл, а данных нет.

У SPA есть особенность при публикации. В приложении один HTML-файл index.html, а адресов много. Если пользователь откроет ссылку /cards/42 или обновит страницу на этом адресе, браузер запросит у сервера путь /cards/42. Сервер начнёт искать такой файл, не найдёт его и ответит ошибкой 404, а до React Router дело не дойдёт.

Поэтому сервер для SPA настраивают так, чтобы на любой адрес, для которого нет файла, он отдавал index.html. Сервер разработки Vite делает это сам. Как настроить это при публикации приложения, разберём на шестнадцатом занятии.

Почитать ещё

Зачем договариваться о структуре проекта

Пока в проекте десяток файлов, их можно держать в одной папке components. Когда файлов становится сотня, появляются вопросы:

  • где искать форму карточки: в components, в forms или рядом со страницей?

  • можно ли импортировать модальное окно карточки в шапку?

  • почему изменение кнопки сломало страницу пользователя?

  • куда положить функцию, которая нужна и доске, и профилю?

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

Архитектура фронтенд-проекта — это договорённость о том, как раскладывать код по папкам и какие части проекта могут зависеть от каких. Хорошая архитектура отвечает на вопрос «где это лежит?» без поиска по проекту и ограничивает, какие файлы затронет изменение.

Одна из распространённых договорённостей — Feature-Sliced Design (FSD). ITAM Board разложен по FSD, и на этом занятии мы переложим по нему наш проект. FSD — не единственный подход: многие команды используют собственные правила. Но идеи, на которых построен FSD, полезны в любой структуре.

Почитать ещё

Слои Feature-Sliced Design

FSD делит код проекта на слои. Каждый слой — папка внутри src. Слои расположены сверху вниз: чем выше слой, тем больше он знает о конкретном приложении, чем ниже — тем он универсальнее.

src/
├── app/        запуск приложения: роутер, макет, глобальные стили
├── pages/      страницы: board, card, user, not-found
├── widgets/    крупные самостоятельные блоки страниц: header, board-columns, card-modal
├── features/   действия пользователя: card-form, vote, filter-cards, add-comment
├── entities/   сущности предметной области: card, user, comment, session
└── shared/     общий код без привязки к предметной области: ui, api, lib, config
  • app — всё, что нужно для запуска: main.tsx, компонент App с маршрутами, макет, глобальные стили.

  • pages — страницы целиком. Страница собирает экран из виджетов и читает параметры адреса.

  • widgets — крупные блоки, собранные из сущностей и действий: шапка с текущим пользователем, колонки доски, модальное окно карточки с голосованием и комментариями.

  • features — действия, которые выполняет пользователь: проголосовать, отфильтровать карточки, написать комментарий, заполнить форму карточки.

  • entities — сущности, с которыми работает приложение: карточка, пользователь, комментарий. Здесь описано, как сущность выглядит, как её загрузить и где хранить.

  • shared — код, который ничего не знает о карточках и пользователях: кнопка, модальное окно, индикатор загрузки, функции форматирования дат, клиент API, адреса страниц.

Не в каждом проекте нужны все слои. Если действий пользователя нет, слоя features не будет. Не создавайте пустые слои заранее.

Почитать ещё

Как выбрать слой

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

  1. Это запуск приложения, маршруты или глобальные стили? Слой app.

  2. У этого есть свой адрес? Слой pages.

  3. Это код, который ничего не знает о предметной области приложения? Кнопка, поле формы, функция склонения слов, клиент API. Слой shared.

  4. Это описание одной сущности: как она выглядит, как её загрузить и хранить? Превью карточки, запросы карточек, профиль пользователя. Слой entities.

  5. Это действие пользователя, которое что-то меняет? Кнопки голосования, форма карточки, фильтры, удаление комментария. Слой features.

  6. Это крупный блок страницы, который объединяет несколько сущностей и действий? Модальное окно карточки с голосованием и комментариями. Слой widgets.

Несколько примеров из ITAM Board:

Код

Слой

Почему

Button, Modal, Loader

shared/ui

Не знают ничего о карточках

formatDateTime, plural, cn

shared/lib

Вспомогательные функции общего назначения

CardPreview, TypeBadge

entities/card

Показывают карточку, но ничего с ней не делают

VoteButtons

features/vote

Действие: проголосовать

CardForm

features/card-form

Действие: создать или изменить карточку

CardModal

widgets/card-modal

Собирает подробности карточки, голосование и комментарии

BoardPage

pages/board

Страница по адресу /

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

Почитать ещё

Слайсы и сегменты

Внутри слоёв pages, widgets, features и entities код делится на слайсы — папки по смыслу. Например, в слое entities слайсы card, user, comment, а в слое features — vote, card-form, filter-cards. Имена слайсов пишут строчными буквами через дефис.

Внутри слайса код делится на сегменты по назначению:

entities/card/
├── api/      cards-api.ts        запросы к серверу
├── model/    filter-cards.ts     данные, состояние и логика
├── lib/      columns.ts          вспомогательные функции и константы
├── ui/       CardPreview.tsx     компоненты
│             CardPreview.module.css
└── index.ts                      публичный API слайса

Сегмент

Что в нём лежит

ui

Компоненты и их стили

api

Функции запросов к серверу

model

Типы, состояние, сторы, бизнес-логика

lib

Вспомогательные функции и константы слайса

config

Настройки

У слоёв app и shared слайсов нет, они сразу делятся на сегменты: shared/ui, shared/api, shared/lib, shared/config.

Не все сегменты нужны каждому слайсу. У слайса features/vote может быть только сегмент ui с компонентом VoteButtons.

Почитать ещё

Правила импортов и публичный API

Слои работают только при соблюдении двух правил.

Правило 1. Модуль импортирует только из слоёв ниже своего. Порядок слоёв: app → pages → widgets → features → entities → shared. Страница может импортировать виджет, виджет — действие, действие — сущность, всё — shared. В обратную сторону импортировать нельзя: кнопка из shared не знает о карточках, а сущность card не импортирует действие vote.

Слайсы одного слоя тоже не импортируют друг друга. Действие vote не импортирует действие card-form. Если двум слайсам нужно работать вместе, их объединяет слой выше: виджет или страница.

Благодаря этому правилу изменения распространяются предсказуемо. Если поменять CardPreview в entities/card, затронуты только слои выше, которые его используют. shared и другие сущности от этого не пострадают.

Правило 2. В другой слайс заходят только через его публичный API. Публичный API слайса — файл index.ts в его корне. В нём перечислено всё, что слайс разрешает использовать снаружи:

// src/entities/card/index.ts
export { COLUMNS, TYPE_LABELS } from './lib/columns';
export { groupByColumn } from './model/filter-cards';
export { CardDetails } from './ui/CardDetails';
export { CardPreview } from './ui/CardPreview';
// Правильно: импорт через публичный API
import { CardPreview, COLUMNS } from '@/entities/card';

// Неправильно: импорт внутреннего файла чужого слайса
import { CardPreview } from '@/entities/card/ui/CardPreview';

Внутренние файлы слайса можно переименовывать и переносить, пока его index.ts экспортирует то же самое. Снаружи ничего не сломается.

Внутри своего слайса файлы импортируют друг друга относительными путями: import { TYPE_LABELS } from '../lib/columns'.

Почитать ещё

Алиас @/

При глубокой вложенности папок относительные импорты становятся длинными: import { Button } from '../../../shared/ui'. Такой путь трудно читать, и он ломается при переносе файла. Алиас — короткое имя, которое заменяет путь к папке. В проектах на Vite часто используют алиас @/ для папки src/.

import { Button } from '@/shared/ui';
import { CardPreview } from '@/entities/card';

Алиас настраивают в двух местах. Vite должен уметь найти файлы при сборке, а TypeScript и редактор — при проверке типов и подсказках.

// vite.config.ts
import { fileURLToPath, URL } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
  },
});
// tsconfig.app.json, внутри compilerOptions
"paths": { "@/*": ["./src/*"] }
  • В vite.config.ts алиас @ указывает на абсолютный путь к папке src. Функции fileURLToPath и URL из модуля Node.js node:url вычисляют этот путь относительно файла конфигурации. Чтобы TypeScript знал типы модулей Node.js, установите пакет с типами: npm install -D @types/node.

  • В tsconfig.app.json настройка paths сообщает TypeScript, что импорты, которые начинаются с @/, нужно искать в src/.

Если настроить только Vite, проект соберётся, но редактор подчеркнёт импорты ошибкой. Если настроить только TypeScript, редактор будет доволен, а сборка упадёт. После изменения tsconfig.app.json перезапустите сервер разработки.

Почитать ещё

Переход проекта на FSD

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

  1. Настройте алиас @/ в vite.config.ts и tsconfig.app.json и проверьте, что проект собирается.

  2. Создайте shared. Перенесите общие компоненты в shared/ui: кнопку, модальное окно, аватар. Функции вроде cn и форматирования дат — в shared/lib, адреса страниц — в shared/config. Типы данных API — в shared/api.

  3. Создайте entities. Для карточки — entities/card с превью, плашкой типа, константами колонок и функцией группировки. Для пользователя — entities/user с профилем.

  4. Создайте features. Голосование — features/vote, форма карточки — features/card-form.

  5. Создайте widgets. Колонки доски, модальное окно карточки, шапку.

  6. Создайте pages и app. Страницы — в pages, по слайсу на страницу. main.tsx, App.tsx, макет и глобальные стили — в app.

  7. Добавьте index.ts в каждый слайс и замените импорты между слайсами на импорты через @/ и публичный API.

  8. Проверьте импорты. Найдите в проекте импорты, которые идут вверх по слоям или заходят во внутренние файлы чужих слайсов. Запустите npm run build.

VS Code помогает при переносе: если перетащить файл в другую папку в дереве проекта, редактор предложит обновить импорты во всех файлах, которые его используют.

Раскладка по FSD не меняет поведение приложения. После переноса всё должно работать так же, как до него. Если что-то сломалось, ищите ошибку в путях импортов.

Почитать ещё

Задания