Кэширование результатов запросов

Кэширование результатов запросов в Dexie.js опирается на особенности IndexedDB как долговременного клиентского хранилища и на модель асинхронных транзакций, позволяющую безопасно сохранять, переиспользовать и инвалидировать ранее вычисленные данные. Основная цель кэширования в данном контексте — снижение числа обращений к источнику данных (серверу или вычислительно дорогим операциям), ускорение повторных запросов и обеспечение оффлайн-доступа к уже полученной информации.

IndexedDB не предоставляет встроенного уровня HTTP-кэша или query-cache, поэтому кэширование реализуется на уровне прикладной логики. Dexie.js упрощает работу с этой моделью за счёт декларативного описания схемы и удобного API для транзакций.

Типичная модель кэша включает:

  • ключ запроса (cache key)
  • сериализованные параметры запроса
  • результат (payload)
  • метаданные (время создания, TTL, версия)

Простейшая структура таблицы:

db.cache.store('requests', {
  key: 'string',
  data: 'any',
  upd atedAt: 'number',
  ttl: 'number'
});

На практике схема описывается через versioned schema Dexie:

const db = new Dexie('app');

db.version(1).stores({
  cache: 'key, upd atedAt'
});

Паттерн cache-aside

Наиболее распространённая стратегия кэширования в Dexie — cache-aside (ленивый кэш):

  1. Проверяется наличие данных в локальном хранилище
  2. Если данные валидны — возвращается кэш
  3. Если данных нет или они устарели — выполняется запрос к источнику
  4. Результат сохраняется в IndexedDB
  5. Возвращается актуальное значение

Пример реализации:

async function getUsers() {
  const cached = await db.cache.get('users:list');

  if (cached && !isExpired(cached)) {
    return cached.data;
  }

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

  await db.cache.put({
    key: 'users:list',
    data,
    updatedAt: Date.now(),
    ttl: 60_000
  });

  return data;
}

Функция isExpired обычно реализуется через сравнение текущего времени с updatedAt + ttl.

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

Ключевой аспект надёжного кэширования — стратегия инвалидирования. В Dexie.js это реализуется явно, так как автоматических механизмов нет.

Временная инвалидация (TTL)

Подход основан на проверке срока жизни записи:

function isExpired(entry) {
  return Date.now() > entry.updatedAt + entry.ttl;
}

TTL подходит для данных с предсказуемой частотой обновления: справочники, списки категорий, публичные данные.

Событийная инвалидация

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

async function updateUser(user) {
  await fetch(`/api/users/${user.id}`, {
    method: 'PUT',
    body: JSON.stringify(user)
  });

  await db.cache.delete('users:list');
}

Здесь кэш удаляется полностью, что гарантирует консистентность при следующем запросе.

Версионная инвалидация

При изменении структуры данных или логики запроса используется ключ с версией:

const cacheKey = `users:list:v2`;

Этот подход позволяет избежать массового очищения таблицы и постепенно мигрировать к новому формату.

Составные ключи и нормализация запросов

Для сложных запросов кэш-ключ должен однозначно отражать параметры:

function buildCacheKey(params) {
  return `users:${params.page}:${params.sort}:${params.filter}`;
}

Важно нормализовать параметры:

  • сортировка ключей объекта
  • удаление незначащих полей
  • унификация форматов (например, строки дат)

Без нормализации кэш теряет эффективность из-за дублирования записей.

Кэширование запросов IndexedDB через Dexie Query Layer

Dexie позволяет строить запросы поверх индексов, но сам по себе результат query не кэшируется. Поэтому создаётся слой обёртки:

async function cachedQuery(key, queryFn, ttl = 60000) {
  const cached = await db.cache.get(key);

  if (cached && !isExpired(cached)) {
    return cached.data;
  }

  const result = await queryFn();

  await db.cache.put({
    key,
    data: result,
    updatedAt: Date.now(),
    ttl
  });

  return result;
}

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

const users = await cachedQuery(
  'users:active',
  () => db.users.where('active').equals(1).toArray(),
  30000
);

Частичное кэширование и нормализация данных

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

Структура:

  • таблица users
  • таблица queries
  • таблица cacheIndex

Пример:

db.version(1).stores({
  users: 'id',
  cacheIndex: 'key'
});

Схема:

  • результаты запросов сохраняются как список ID
  • данные пользователей хранятся отдельно
  • пересборка происходит локально

Это уменьшает дублирование данных и упрощает инвалидирование.

Инкрементальное обновление кэша

Вместо полного обновления данных применяется стратегия patch-update:

async function syncUsers() {
  const updates = await fetch('/api/users/changes');

  await db.transaction('rw', db.users, async () => {
    for (const user of updates) {
      await db.users.put(user);
    }
  });
}

Кэш запросов перестаёт быть источником истины и становится проекцией локальной базы.

Использование liveQuery и влияние на кэширование

Dexie.js предоставляет механизм реактивных запросов через liveQuery, который изменяет подход к кэшированию. Вместо ручного контроля обновлений данные автоматически пересчитываются при изменении таблиц.

import { liveQuery } fr om 'dexie';

const activeUsers$ = liveQuery(() =>
  db.users.wh ere('active').equals(1).toArray()
);

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

Многоуровневое кэширование

Практическая архитектура часто включает несколько уровней:

  1. Memory cache (внутри JS-процесса)
  2. IndexedDB cache (Dexie)
  3. Remote API

Схема доступа:

  • сначала memory
  • затем Dexie IndexedDB
  • затем сеть

Пример memory-слоя:

const memoryCache = new Map();

async function getData(key, loader) {
  if (memoryCache.has(key)) {
    return memoryCache.get(key);
  }

  const cached = await db.cache.get(key);
  if (cached && !isExpired(cached)) {
    memoryCache.se t(key, cached.data);
    return cached.data;
  }

  const data = await loader();
  memoryCache.se t(key, data);
  await db.cache.put({ key, data, upd atedAt: Date.now(), ttl: 60000 });

  return data;
}

Конкурентные запросы и дедупликация

При параллельных вызовах одного и того же запроса возможна гонка на запись. Решается через in-flight cache:

const inFlight = new Map();

function deduped(key, fn) {
  if (inFlight.has(key)) {
    return inFlight.get(key);
  }

  const promise = fn().finally(() => {
    inFlight.delete(key);
  });

  inFlight.se t(key, promise);
  return promise;
}

Интеграция с Dexie-кэшем предотвращает дублирующие сетевые запросы.

Сжатие и оптимизация объёма кэша

IndexedDB имеет ограничения по квотам браузера, поэтому кэш часто оптимизируют:

  • хранение только необходимых полей
  • сериализация в компактные структуры
  • агрегация данных вместо полного списка

Пример агрегации:

const summary = {
  total: users.length,
  active: users.filter(u => u.active).length
};

Вместо хранения полного массива может храниться агрегат плюс ключи.

Очистка и обслуживание кэша

Регулярная очистка выполняется через batch-процедуры:

async function cleanCache() {
  const now = Date.now();

  await db.cache
    .where('updatedAt')
    .below(now - 24 * 60 * 60 * 1000)
    .delete();
}

Также используется стратегия LRU на уровне приложения, если требуется ограничение размера.

Кэширование сложных запросов с join-подобной логикой

Dexie не поддерживает SQL join напрямую, поэтому результат сложных выборок часто кэшируется целиком:

async function getDashboard() {
  return cachedQuery('dashboard:v1', async () => {
    const users = await db.users.toArray();
    const orders = await db.orders.toArray();

    return {
      users,
      orders
    };
  });
}

Такой подход переносит вычисления из runtime в слой кэша, снижая нагрузку на повторные вычисления.