Современные интерфейсы взаимодействуют с сервером через десятки API-вызовов. Представьте сценарий: компоненты в разных частях приложения одновременно запрашивают одни и те же данные. Без оптимизации это приводит к дублированию сетевых запросов, избыточной нагрузке на сервер и "соревнованию" компонентов за одни ресурсы. Влияние на производительность особенно заметно на мобильных устройствах и в сложных SPA.
Почему дедупликация и кеширование критичны
- Сетевая эффективность: Множественные идентичные запросы расходуют bandwidth и увеличивают задержки.
- Консистентность данных: При параллельных запросах ответы могут приходить в разное время, вызывая рассогласование интерфейса.
- Серверная нагрузка: Каждый дубликат создаёт ненужную нагрузку на бэкенд.
Практическая реализация: кеш с дедупликацией
Рассмотрим решение на TypeScript, объединяющее:
- Кеширование для хранения результатов
- Дедупликацию для предотвращения одновременного выполнения одинаковых запросов
interface CacheRecord {
promise: Promise<any>;
data?: any;
error?: Error;
timestamp: number;
}
const API_CACHE = new Map<string, CacheRecord>();
const CACHE_TTL = 60_000; // 60 секунд
async function fetchWithCache(
url: string,
options?: RequestInit
): Promise<any> {
// Генерация ключа: комбинация URL, метода и тела
const requestKey = JSON.stringify({ url, method: options?.method, body: options?.body });
// Проверка актуальности кеша
if (API_CACHE.has(requestKey)) {
const record = API_CACHE.get(requestKey)!;
const cacheValid = Date.now() - record.timestamp < CACHE_TTL;
if (cacheValid) {
if (record.data) return record.data;
if (record.error) throw record.error;
// Превышение TTL — удалить запись
} else {
API_CACHE.delete(requestKey);
}
}
// Дедупликация: если запрос уже выполняется
if (!API_CACHE.get(requestKey)?.promise) {
const requestPromise = fetch(url, options)
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(data => {
API_CACHE.set(requestKey, {
...record,
data,
timestamp: Date.now()
});
return data;
})
.catch(error => {
API_CACHE.set(requestKey, { ...record, error });
throw error;
});
// Блокировка для последующих запросов до разрешения
const record: CacheRecord = {
promise: requestPromise,
timestamp: Date.now()
};
API_CACHE.set(requestKey, record);
}
return API_CACHE.get(requestKey)!.promise;
}
Как это работает:
- При первом запросе создаётся ключ из параметров запроса и записывается
Promise
вMap
. - Последующие идентичные запросы возвращают этот
Promise
. - Успешный ответ кешируется в записи вместе с меткой времени.
- Ответы считаются актуальными, если не превышен
CACHE_TTL
.
Расширение функциональности
-
Инвалидация кеша:
- Явная очистка при мутациях данных:
typescriptfunction invalidateCache(urlMatch: string) { Array.from(API_CACHE.keys()).forEach(key => { if (key.includes(urlMatch)) API_CACHE.delete(key); }); }
- Перезапрос через
stale-while-revalidate
:
typescriptif (record && Date.now() -record.timestamp > CACHE_TTL) { fetchWithCache(url, options); // Фоновое обновление return record.data; // Возвращаем устаревшие данные немедленно }
-
Вариативные ключи кеширования:
Параметры сортировки, фильтры должны быть частью ключа:typescriptconst requestKey = JSON.stringify({ url, method: options?.method, body: options?.body, queryParams: new URLSearchParams(window.location.search) });
-
Обработка ошибок:
Кеширование ошибок по умолчанию предотвращает циклы запросов сбоящих вызовов. -
Мемоизация хэшированием:
Для сложных параметров используйте хэш-функцию вместоJSON.stringify
:typescriptimport hash from 'object-hash'; const requestKey = hash({ url, options });
Баланс между сложностью и выгодой
Главная задача — определить точки применения стратегии:
- Запросы с высоким
cost-per-request
(рендеринг на сервере, тяжёлые вычисления) - Данные, которые редко меняются (настройки пользователя, справочники)
- Критичные ко времени выполнения компоненты (выше области видимости)
Использование Map
вместо объекта - осознанное решение:
- Ключи любой структуры (объекты, массивы)
- Встроенные методы
.set()
и.has()
эффективны при частых обращениях - Слабая ссылка на объекты не требуется — кеш должен жить только пока активны связанные компоненты
Когда стандартные решения уместны
Библиотеки (react-query
, SWR
) предоставляют кеширование "из коробки", но скрывают детали реализации. Кастомное решение оправдано, когда:
- Требуется тонкий контроль над жизненным циклом кеша
- Специфические сценарии инвалидации
- Нет возможности добавить зависимости
Измеряйте результаты: инструменты вроде Chrome DevTools Network Flame Charts
покажут сокращение запросов. В приложениях с большим количеством "читающих" операций (1 запрос на 4 рендера) подобная оптимизация снижает задержку на 40-70%.
Заключение
Предложенный паттерн — композитное решение, сочетающее простоту и масштабируемость. Ключевые принципы:
- Мемоизация запросов снижает нагрузку на клиент и сервер.
- TTL-механизм гарантирует актуальность данных без потери отзывчивости.
- Обработка ошибок как состояния предотвращает "лавины сбоев".
Потратив 100 строк кода, вы минимизируете дублирующиеся запросы и создадите ощущение стабильности приложения. В крупных проектах интегрируйте этот механизм с сервис-воркерами для продвинутого оффлайн-поведения.
Техника универсальна: работает с любым фреймворком и нативной разработкой. Проверьте production-бандл на дубликаты API-вызовов — скорее всего, момент для оптимизации уже настал.