08 дек. 2025 г.·6 мин. чтения

Слой сервисов во фронтенде: отделите вызовы API от UI-компонентов

Узнайте, как слой сервисов во фронтенде выносит логику fetch из компонентов, стандартизирует ошибки и делает изменения более безопасными и быстрыми.

Слой сервисов во фронтенде: отделите вызовы API от UI-компонентов

Почему вызовы API внутри компонентов превращаются в баги

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

Когда каждый экран реализует свою логику запросов, вы дублируете мелкие решения, которые должны быть единообразными. Одна компонента повторяет попытку при 401, другая выкидывает пользователя. Одна отправляет Content-Type: application/json, другая забывает это. Одна считает пустой ответ успехом, другая падает на await res.json().

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

Ещё одна проблема — скрытая связанность. UI начинает зависеть от деталей эндпоинтов, которые должны быть приватными: точные URL, query-параметры, форма заголовков и форматы ответов. Позже, если бэкенд сменит user_id на id, вы изменяете не одно место вызова, а ищете по всем экранам, модалкам и хукам, не пропустили ли вы где-то.

Симптомы обычно выглядят так:

  • Заголовки, которые трудно обновлять (токен авторизации, версия приложения, tenant id)
  • Разные сообщения об ошибках на разных экранах
  • Баги авторизации, которые проявляются только в отдельных потоках
  • Состояния загрузки, которые зависают после исключения
  • «Работает на одной странице» поведение API

Небольшой пример: экран профиля и экран биллинга оба делают fetch /me. Один добавляет заголовок авторизации из localStorage, другой использует устаревшее значение из памяти. Пользователи видят «Пожалуйста, войдите» на биллинге, а профиль всё ещё загружается.

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

Что такое слой сервисов (простыми словами)

Слой сервисов — это небольшой набор файлов, который отвечает за общение с бэкендом. Вместо того чтобы каждая компонента строила свой URL, заголовки и fetch-вызов, приложение делает запросы через общие функции вроде getUser(), updateProfile() или createInvoice().

Это не фреймворк и не требует новых зависимостей. Думайте о нём как о простых модулях JavaScript или TypeScript, которые находятся между UI и бэкендом. Можно начать с одного файла (например, apiClient.ts) и добавлять по мере роста приложения (например, authService.ts, billingService.ts).

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

Компоненты при этом становятся проще. Им больше не важно, как именно работают HTTP-вызовы. Они запрашивают данные или действия и рендерят результат.

Вместо такого кода в компоненте:

const res = await fetch(`/api/users/${id}`, {
  method: "GET",
  headers: { Authorization: `Bearer ${token}` }
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "Failed");

Вы получаете UI-код, который читается как поток действий пользователя:

const user = await userService.getUser(id);

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

Что должно быть в UI и что — в слое сервисов

Простое правило: UI решает, что показывать, а слой сервисов решает, как говорить с сервером. Когда это смешивается, компоненты становятся сложнее для чтения и легче ломаются.

В UI держите работу, привязанную к экрану: локальное состояние (loading, data, error), запуск действий по клику или при загрузке страницы и показ обратной связи (toasts, inline-ошибки, пустые состояния). Компонента не должна знать, использует ли запрос fetch, какие заголовки нужны или как интерпретировать странную ошибку от бэкенда.

В слое сервисов помещайте части, которые должны быть единообразными везде:

  • Построение запросов (URL, метод, заголовки, токен авторизации, тело)
  • Парсинг ответов (JSON vs пустое тело, коды статусов)
  • Преобразование сбоев в небольшой набор типов ошибок, которые UI понимает
  • Возврат предсказуемой формы результата, чтобы каждый экран обрабатывал её одинаково

Решите заранее, как вы представляете результаты. Обычный вариант: сервис возвращает либо { data }, либо { error }, а UI хранит loading. Это держит сервисы сфокусированными, а логику UI предсказуемой.

Названия тоже важны. Выберите один стиль и придерживайтесь его, например userService.getProfile() (что делает) или ordersApi.create() (какой ресурс затрагивает). Смешение стилей затрудняет поиск кода позже.

Конкретный пример: если форма входа должна показать «Неверный пароль» или «Сетевая ошибка», сервис должен переводить сырые ответы в INVALID_CREDENTIALS или NETWORK. UI тогда просто выбирает правильное сообщение.

Пошагово: рефакторим один API-вызов, не ломая UI

Начните с малого. Выберите один эндпоинт, который используется в более чем одном месте, например «получить текущего пользователя» или «загрузить проекты». Такие эндпоинты идеальны, потому что вы можете проверить изменение на двух экранах.

Предположим, у вас есть fetch('/api/me'), дублированный в хедере и на странице настроек. Ваша цель — сохранить поведение UI, переместив сетевую логику в слой сервисов.

1) Перенесите fetch в сервисную функцию

Создайте файл, например services/userService.ts (название не важно, важно единообразие).

// services/userService.ts
export async function getMe() {
  const res = await fetch('/api/me', { credentials: 'include' });
  const data = await res.json().catch(() => null);

  if (!res.ok) {
    return { ok: false, error: data?.error || 'Request failed', status: res.status };
  }

  return { ok: true, data };
}

Обратите внимание: форма возвращаемого значения всегда предсказуемая: { ok: true, data } или { ok: false, error }. Одно такое решение снимает множество вопросов «а что мне тут проверять?» и предотвращает баги.

2) Замените старые блоки и сохраните вывод UI таким же

Обновите каждый компонент, чтобы он вызывал getMe() и сохранял прежние состояния загрузки, успеха и ошибки.

Безопасный путь рефакторинга:

  • Замените inline fetch на await getMe()
  • Сопоставьте старые обновления состояния с новым результатом (if (result.ok) setUser(result.data) else setError(result.error))
  • Сохраните те же спиннеры, уведомления и пустые состояния
  • Протестируйте оба места, где используется эндпоинт
  • Только после этого удалите старый код с fetch

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

Стандартизируйте построение запросов

Укрепить безопасность при рефакторинге
Мы ищем открытые секреты, риски SQL-инъекций и небезопасные паттерны, характерные для ИИ-кода.

Когда каждый компонент сам строит запрос, мелкие различия накапливаются: один вызов забывает заголовок auth, другой отправляет неправильный content-type, третий использует слегка иной базовый URL. Слой сервисов решает это, давая одну «дверь» к API.

Начните с одного обёрточного запроса (API-клиента), который владеет скучными деталями. Компоненты должны передавать только то, что уникально: эндпоинт, метод и любые данные.

Хороший строитель запросов обычно обрабатывает в одном месте:

  • Базовый URL и общие заголовки (например, Accept: application/json)
  • Токены авторизации (читать из хранилища, прикреплять в заголовок, при необходимости обновлять)
  • Таймауты и ID запросов (чтобы вызовы не висели вечно и можно было отследить проблему)
  • Query-параметры (кодировать последовательно)
  • JSON-тела (строгое stringified с правильным content-type)

Аутентификация часто даёт наибольшую выгоду. Вместо того чтобы раскидывать логику Authorization по UI, пусть клиент автоматически прикрепляет токен. Если токен может истечь, держите логику обновления внутри клиента. Тогда профиль и биллинг будут вести себя одинаково, а изменение авторизации — одна правка.

Будьте строгими в том, как вы передаёте params и body. Например, решите, что GET принимает params, а POST/PUTbody, и клиент это контролирует. Это предотвращает распространённую ошибку «почему сервер получает пустое тело?».

Конкретный пример: поле поиска пользователей может вызывать searchUsers({ q, page }). UI просто передаёт q и page. Клиент превращает это в GET /users/search?q=...&page=..., добавляет заголовки, прикрепляет auth, применяет таймаут и добавляет request ID. Если позже API переедет на новый домен, меняете только базовый URL.

Стандартизируйте ответы и обработку ошибок

Если каждая компонента решает, что значит «успех», UI наполняется мелкими правилами: иногда читают data, иногда user, иногда проверяют ok. Слой сервисов работает лучше, когда он всегда возвращает один и тот же формат UI, чтобы компоненты оставались простыми.

Нормализация успешных ответов

Выберите контракт для того, что получает UI. Сервисные функции либо возвращают распаршенную полезную нагрузку напрямую, либо возвращают предсказуемую оболочку вроде { data, meta }. Большинство команд делает UI чище, возвращая полезную нагрузку напрямую.

Будьте строгими: если один эндпоинт возвращает { user: {...} }, а другой { data: {...} }, нормализуйте их внутри сервиса, чтобы компонент всегда получал один и тот же тип значения.

Создайте единый формат ошибки, который UI может отобразить

Не бросайте в одном месте случайные строки, а в другом — объекты Response. Определите единый объект ошибки, который UI сможет рендерить без догадок.

export type ApiError = {
  kind: "auth" | "forbidden" | "not_found" | "rate_limited" | "server" | "network" | "unknown";
  message: string;
  status?: number;
  requestId?: string;
};

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

  • 401: попросить пользователя войти снова (kind: auth)
  • 403: показать «У вас нет доступа» (kind: forbidden)
  • 404: показать «Не найдено» и прекратить повторы (kind: not_found)
  • 429: показать «Слишком много запросов» и предложить подождать (kind: rate_limited)
  • 500+: показать спокойный запасной экран и позволить повтор (kind: server)

Для отладки логируйте полезный контекст: статус, имя эндпоинта и request id, если он есть. Не логируйте токены или полезные данные, которые могут содержать секреты. Пользователи должны получать дружелюбное сообщение, а разработчики — детали.

Ретраи, кеширование и отмена без лишнего шума

Когда API-вызовы находятся в одном месте, вы можете добавить «затычки качества» без правок в каждом экране. UI остаётся сосредоточенным на состояниях загрузки и рендеринге, а сервисы обрабатывают грязную работу.

Простое кеширование, чтобы избежать двойных запросов

Не каждый запрос нуждается в кешировании, но немного кеша предотвращает частые неприятности, например повторный запрос профиля при переключении вкладок. Практичный подход — небольшой in-memory кеш с коротким временем жизни (например, 10–30 секунд) для чтений, которые редко меняются.

Конкретный пример: дашборд и страница настроек оба запрашивают /me. Если они монтируются близко во времени, можно вернуть cached результат вместо отправки двух запросов и гонки ответов.

Ретраи, но только когда это безопасно

Ретраи должны быть исключением, а не правилом. Повторение «чтения» (GET) после сетевой нестабильности обычно безопасно. Повторение «записи» (POST, PUT, DELETE) может создать дубликаты или нежелательные изменения.

Держите правила повторов в слое сервисов, чтобы компоненты не придумывали своё поведение:

  • Ретрай только безопасных методов (обычно GET) и только при сетевых ошибках или ответах 5xx.
  • Используйте небольшой лимит (1–2 повтора) и короткую задержку.
  • Никогда автоматически не ретрайте ошибки аутентификации (401).

Отмена для быстрой навигации и поиска

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

Использование AbortController в слое сервисов делает отмену последовательной:

export function searchUsers(query, { signal } = {}) {
  return api.get('/users/search', { params: { q: query }, signal });
}

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

Распространённые ошибки, которых следует избегать

Получить реалистичный график
Большинство проектов FixMyMess завершаются за 48–72 часа после ознакомления с кодом.

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

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

Ещё одна ошибка — возвращать UI сырые Response объекты от fetch. Это заставляет каждый компонент помнить, когда вызывать json(), как проверять ok и что делать с кодами статусов. UI должен получать простые данные (или чёткую ошибку), а не низкоуровневый сетевой объект.

Следите за дрейфом в именованиях и формах. Если одна функция возвращает { user }, другая — { data: user }, а третья — просто user, баги проявляются только во время выполнения. Выберите паттерн и придерживайтесь его во всех файлах.

Ошибки — место, где многие приложения становятся грязными. Если сервис прячет ошибки и возвращает null или пустой массив «на всякий случай», UI не сможет корректно отреагировать. Интерфейсу нужно знать разницу между «нет результатов» и «запрос упал».

И наконец, избегайте привязки сервисов к одному экрану. Если вы называете функции в духе страниц (getSettingsPageData) или прячете предположения о UI в параметрах, повторное использование становится сложнее, а рефакторинг — медленнее.

Быстрый чек-лист перед мерджем

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

  • UI-компоненты вызывают сервисную функцию, а не fetch или низкоуровневый клиент напрямую.
  • Каждая сервисная функция имеет один очевидный контракт: ясный вход и единая форма выхода.
  • Ошибки переводятся в одном месте в небольшой набор типов ошибок или сообщений на уровне приложения.
  • Общие детали запросов живут в одном месте: базовый URL, заголовки авторизации, общие query-параметры, таймауты.
  • Ничего чувствительного не захардкожено во фронтенде (токены, API-ключи, временные креды).

Простой тест: откройте обновлённую компоненту и спросите — «Я могу поменять эндпоинт, не трогая этот UI-файл?» Если ответ нет, граница, вероятно, протекает.

Пример: почистить прототип с разбросанными fetch-вызовами

Покончить с проблемой «работает на одной странице»
Если приложение работает в демо, но падает в продакшне — мы быстро стабилизируем его.

Обычно в прототипах, сгенерированных ИИ (из инструментов вроде Lovable, Bolt, v0, Cursor или Replit), один и тот же блок fetch копируется во многие компоненты. В одном экране заголовок чуть другой. В другом — парсинг JSON другой. В третьем показывают toast при ошибке, а в остальных — ничего не делают. В демо всё работает, а в продакшне ломается, как только появляется реальная авторизация, реальные ошибки и реальные пользователи.

В одном таком прототипе fetch дублировался в 12 компонентах. Баги были мелкие, но постоянные:

  • Дрейф заголовков авторизации: некоторые вызовы использовали Authorization, другие — кастомный заголовок, несколько вообще забыли его.
  • Несогласованный парсинг: один вызов ожидал { data: ... }, другой использовал сырой JSON, третий никогда не проверял res.ok.
  • Случайные сообщения пользователям: некоторые экраны показывали «Что-то пошло не так», другие выплёвывали текст сервера, третьи молчали.

Первый рефактор был целенаправленно небольшим. Вместо переписывания всего приложения мы сделали один apiClient и два узконаправленных сервиса: authService (login, refresh, current user) и projectService (list, create, update).

Раньше компонент выглядел так (упрощённо):

useEffect(() => {
  fetch('/api/projects', {
    headers: { Authorization: `Bearer ${token}` }
  })
    .then(r => r.json())
    .then(setProjects)
    .catch(() => toast('Error'));
}, [token]);

После — UI просто запрашивал данные и обрабатывал состояние загрузки:

useEffect(() => {
  projectService.list().then(setProjects).catch(showError);
}, []);

Эффект виден быстро. UI сокращается, а правила живут в одном месте: как строятся заголовки, как парсится JSON и как формируются ошибки. Когда бэкенд меняет формат (например, начинает отдавать items вместо data), вы правите это один раз в сервисе, и все экраны обновляются вместе.

Следующие шаги: держать согласованность и просить помощи при необходимости

Слой сервисов приносит пользу только если все им пользуются. Самый быстрый способ сохранить выгоду — сделать его «дефолтным» путём для любой новой работы с API. Если кому-то нужны данные, он должен смотреть в сторону сервисной функции, а не писать новый fetch внутри компоненты.

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

Документация может быть лаконичной, но понятной. Короткий список рекомендованных имён сервисных функций предотвратит дубли типа getUser, fetchUser и loadUser, которые делают одно и то же с небольшими отличиями.

Если вы имеете дело с кодовой базой, сгенерированной ИИ, где разбросаны fetch-вызовы, есть проблемы с авторизацией или вопросы безопасности (например, открытые секреты), FixMyMess (fixmymess.ai) может помочь. Они специализируются на диагностике и исправлении проблем в проектах, сгенерированных ИИ: рефакторинг слоёв запросов, усиление безопасности и подготовка проектов к продакшну.

Часто задаваемые вопросы

Почему вызовы API внутри UI-компонентов приводят к множеству багов?

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

Что такое слой сервисов во фронтенде простыми словами?

Слой сервисов — это небольшой набор общих функций, которые занимаются общением с бэкендом, например getMe() или createInvoice(). Компоненты вызывают эти функции и сосредоточены на состоянии и отрисовке, а не на деталях HTTP.

Что должно находиться в UI, а что — в слое сервисов?

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

Как безопаснее всего рефакторить один API-вызов в сервис без поломки UI?

Начните с одного эндпоинта, который используется в нескольких местах, например /me или списка проектов. Перенесите fetch в одну сервисную функцию, которая всегда возвращает предсказуемый результат, затем замените вызовы в компонентах, сохранив прежнее поведение UI.

Какую форму возврата должны использовать сервисные функции?

Используйте один единый формат возврата по всему приложению, например { ok: true, data } и { ok: false, error, status }. Это снимает необходимость гадать, что проверять в компоненте, и делает обработку ошибок и успеха одинаковой на всех экранах.

Как стандартизировать заголовки, базовый URL и обработку аутентификации?

Создайте единый обёрточный запрос (API-клиент), который управляет базовым URL, общими заголовками, прикреплением токена, кодировкой JSON и таймаутами. Затем все сервисные функции вызывают этот клиент, и общее поведение меняется в одном месте, а не в десятке компонентов.

Как справиться с несогласованными форматами ответов от бэкенда?

Нормализуйте ответы в слое сервисов, чтобы UI всегда получал один и тот же вид полезной нагрузки, даже если бэкенд возвращает разные формы для разных эндпоинтов. Это избавляет компоненты от жёсткой привязки к деталям бэкенда, таким как data vs user vs items.

Как стандартизировать обработку ошибок?

Определите единый объект ошибки на уровне приложения и в одном месте мапьте HTTP- и сетевые ошибки в него. Тогда UI сможет показывать соответствующее сообщение без догадок, и вы избежите развода случайных строк или низкоуровневых Response объектов по компонентам.

Стоит ли добавлять ретраи, кеширование и отмену запросов в слой сервисов?

Повторные запросы обычно безопасны только для операций чтения (GET) и при сетевых ошибках или ответах 5xx, с небольшим лимитом попыток. Отмена запросов важна для поиска и быстрой навигации — держите AbortController в слое сервисов, чтобы избегать устаревших результатов и предупреждений об обновлении состояния после размонтирования.

Как FixMyMess помогает, если у меня в прототипе, сгенерированном ИИ, разбросаны fetch-вызовы и баги с авторизацией?

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