Кэширование загруженных данных

При работе с удалёнными источниками данных библиотека Choices.js часто используется совместно с AJAX-запросами, fetch, REST API и серверными фильтрами. Без механизма кэширования каждое открытие списка, ввод символа или повторный поиск инициирует новый сетевой запрос. Это создаёт несколько проблем:

  • увеличение нагрузки на сервер;
  • рост времени отклика интерфейса;
  • лишний сетевой трафик;
  • мерцание интерфейса при повторной загрузке;
  • дублирующиеся запросы;
  • ухудшение UX при медленном соединении.

Кэширование решает эти проблемы за счёт сохранения уже загруженных данных в памяти браузера, localStorage, sessionStorage или специальных слоях хранения.


Принцип работы кэширования

Типичный поток загрузки данных без кэша:

  1. Пользователь вводит запрос.
  2. Выполняется HTTP-запрос.
  3. Сервер возвращает данные.
  4. Choices.js отображает элементы.

При повторном вводе такого же значения процесс повторяется полностью.

С кэшированием схема меняется:

  1. Выполняется проверка локального кэша.
  2. Если данные уже существуют — используется локальная копия.
  3. Если данных нет — выполняется запрос к серверу.
  4. Результат сохраняется в кэше.
  5. Choices.js получает сохранённые данные.

Простое кэширование в памяти

Базовый объект-кэш

Самый простой вариант — хранение результатов в обычном объекте JavaScript.

<select id="users"></select>
const cache = {};

const choices = new Choices('#users', {
  searchEnabled: true,
  shouldSort: false
});

async function loadUsers(query) {

  if (cache[query]) {
    console.log('Данные из кэша');

    choices.setChoices(
      cache[query],
      'value',
      'label',
      true
    );

    return;
  }

  console.log('Запрос к серверу');

  const response = await fetch(`/api/users?q=${query}`);
  const data = await response.json();

  cache[query] = data;

  choices.setChoices(
    data,
    'value',
    'label',
    true
  );
}

Структура кэшированных данных

Обычно данные сохраняются в формате:

{
  "alex": [
    { value: 1, label: "Alex Johnson" },
    { value: 2, label: "Alex Smith" }
  ],

  "john": [
    { value: 3, label: "John Doe" }
  ]
}

Ключом выступает поисковая строка.


Кэширование с использованием Map

Объект подходит не всегда. Для более управляемого хранения лучше использовать Map.

Преимущества Map

  • поддержка любых типов ключей;
  • быстрый доступ;
  • удобный размер через size;
  • простой перебор;
  • отсутствие конфликтов с прототипом.
const cache = new Map();

async function loadCities(query) {

  if (cache.has(query)) {

    choices.setChoices(
      cache.get(query),
      'value',
      'label',
      true
    );

    return;
  }

  const response = await fetch(`/api/cities?q=${query}`);
  const data = await response.json();

  cache.set(query, data);

  choices.setChoices(
    data,
    'value',
    'label',
    true
  );
}

Кэширование с ограничением времени жизни

Без ограничения кэш может устаревать. Пользователь продолжит видеть старые данные даже после обновления базы на сервере.

TTL-кэш

TTL (time to live) — время жизни записи.

const cache = new Map();

const CACHE_TTL = 60000;

async function loadProducts(query) {

  const cached = cache.get(query);

  if (cached) {

    const isExpired =
      Date.now() - cached.timestamp > CACHE_TTL;

    if (!isExpired) {

      choices.setChoices(
        cached.data,
        'value',
        'label',
        true
      );

      return;
    }

    cache.delete(query);
  }

  const response = await fetch(`/api/products?q=${query}`);
  const data = await response.json();

  cache.set(query, {
    data,
    timestamp: Date.now()
  });

  choices.setChoices(
    data,
    'value',
    'label',
    true
  );
}

Автоматическая очистка кэша

При долгой работе приложения память постепенно заполняется.

Очистка устаревших записей

function clearExpiredCache() {

  for (const [key, value] of cache.entries()) {

    const expired =
      Date.now() - value.timestamp > CACHE_TTL;

    if (expired) {
      cache.delete(key);
    }
  }
}

setInterval(clearExpiredCache, 30000);

Кэширование через localStorage

Оперативная память очищается после перезагрузки страницы. Для постоянного хранения используется localStorage.

Сохранение результатов поиска

async function loadCountries(query) {

  const cacheKey = `countries_${query}`;

  const cached = localStorage.getItem(cacheKey);

  if (cached) {

    console.log('localStorage cache');

    choices.setChoices(
      JSON.parse(cached),
      'value',
      'label',
      true
    );

    return;
  }

  const response =
    await fetch(`/api/countries?q=${query}`);

  const data = await response.json();

  localStorage.setItem(
    cacheKey,
    JSON.stringify(data)
  );

  choices.setChoices(
    data,
    'value',
    'label',
    true
  );
}

Недостатки localStorage

Синхронность

localStorage блокирует основной поток браузера.

Ограничение размера

Обычно доступно около 5–10 МБ.

Отсутствие автоматического удаления

Старые записи необходимо очищать вручную.


Кэширование с датой истечения

Хранение метаданных

function saveCache(key, data) {

  const payload = {
    data,
    expires: Date.now() + 3600000
  };

  localStorage.setItem(
    key,
    JSON.stringify(payload)
  );
}

function getCache(key) {

  const raw = localStorage.getItem(key);

  if (!raw) {
    return null;
  }

  const parsed = JSON.parse(raw);

  if (Date.now() > parsed.expires) {

    localStorage.removeItem(key);

    return null;
  }

  return parsed.data;
}

Использование sessionStorage

sessionStorage работает аналогично localStorage, но данные удаляются после закрытия вкладки.

Когда подходит sessionStorage

  • временные поисковые данные;
  • данные текущей сессии;
  • фильтры;
  • промежуточные результаты.
sessionStorage.setItem(
  'search_users',
  JSON.stringify(data)
);

Кэширование запросов с debounce

Даже при наличии кэша количество запросов может быть слишком большим.

Комбинация debounce и кэша

function debounce(callback, delay) {

  let timeout;

  return (...args) => {

    clearTimeout(timeout);

    timeout = setTimeout(() => {
      callback(...args);
    }, delay);
  };
}
const search = debounce(async (query) => {
  await loadUsers(query);
}, 400);

Предотвращение дублирующихся запросов

Иногда пользователь быстро вводит одинаковые значения, пока первый запрос ещё не завершён.

Кэш промисов

const requests = new Map();

async function loadTags(query) {

  if (requests.has(query)) {
    return requests.get(query);
  }

  const promise = fetch(`/api/tags?q=${query}`)
    .then(response => response.json())
    .then(data => {

      requests.delete(query);

      return data;
    });

  requests.set(query, promise);

  return promise;
}

Кэширование с нормализацией ключей

Без нормализации строки:

Alex
alex
ALEX

будут считаться разными запросами.

Нормализация

function normalizeQuery(query) {
  return query
    .trim()
    .toLowerCase();
}
const key = normalizeQuery(query);

Частичное совпадение запросов

Иногда можно использовать уже загруженные данные для более длинного запроса.

Повторное использование результатов

Например:

a
al
ale
alex

Если результаты для alex уже загружены, повторный запрос не требуется.

function findClosestCache(query) {

  for (const key of cache.keys()) {

    if (query.startsWith(key)) {
      return cache.get(key);
    }
  }

  return null;
}

LRU-кэш

LRU (Least Recently Used) удаляет самые редко используемые записи.

Реализация LRU

class LRUCache {

  constructor(limit = 50) {
    this.limit = limit;
    this.cache = new Map();
  }

  get(key) {

    if (!this.cache.has(key)) {
      return null;
    }

    const value = this.cache.get(key);

    this.cache.delete(key);
    this.cache.set(key, value);

    return value;
  }

  set(key, value) {

    if (this.cache.has(key)) {
      this.cache.delete(key);
    }

    this.cache.set(key, value);

    if (this.cache.size > this.limit) {

      const oldest =
        this.cache.keys().next().value;

      this.cache.delete(oldest);
    }
  }
}

Интеграция LRU-кэша с Choices.js

const cache = new LRUCache(100);

async function searchUsers(query) {

  const cached = cache.get(query);

  if (cached) {

    choices.setChoices(
      cached,
      'value',
      'label',
      true
    );

    return;
  }

  const response =
    await fetch(`/api/users?q=${query}`);

  const data = await response.json();

  cache.set(query, data);

  choices.setChoices(
    data,
    'value',
    'label',
    true
  );
}

Кэширование при пагинации

При постраничной загрузке необходимо учитывать номер страницы.

Формирование ключа

const key = `${query}_${page}`;

Кэширование нескольких параметров

Если поиск зависит от фильтров:

  • страны;
  • категории;
  • роли;
  • сортировки;

ключ должен учитывать все параметры.

function buildKey(params) {

  return JSON.stringify(params);
}
const key = buildKey({
  query,
  category,
  sort
});

Инвалидация кэша

Инвалидация — удаление устаревших данных.

Полная очистка

cache.clear();

Очистка по ключу

cache.delete(query);

Очистка по маске

for (const key of cache.keys()) {

  if (key.startsWith('users_')) {
    cache.delete(key);
  }
}

Стратегии кэширования

Cache First

Сначала используется кэш.

if (cache.has(key)) {
  return cache.get(key);
}

Подходит для редко изменяемых данных.


Network First

Сначала выполняется запрос к серверу.

try {

  const data = await fetchData();

  cache.set(key, data);

  return data;

} catch {

  return cache.get(key);
}

Подходит для актуальных данных.


Stale While Revalidate

Пользователь мгновенно получает старые данные, а обновление выполняется в фоне.

async function getData(key) {

  const cached = cache.get(key);

  if (cached) {

    fetchFreshData(key);

    return cached;
  }

  return await fetchFreshData(key);
}

Обработка ошибок при кэшировании

Ошибки сети не должны разрушать интерфейс.

async function loadData(query) {

  try {

    const response =
      await fetch(`/api/search?q=${query}`);

    const data = await response.json();

    cache.set(query, data);

    return data;

  } catch (error) {

    if (cache.has(query)) {
      return cache.get(query);
    }

    throw error;
  }
}

Индикатор происхождения данных

Для отладки полезно понимать источник данных.

if (cache.has(query)) {
  console.log('cache');
} else {
  console.log('network');
}

Ограничение размера localStorage

Слишком большой кэш может вызвать исключение.

try {

  localStorage.setItem(
    key,
    JSON.stringify(data)
  );

} catch (error) {

  console.error('Storage limit exceeded');
}

Кэширование и серверная фильтрация

Choices.js часто работает с серверным поиском:

fetch(`/api/users?search=${query}`)

Кэш особенно эффективен при:

  • повторных запросах;
  • популярных поисковых строках;
  • возврате к предыдущим фильтрам;
  • автодополнении.

Предзагрузка данных

Некоторые данные можно загрузить заранее.

Prefetch

async function preloadUsers() {

  const response =
    await fetch('/api/users/popular');

  const data = await response.json();

  cache.set('popular', data);
}

Кэширование в Service Worker

Для офлайн-режима используется API Cache Storage.

self.addEventListener('fetch', event => {

  event.respondWith(

    caches.match(event.request)
      .then(response => {

        return response || fetch(event.request);
      })
  );
});

Оптимизация памяти

Не хранить лишние поля

Плохой вариант:

{
  id,
  name,
  avatar,
  biography,
  permissions,
  history,
  metadata
}

Оптимальный вариант:

{
  value: id,
  label: name
}

Кэширование изображений

Если элементы содержат аватары:

{
  value: 1,
  label: 'Alex',
  avatar: '/img/alex.jpg'
}

изображения также могут кэшироваться браузером автоматически.


Асинхронная инициализация с кэшем

const choices = new Choices('#users');

(async () => {

  const cached = getCache('users');

  if (cached) {

    choices.setChoices(
      cached,
      'value',
      'label',
      true
    );

    return;
  }

  const response =
    await fetch('/api/users');

  const data = await response.json();

  saveCache('users', data);

  choices.setChoices(
    data,
    'value',
    'label',
    true
  );

})();

Типичные ошибки

Кэширование ошибок сервера

Нельзя сохранять невалидный ответ:

if (!response.ok) {
  throw new Error('Server error');
}

Отсутствие очистки

Бесконечный рост кэша приводит к утечкам памяти.


Слишком большой TTL

Данные становятся неактуальными.


Слишком маленький TTL

Кэш теряет смысл.


Практическая архитектура

Наиболее распространённая схема:

Choices.js
    ↓
Debounce
    ↓
Проверка кэша
    ↓
Fetch API
    ↓
Обновление кэша
    ↓
Отображение данных

Комплексный пример

const cache = new LRUCache(100);

const CACHE_TTL = 300000;

async function searchUsers(query) {

  const key =
    query.trim().toLowerCase();

  const cached = cache.get(key);

  if (cached) {

    const expired =
      Date.now() - cached.timestamp > CACHE_TTL;

    if (!expired) {

      choices.setChoices(
        cached.data,
        'value',
        'label',
        true
      );

      return;
    }
  }

  try {

    const response =
      await fetch(`/api/users?q=${key}`);

    if (!response.ok) {
      throw new Error('Network error');
    }

    const data = await response.json();

    cache.set(key, {
      data,
      timestamp: Date.now()
    });

    choices.setChoices(
      data,
      'value',
      'label',
      true
    );

  } catch (error) {

    console.error(error);

    if (cached) {

      choices.setChoices(
        cached.data,
        'value',
        'label',
        true
      );
    }
  }
}