Слой API: как перестать писать try/catch в каждом компоненте
В любом проекте старше года живёт три способа сходить на бэк. Показываю, как я это привожу к одному виду и что складываю на каждый уровень.
Открываешь проект старше года и находишь три способа сходить на сервер: где-то fetch, где-то axios, где-то самописный request, который писал человек, уже сменивший работу. И в каждом компоненте свой try/catch со своим текстом ошибки.
Разгребается это в четыре слоя. Ниже - как выглядит у меня.
Транспорт
Один клиент на приложение. Здесь всё общее: базовый адрес, заголовки, токен, реакция на 401.
// shared/api/client.ts
export const client = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 15_000,
});
client.interceptors.request.use((config) => {
const token = useAuthStore().token;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
client.interceptors.response.use(
(res) => res,
async (error) => {
if (error.response?.status === 401) await useAuthStore().logout();
return Promise.reject(normalizeError(error));
},
);
Про normalizeError отдельно. У нас был бэк, который отдавал ошибки тремя способами в зависимости от того, какой сервис отвечал: {message}, {detail} и {errors: []}. Я не хочу, чтобы это знание расползлось по тридцати компонентам:
export interface AppError {
code: string;
message: string;
fields?: Record<string, string>;
}
function normalizeError(e: unknown): AppError {
if (axios.isAxiosError(e)) {
const data = e.response?.data as any;
return {
code: data?.code ?? String(e.response?.status ?? 'network'),
message: data?.message ?? data?.detail ?? 'Что-то пошло не так',
fields: data?.errors,
};
}
return { code: 'unknown', message: 'Неизвестная ошибка' };
}
Дальше по коду ошибка всегда одного вида. Это экономит по десятку строк в каждой форме.
Методы домена
Компонент не знает URL. Он знает функцию.
// entities/user/api.ts
export const userApi = {
list: (params: UserListParams) =>
client.get<Paginated<User>>('/users', { params }).then(r => r.data),
byId: (id: string) =>
client.get<User>(`/users/${id}`).then(r => r.data),
update: (id: string, dto: UpdateUserDto) =>
client.patch<User>(`/users/${id}`, dto).then(r => r.data),
};
Скучно, зато однажды у нас /users переехал на /v2/accounts, и правка заняла две минуты вместо получаса поиска по строкам в проекте.
Кэш и состояние запроса
Вот тут я категорически против рукописного. loading/error/data в каждом компоненте - это самодельный кэш, и получается он всегда хуже готового.
export function useUsers(params: Ref<UserListParams>) {
return useQuery({
queryKey: ['users', params], // ключ реактивный - параметры сменились, запрос ушёл заново
queryFn: () => userApi.list(toValue(params)),
staleTime: 60_000,
placeholderData: (prev) => prev, // при смене страницы не мигаем пустотой
});
}
Бесплатно приезжают: дедупликация одинаковых запросов, ретраи, обновление при возврате на вкладку, отмена устаревших ответов и общий кэш между компонентами. Всё это руками писать неделю, а поддерживать - вечно.
Мутации так же, с инвалидацией:
const { mutateAsync, isPending } = useMutation({
mutationFn: (dto: UpdateUserDto) => userApi.update(id, dto),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
Типы не пишем руками
Если у бэка есть OpenAPI - типы генерируются:
npx openapi-typescript http://api.local/openapi.json -o src/shared/api/schema.d.ts
Написанный руками interface User расходится с реальностью спринта через два, и узнаёшь ты об этом от пользователя. Мы ставим генерацию отдельным шагом в CI: сгенерированный файл отличается от закоммиченного - сборка красная, идём выяснять, кто поменял контракт.
Если схемы нет, с бэкендом обычно работает такой заход: генерация превращает контракт в исполняемый документ, любое несогласованное изменение поля становится ошибкой компиляции у нас, а не багом у пользователя. Против этого сложно возразить.
Что в итоге в компоненте
<script setup lang="ts">
const params = ref({ page: 1, search: '' });
const { data, isPending, error } = useUsers(params);
</script>
<template>
<UiSpinner v-if="isPending" />
<UiError v-else-if="error" :message="error.message" />
<UserTable v-else :items="data.items" />
</template>
Ни одного try/catch, ни одного URL, ни одного any.
Смысл слоёв не в красоте, а в стоимости изменений. Меняем библиотеку запросов - трогаем первый уровень. Переехал эндпоинт - второй. Поменяли стратегию кэша - третий. Компоненты не трогаем вообще.