Оптимизация фронтенда: элегантное кеширование и дедупликация API-запросов

Современные интерфейсы взаимодействуют с сервером через десятки API-вызовов. Представьте сценарий: компоненты в разных частях приложения одновременно запрашивают одни и те же данные. Без оптимизации это приводит к дублированию сетевых запросов, избыточной нагрузке на сервер и "соревнованию" компонентов за одни ресурсы. Влияние на производительность особенно заметно на мобильных устройствах и в сложных SPA.

Почему дедупликация и кеширование критичны

  1. Сетевая эффективность: Множественные идентичные запросы расходуют bandwidth и увеличивают задержки.
  2. Консистентность данных: При параллельных запросах ответы могут приходить в разное время, вызывая рассогласование интерфейса.
  3. Серверная нагрузка: Каждый дубликат создаёт ненужную нагрузку на бэкенд.

Практическая реализация: кеш с дедупликацией

Рассмотрим решение на TypeScript, объединяющее:

  • Кеширование для хранения результатов
  • Дедупликацию для предотвращения одновременного выполнения одинаковых запросов
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;
}

Как это работает:

  1. При первом запросе создаётся ключ из параметров запроса и записывается Promise в Map.
  2. Последующие идентичные запросы возвращают этот Promise.
  3. Успешный ответ кешируется в записи вместе с меткой времени.
  4. Ответы считаются актуальными, если не превышен CACHE_TTL.

Расширение функциональности

  1. Инвалидация кеша:

    • Явная очистка при мутациях данных:
    typescript
    function invalidateCache(urlMatch: string) {
      Array.from(API_CACHE.keys()).forEach(key => {
        if (key.includes(urlMatch)) API_CACHE.delete(key);
      });
    }
    
    • Перезапрос через stale-while-revalidate:
    typescript
    if (record && Date.now() -record.timestamp > CACHE_TTL) {
      fetchWithCache(url, options); // Фоновое обновление
      return record.data; // Возвращаем устаревшие данные немедленно
    }
    
  2. Вариативные ключи кеширования:
    Параметры сортировки, фильтры должны быть частью ключа:

    typescript
    const requestKey = JSON.stringify({
      url,
      method: options?.method,
      body: options?.body,
      queryParams: new URLSearchParams(window.location.search)
    });
    
  3. Обработка ошибок:
    Кеширование ошибок по умолчанию предотвращает циклы запросов сбоящих вызовов.

  4. Мемоизация хэшированием:
    Для сложных параметров используйте хэш-функцию вместо JSON.stringify:

    typescript
    import 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%.

Заключение

Предложенный паттерн — композитное решение, сочетающее простоту и масштабируемость. Ключевые принципы:

  1. Мемоизация запросов снижает нагрузку на клиент и сервер.
  2. TTL-механизм гарантирует актуальность данных без потери отзывчивости.
  3. Обработка ошибок как состояния предотвращает "лавины сбоев".

Потратив 100 строк кода, вы минимизируете дублирующиеся запросы и создадите ощущение стабильности приложения. В крупных проектах интегрируйте этот механизм с сервис-воркерами для продвинутого оффлайн-поведения.

Техника универсальна: работает с любым фреймворком и нативной разработкой. Проверьте production-бандл на дубликаты API-вызовов — скорее всего, момент для оптимизации уже настал.