Перейти к основному содержимому

Чистый API-клиент

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

Проблема: хаос в компонентах

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

  • Дублирование: один и тот же endpoint вызывается в нескольких разных файлах.
  • Непоследовательность: везде разная обработка ошибок и настройки заголовков.
  • Боль при рефакторинге: при изменении API приходится переписывать половину UI-компонентов.
  • Сложность переиспользования: логику невозможно перенести в другой проект.

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


Архитектура слоя запросов

Базовая схема директорий

services/client.ts
import * as products from "@/services/requests/products/index"
import * as users from "@/services/requests/users/index"

export const ApiClient = {
  products,
  users
}

Каждый элемент этой структуры решает строго одну задачу:

ФайлЗона ответственности
request.tsНизкоуровневый транспорт: fetch/axios, таймауты, нормализация ошибок и заголовки.
requests/<domain>Доменная логика: эндпоинты (routes.ts) и типизированные методы (index.ts) для конкретной сущности.
client.tsЕдиный фасад (ApiClient), собирающий все домены в одну удобную точку входа.

Request-слой (Транспорт)

В request.ts лежит универсальная обертка, которая собирает URL с query-параметрами, ставит таймауты и парсит ответы.

Пример реализации:

services/request.ts
export const request = {
  get: <T>(url: string, options?: Omit<ApiRequestOptions, "method">) =>
    apiRequest<T>(url, { ...options, method: "GET" }),

  post: <T>(url: string, body?: unknown, options?: Omit<ApiRequestOptions, "method" | "body">) =>
    apiRequest<T>(url, { ...options, method: "POST", body }),

  put: <T>(url: string, body?: unknown, options?: Omit<ApiRequestOptions, "method" | "body">) =>
    apiRequest<T>(url, { ...options, method: "PUT", body })
}

Доменные модули

Каждый источник данных живет в своей папке. Например, продукты лежат в products/. Мы разделяем сами запросы и пути к ним, чтобы URL не размазывались по коду.

services/requests/products/routes.ts
export const routes = {
  list: () => `/products`,
  byId: (id: number) => `/products/${id}`
}

В index.ts мы используем эти роуты и наш базовый транспорт, оборачивая всё в строгие типы:

services/requests/products/index.ts
import { request } from "@/services/request"
import { routes } from "./routes"
import type { Product } from "./types"

export const getProducts = async (): Promise<Product[]> => {
  try {
    return await request.get<Product[]>(routes.list())
  } catch (error) {
    // Централизованная обработка или фоллбэк
    return []
  }
}

export const getProduct = async (id: number): Promise<Product | null> => {
  try {
    return await request.get<Product>(routes.byId(id))
  } catch (error) {
    return null
  }
}

Единый API-клиент

Когда модулей становится много, импортировать каждый по отдельности неудобно. Я собираю их в общий клиент:

services/client.ts
import * as products from "@/services/requests/products/index"
import * as users from "@/services/requests/users/index"

export const ApiClient = {
  products,
  users
}

Теперь у приложения есть предсказуемый и удобный интерфейс доступа к данным: ApiClient.<домен>.<метод>.


Как это используется в UI

Посмотрите, насколько чище становится код самого компонента. Он больше не знает про URL, headers, токены и fetch-детали.

app/components/ProductList.vue
<script setup lang="ts">
import { onMounted, ref } from "vue"
import { ApiClient } from "@/services/client"

const products = ref([])
const loading = ref(true)

onMounted(async () => {
  try {
    products.value = await ApiClient.products.getProducts()
  } finally {
    loading.value = false
  }
})
</script>

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


Итог

Такое разделение позволяет не смешивать бизнес-логику и представление. Базового набора из общего request, доменных папок и единого ApiClient более чем достаточно, чтобы код не расползался и оставался читаемым даже при сильном масштабировании проекта.

\n","app/components/ProductList.vue","vue",[203,2010,2011,2035,2059,2079,2083,2097,2116,2120,2136,2142,2172,2181,2195,2199,2205],{"__ignoreMap":201},[206,2012,2013,2015,2018,2021,2024,2026,2028,2030,2032],{"class":208,"line":108},[206,2014,270],{"class":223},[206,2016,2017],{"class":323},"script",[206,2019,2020],{"class":215}," setup",[206,2022,2023],{"class":215}," lang",[206,2025,224],{"class":223},[206,2027,285],{"class":223},[206,2029,200],{"class":281},[206,2031,285],{"class":223},[206,2033,2034],{"class":223},">\n",[206,2036,2037,2039,2041,2044,2046,2049,2051,2053,2055,2057],{"class":208,"line":101},[206,2038,666],{"class":211},[206,2040,312],{"class":223},[206,2042,2043],{"class":219}," onMounted",[206,2045,258],{"class":223},[206,2047,2048],{"class":219}," ref",[206,2050,336],{"class":223},[206,2052,676],{"class":211},[206,2054,278],{"class":223},[206,2056,2008],{"class":281},[206,2058,684],{"class":223},[206,2060,2061,2063,2065,2068,2070,2072,2074,2077],{"class":208,"line":294},[206,2062,666],{"class":211},[206,2064,312],{"class":223},[206,2066,2067],{"class":219}," ApiClient",[206,2069,336],{"class":223},[206,2071,676],{"class":211},[206,2073,278],{"class":223},[206,2075,2076],{"class":281},"@/services/client",[206,2078,684],{"class":223},[206,2080,2081],{"class":208,"line":345},[206,2082,349],{"emptyLinePlaceholder":348},[206,2084,2085,2088,2090,2092,2094],{"class":208,"line":352},[206,2086,2087],{"class":215},"const",[206,2089,1117],{"class":219},[206,2091,224],{"class":223},[206,2093,2048],{"class":232},[206,2095,2096],{"class":219},"([])\n",[206,2098,2099,2101,2104,2106,2108,2110,2114],{"class":208,"line":416},[206,2100,2087],{"class":215},[206,2102,2103],{"class":219}," loading ",[206,2105,224],{"class":223},[206,2107,2048],{"class":232},[206,2109,799],{"class":219},[206,2111,2113],{"class":2112},"sfNiH","true",[206,2115,567],{"class":219},[206,2117,2118],{"class":208,"line":462},[206,2119,349],{"emptyLinePlaceholder":348},[206,2121,2122,2125,2127,2130,2132,2134],{"class":208,"line":467},[206,2123,2124],{"class":232},"onMounted",[206,2126,799],{"class":219},[206,2128,2129],{"class":215},"async",[206,2131,602],{"class":223},[206,2133,605],{"class":215},[206,2135,227],{"class":223},[206,2137,2138,2140],{"class":208,"line":527},[206,2139,770],{"class":211},[206,2141,227],{"class":223},[206,2143,2144,2147,2149,2152,2155,2157,2159,2161,2164,2166,2169],{"class":208,"line":570},[206,2145,2146],{"class":219}," products",[206,2148,785],{"class":223},[206,2150,2151],{"class":219},"value",[206,2153,2154],{"class":223}," =",[206,2156,780],{"class":211},[206,2158,2067],{"class":219},[206,2160,785],{"class":223},[206,2162,2163],{"class":219},"products",[206,2165,785],{"class":223},[206,2167,2168],{"class":232},"getProducts",[206,2170,2171],{"class":323},"()\n",[206,2173,2174,2176,2179],{"class":208,"line":835},[206,2175,815],{"class":223},[206,2177,2178],{"class":211}," finally",[206,2180,227],{"class":223},[206,2182,2183,2186,2188,2190,2192],{"class":208,"line":840},[206,2184,2185],{"class":219}," loading",[206,2187,785],{"class":223},[206,2189,2151],{"class":219},[206,2191,2154],{"class":223},[206,2193,2194],{"class":2112}," false\n",[206,2196,2197],{"class":208,"line":845},[206,2198,832],{"class":223},[206,2200,2201,2203],{"class":208,"line":887},[206,2202,455],{"class":223},[206,2204,567],{"class":219},[206,2206,2207,2210,2212],{"class":208,"line":894},[206,2208,2209],{"class":223},"