272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||
import axios from 'axios'; // Убираем CancelTokenSource
|
||
import api, { apiStatus } from '@/lib/api';
|
||
|
||
interface UseApiRequestOptions<T> {
|
||
// URL для запроса
|
||
url: string;
|
||
// Метод запроса
|
||
method?: 'get' | 'post' | 'put' | 'delete';
|
||
// Параметры запроса
|
||
params?: any;
|
||
// Данные для отправки (для POST, PUT)
|
||
data?: any;
|
||
// Хедеры запроса
|
||
headers?: Record<string, string>;
|
||
// Автоматически выполнять запрос при монтировании
|
||
autoFetch?: boolean;
|
||
// Интервал для повторного запроса в миллисекундах
|
||
refreshInterval?: number;
|
||
// Количество автоматических повторных попыток при ошибке
|
||
retries?: number;
|
||
// Интервал между повторными попытками в миллисекундах
|
||
retryInterval?: number;
|
||
// Преобразователь для данных ответа
|
||
dataTransformer?: (data: any) => T;
|
||
// Функция для определения успешности ответа
|
||
isSuccessful?: (response: any) => boolean;
|
||
// Максимальное время запроса в миллисекундах
|
||
timeout?: number;
|
||
}
|
||
|
||
interface UseApiRequestResult<T> {
|
||
// Данные ответа
|
||
data: T | null;
|
||
// Состояние загрузки
|
||
loading: boolean;
|
||
// Ошибка, если есть
|
||
error: Error | null;
|
||
// Функция для выполнения запроса
|
||
fetchData: (config?: Partial<UseApiRequestOptions<T>>) => Promise<T | null>;
|
||
// Функция для отмены текущего запроса
|
||
cancelRequest: () => void;
|
||
// Функция для сброса состояния
|
||
reset: () => void;
|
||
// Статус запроса
|
||
status: 'idle' | 'loading' | 'success' | 'error';
|
||
}
|
||
|
||
// AbortController используется вместо CancelTokenSource
|
||
|
||
/**
|
||
* Хук для выполнения API-запросов с оптимизацией
|
||
* @param options Параметры запроса
|
||
* @returns Результат запроса и функции управления
|
||
*/
|
||
export function useApiRequest<T = any>(
|
||
options: UseApiRequestOptions<T>
|
||
): UseApiRequestResult<T> {
|
||
const [data, setData] = useState<T | null>(null);
|
||
const [loading, setLoading] = useState<boolean>(false);
|
||
const [error, setError] = useState<Error | null>(null);
|
||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||
|
||
// Для хранения AbortController
|
||
const abortControllerRef = useRef<AbortController | null>(null);
|
||
|
||
// Для хранения таймера обновления
|
||
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||
|
||
// Для отслеживания количества повторных попыток
|
||
const retriesCountRef = useRef<number>(0);
|
||
|
||
// Кэш последних успешных ответов по URL
|
||
const responseCache = useRef<Map<string, { data: T, timestamp: number }>>(new Map());
|
||
|
||
// Создаем ключ кэша на основе URL и параметров
|
||
const getCacheKey = useCallback((url: string, params?: any, data?: any): string => {
|
||
return `${url}${params ? `?${JSON.stringify(params)}` : ''}${data ? `|${JSON.stringify(data)}` : ''}`;
|
||
}, []);
|
||
|
||
// Функция для отмены текущего запроса
|
||
const cancelRequest = useCallback(() => {
|
||
if (abortControllerRef.current) {
|
||
abortControllerRef.current.abort('Запрос отменен пользователем'); // Используем abort()
|
||
abortControllerRef.current = null;
|
||
}
|
||
|
||
if (refreshTimerRef.current) {
|
||
clearTimeout(refreshTimerRef.current);
|
||
refreshTimerRef.current = null;
|
||
}
|
||
}, []);
|
||
|
||
// Функция для сброса состояния
|
||
const reset = useCallback(() => {
|
||
cancelRequest();
|
||
setData(null);
|
||
setLoading(false);
|
||
setError(null);
|
||
setStatus('idle');
|
||
retriesCountRef.current = 0;
|
||
}, [cancelRequest]);
|
||
|
||
// Функция для создания источника отмены - больше не нужна, используем axios.CancelToken.source()
|
||
|
||
// Основная функция для выполнения запроса
|
||
const fetchData = useCallback(
|
||
async (overrideOptions?: Partial<UseApiRequestOptions<T>>): Promise<T | null> => {
|
||
try {
|
||
// Отменяем предыдущий запрос, если он есть
|
||
cancelRequest();
|
||
|
||
// Обновляем опции запроса
|
||
const currentOptions = { ...options, ...overrideOptions };
|
||
const {
|
||
url,
|
||
method = 'get',
|
||
params,
|
||
data: requestData,
|
||
headers,
|
||
retries = 0,
|
||
retryInterval = 1000,
|
||
dataTransformer,
|
||
isSuccessful = response => true,
|
||
timeout = 30000
|
||
} = currentOptions;
|
||
|
||
setLoading(true);
|
||
setStatus('loading');
|
||
setError(null);
|
||
|
||
// Проверяем наличие в кэше (только для GET запросов)
|
||
if (method === 'get') {
|
||
const cacheKey = getCacheKey(url || '', params || {}, null);
|
||
const cachedResponse = responseCache.current.get(cacheKey);
|
||
|
||
// Используем кэш, если он не старше 5 минут
|
||
if (cachedResponse && Date.now() - cachedResponse.timestamp < 5 * 60 * 1000) {
|
||
console.log(`Используются кэшированные данные для ${url}`);
|
||
setData(cachedResponse.data);
|
||
setLoading(false);
|
||
setStatus('success');
|
||
return cachedResponse.data;
|
||
}
|
||
}
|
||
|
||
// Создаем новый AbortController
|
||
abortControllerRef.current = new AbortController();
|
||
const signal = abortControllerRef.current.signal;
|
||
|
||
// Выполняем запрос с помощью api-клиента, передавая signal
|
||
let response;
|
||
try {
|
||
const config = { headers, timeout, signal }; // Добавляем signal в конфиг
|
||
switch (method) {
|
||
case 'get':
|
||
response = await api.get(url, { params: params || {}, ...config }); // Передаем config
|
||
break;
|
||
case 'post':
|
||
response = await api.post(url, requestData || {}, config); // Передаем config
|
||
break;
|
||
case 'put':
|
||
response = await api.put(url, requestData || {}, config); // Передаем config
|
||
break;
|
||
case 'delete':
|
||
response = await api.delete(url, config); // Передаем config
|
||
break;
|
||
default:
|
||
throw new Error(`Неподдерживаемый метод: ${method}`);
|
||
}
|
||
} catch (error) {
|
||
throw error;
|
||
}
|
||
|
||
// Проверяем успешность ответа
|
||
if (!isSuccessful(response)) {
|
||
throw new Error('Ответ API не соответствует ожидаемому формату');
|
||
}
|
||
|
||
// Преобразуем данные, если указан трансформер
|
||
const processedData = dataTransformer ? dataTransformer(response) : (response as T);
|
||
|
||
// Кэшируем ответ для GET запросов
|
||
if (method === 'get') {
|
||
const cacheKey = getCacheKey(url || '', params || {}, null);
|
||
responseCache.current.set(cacheKey, {
|
||
data: processedData,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Ограничиваем размер кэша
|
||
if (responseCache.current.size > 50) {
|
||
const oldestKey = responseCache.current.keys().next().value;
|
||
responseCache.current.delete(oldestKey);
|
||
}
|
||
}
|
||
|
||
// Обновляем состояние с полученными данными
|
||
setData(processedData);
|
||
setLoading(false);
|
||
setStatus('success');
|
||
retriesCountRef.current = 0;
|
||
|
||
// Устанавливаем таймер для автоматического обновления, если задан
|
||
if (currentOptions.refreshInterval && currentOptions.refreshInterval > 0) {
|
||
refreshTimerRef.current = setTimeout(() => {
|
||
fetchData(overrideOptions);
|
||
}, currentOptions.refreshInterval);
|
||
}
|
||
|
||
return processedData;
|
||
} catch (err: unknown) { // Явно типизируем err как unknown
|
||
// Обработка ошибки отмены (AbortError)
|
||
if (err instanceof Error && err.name === 'AbortError') {
|
||
// Используем сообщение из AbortSignal, если оно есть, иначе стандартное
|
||
const abortReason = (abortControllerRef.current?.signal.reason as string) || 'Запрос отменен';
|
||
console.log('Запрос отменен:', abortReason);
|
||
setLoading(false);
|
||
setStatus('idle');
|
||
return null;
|
||
}
|
||
|
||
// Обработка других ошибок
|
||
console.error('Ошибка API-запроса:', err);
|
||
const errorObj = err instanceof Error ? err : new Error('Неизвестная ошибка при запросе к API');
|
||
setError(errorObj);
|
||
setLoading(false);
|
||
setStatus('error');
|
||
|
||
// Повторная попытка, если указано количество повторов
|
||
const { retries = 0, retryInterval = 1000 } = { ...options, ...overrideOptions };
|
||
|
||
if (retriesCountRef.current < retries) {
|
||
console.log(`Повторная попытка ${retriesCountRef.current + 1}/${retries} через ${retryInterval}мс`);
|
||
retriesCountRef.current++;
|
||
|
||
await new Promise(resolve => setTimeout(resolve, retryInterval));
|
||
return fetchData(overrideOptions);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
},
|
||
// Убираем createCancelToken из зависимостей
|
||
[options, cancelRequest, getCacheKey]
|
||
);
|
||
|
||
// Автоматический запрос при монтировании компонента
|
||
useEffect(() => {
|
||
if (options.autoFetch) {
|
||
fetchData();
|
||
}
|
||
|
||
// Очистка
|
||
return () => {
|
||
cancelRequest();
|
||
};
|
||
}, [fetchData, cancelRequest, options.autoFetch]);
|
||
|
||
return {
|
||
data,
|
||
loading,
|
||
error,
|
||
fetchData,
|
||
cancelRequest,
|
||
reset,
|
||
status
|
||
};
|
||
}
|