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

Чистый 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",[212,2019,2020,2044,2068,2088,2092,2106,2125,2129,2145,2151,2181,2190,2204,2208,2214],{"__ignoreMap":210},[215,2021,2022,2024,2027,2030,2033,2035,2037,2039,2041],{"class":217,"line":6},[215,2023,279],{"class":232},[215,2025,2026],{"class":332},"script",[215,2028,2029],{"class":224}," setup",[215,2031,2032],{"class":224}," lang",[215,2034,233],{"class":232},[215,2036,294],{"class":232},[215,2038,209],{"class":290},[215,2040,294],{"class":232},[215,2042,2043],{"class":232},">\n",[215,2045,2046,2048,2050,2053,2055,2058,2060,2062,2064,2066],{"class":217,"line":120},[215,2047,675],{"class":220},[215,2049,321],{"class":232},[215,2051,2052],{"class":228}," onMounted",[215,2054,267],{"class":232},[215,2056,2057],{"class":228}," ref",[215,2059,345],{"class":232},[215,2061,685],{"class":220},[215,2063,287],{"class":232},[215,2065,2017],{"class":290},[215,2067,693],{"class":232},[215,2069,2070,2072,2074,2077,2079,2081,2083,2086],{"class":217,"line":303},[215,2071,675],{"class":220},[215,2073,321],{"class":232},[215,2075,2076],{"class":228}," ApiClient",[215,2078,345],{"class":232},[215,2080,685],{"class":220},[215,2082,287],{"class":232},[215,2084,2085],{"class":290},"@/services/client",[215,2087,693],{"class":232},[215,2089,2090],{"class":217,"line":354},[215,2091,358],{"emptyLinePlaceholder":357},[215,2093,2094,2097,2099,2101,2103],{"class":217,"line":361},[215,2095,2096],{"class":224},"const",[215,2098,1126],{"class":228},[215,2100,233],{"class":232},[215,2102,2057],{"class":241},[215,2104,2105],{"class":228},"([])\n",[215,2107,2108,2110,2113,2115,2117,2119,2123],{"class":217,"line":425},[215,2109,2096],{"class":224},[215,2111,2112],{"class":228}," loading ",[215,2114,233],{"class":232},[215,2116,2057],{"class":241},[215,2118,808],{"class":228},[215,2120,2122],{"class":2121},"sfNiH","true",[215,2124,576],{"class":228},[215,2126,2127],{"class":217,"line":471},[215,2128,358],{"emptyLinePlaceholder":357},[215,2130,2131,2134,2136,2139,2141,2143],{"class":217,"line":476},[215,2132,2133],{"class":241},"onMounted",[215,2135,808],{"class":228},[215,2137,2138],{"class":224},"async",[215,2140,611],{"class":232},[215,2142,614],{"class":224},[215,2144,236],{"class":232},[215,2146,2147,2149],{"class":217,"line":536},[215,2148,779],{"class":220},[215,2150,236],{"class":232},[215,2152,2153,2156,2158,2161,2164,2166,2168,2170,2173,2175,2178],{"class":217,"line":579},[215,2154,2155],{"class":228}," products",[215,2157,794],{"class":232},[215,2159,2160],{"class":228},"value",[215,2162,2163],{"class":232}," =",[215,2165,789],{"class":220},[215,2167,2076],{"class":228},[215,2169,794],{"class":232},[215,2171,2172],{"class":228},"products",[215,2174,794],{"class":232},[215,2176,2177],{"class":241},"getProducts",[215,2179,2180],{"class":332},"()\n",[215,2182,2183,2185,2188],{"class":217,"line":844},[215,2184,824],{"class":232},[215,2186,2187],{"class":220}," finally",[215,2189,236],{"class":232},[215,2191,2192,2195,2197,2199,2201],{"class":217,"line":849},[215,2193,2194],{"class":228}," loading",[215,2196,794],{"class":232},[215,2198,2160],{"class":228},[215,2200,2163],{"class":232},[215,2202,2203],{"class":2121}," false\n",[215,2205,2206],{"class":217,"line":854},[215,2207,841],{"class":232},[215,2209,2210,2212],{"class":217,"line":896},[215,2211,464],{"class":232},[215,2213,576],{"class":228},[215,2215,2216,2219,2221],{"class":217,"line":903},[215,2217,2218],{"class":232},"