Dexie.liveQuery(): основы

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

Ключевая идея заключается в том, что результат запроса рассматривается как динамическое значение во времени, а не как одноразовый снимок.


Базовая модель работы liveQuery

Dexie.liveQuery() принимает функцию, внутри которой выполняется любой чтение-ориентированный код Dexie. Эта функция возвращает значение или промис, который резолвится в итоговый результат запроса.

import Dexie fr om "dexie";

const db = new Dexie("app");

db.version(1).stores({
  todos: "++id,title,done"
});

const query$ = Dexie.liveQuery(() => {
  return db.todos.wh ere("done").equals(0).toArray();
});

query$ в этом примере — не обычный массив и не промис, а Observable-подобный поток значений. При каждом изменении таблицы todos результат автоматически пересчитывается.


Подписка на изменения

Чтобы начать получать значения, используется подписка:

const subscription = query$.subscribe({
  next: (value) => {
    console.log("Список задач:", value);
  },
  error: (err) => {
    console.error("Ошибка liveQuery:", err);
  }
});

Подписка получает:

  • next — новый результат запроса
  • error — ошибку выполнения запроса

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


Механизм реактивности Dexie

Dexie отслеживает зависимости запроса на уровне транзакций чтения. Когда внутри liveQuery выполняется чтение из таблицы, библиотека регистрирует, какие индексы и записи были затронуты.

При последующих изменениях данных Dexie:

  1. Определяет, затронут ли результат запроса
  2. Если затронут — пересчитывает функцию
  3. Эмитит новое значение в подписку

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


Отмена подписки

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

subscription.unsubscribe();

После вызова unsubscribe поток перестаёт реагировать на изменения базы.


Поведение при ошибках

Если внутри функции запроса происходит ошибка, она передаётся в error обработчик. Подписка при этом может завершиться или продолжить работу в зависимости от типа ошибки и контекста выполнения.

const q$ = Dexie.liveQuery(() => {
  return db.todos.where("unknownIndex").toArray();
});

q$.subscribe({
  next: console.log,
  error: (e) => {
    console.error("Запрос упал:", e);
  }
});

Ошибки часто возникают при:

  • обращении к несуществующим индексам
  • нарушении схемы данных
  • проблемах миграции версии базы

Возвращаемые значения и типы

liveQuery поддерживает любые возвращаемые значения:

  • массивы (toArray)
  • одиночные объекты (first, get)
  • агрегированные данные
  • примитивы (числа, строки)
  • результаты Promise
const count$ = Dexie.liveQuery(() => {
  return db.todos.count();
});

Каждое изменение таблицы, влияющее на количество записей, приведёт к новому значению.


Использование с асинхронной логикой

Функция внутри liveQuery может быть async, что позволяет использовать await:

const enriched$ = Dexie.liveQuery(async () => {
  const todos = await db.todos.toArray();

  return todos.map(t => ({
    ...t,
    priority: t.done ? 0 : 1
  }));
});

Dexie корректно обрабатывает Promise и интегрирует его в поток значений.


Гранулярность обновлений

Обновление происходит не по изменению конкретного поля, а по влиянию изменения на результат запроса. Это означает:

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

Пример фильтрации и сортировки

const activeSorted$ = Dexie.liveQuery(() => {
  return db.todos
    .where("done")
    .equals(0)
    .sortBy("title");
});

Любое добавление, удаление или обновление done или title может привести к пересчёту результата.


Использование в UI-реактивности

liveQuery часто применяется как слой реактивных данных для UI-фреймворков.

Пример интеграции с React-подобной моделью:

function useLiveTodos() {
  const [state, setState] = useState([]);

  useEffect(() => {
    const sub = Dexie.liveQuery(() =>
      db.todos.where("done").equals(0).toArray()
    ).subscribe(setState);

    return () => sub.unsubscribe();
  }, []);

  return state;
}

Такой подход устраняет необходимость вручную синхронизировать состояние с IndexedDB.


Поведение транзакций

Функция liveQuery выполняется внутри чтения базы данных. Это означает:

  • чтения консистентны на момент выполнения
  • нет необходимости вручную открывать транзакции
  • Dexie самостоятельно управляет scope транзакции

Однако вложенные асинхронные операции могут выходить за пределы одной транзакции, поэтому важно учитывать возможные изменения между await-шагами.


Оптимизация производительности

При активном использовании liveQuery важно учитывать частоту обновлений:

  • избыточные подписки увеличивают нагрузку на IndexedDB
  • сложные вычисления внутри запроса замедляют пересчёт
  • агрегации лучше переносить в индексируемые поля

Оптимальный подход — минимизировать объём данных внутри liveQuery и избегать тяжёлых преобразований.


Кэширование поведения

Dexie не кэширует результат liveQuery в традиционном смысле, но:

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

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


Множественные подписки

Один и тот же liveQuery можно использовать несколькими подписчиками:

const q$ = Dexie.liveQuery(() => db.todos.toArray());

q$.subscribe(setA);
q$.subscribe(setB);

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


Отличие от обычных промисов

Обычный запрос:

const todos = await db.todos.toArray();

выполняется один раз.

liveQuery:

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

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

Несмотря на удобство, существуют ограничения:

  • отсутствие глубокой дифференциации изменений (пересчитывается результат целиком)
  • зависимость от корректных индексов для эффективности
  • невозможность реактивности вне IndexedDB-операций
  • потенциальные повторные вычисления при сложных запросах

Композиция нескольких liveQuery

Можно комбинировать несколько потоков:

const active$ = Dexie.liveQuery(() =>
  db.todos.where("done").equals(0).toArray()
);

const count$ = Dexie.liveQuery(() =>
  db.todos.where("done").equals(0).count()
);

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


Управление жизненным циклом

Типичный жизненный цикл включает:

  1. создание liveQuery
  2. подписку
  3. получение серии значений
  4. отмену подписки

Отсутствие шага отмены приводит к накоплению активных слушателей, что критично в долгоживущих приложениях.


Влияние структуры базы

Эффективность liveQuery напрямую зависит от схемы IndexedDB:

  • корректные индексы ускоряют фильтрацию
  • нормализованные структуры уменьшают пересчёты
  • избыточные поля увеличивают объём реактивных обновлений

Проектирование схемы базы становится ключевым фактором производительности реактивных запросов.


Поведение при массовых изменениях

При массовых операциях записи:

await db.transaction("rw", db.todos, async () => {
  await db.todos.clear();
  await db.todos.bulkAdd([...items]);
});

liveQuery может эмитить одно или несколько обновлений, в зависимости от того, как Dexie агрегирует изменения внутри транзакции и после её завершения.


Использование с фильтрацией в памяти

Хотя возможно выполнять фильтрацию внутри liveQuery, например:

Dexie.liveQuery(() =>
  db.todos.toArray().then(list =>
    list.filter(x => x.title.startsWith("A"))
  )
);

такой подход менее эффективен, поскольку:

  • IndexedDB не используется для фильтрации
  • увеличивается объём пересчитываемых данных
  • реактивность становится дороже по CPU

Стабильность ссылок результатов

Каждое новое значение liveQuery возвращает новый объект или массив. Это важно для UI:

  • сравнение по ссылке всегда показывает изменение
  • мемоизация должна учитывать частые обновления
  • оптимизация требует shallow-equal или аналогов

Сценарии применения

liveQuery особенно эффективен в задачах:

  • списки задач и фильтры
  • чаты и сообщения
  • синхронизация локального кэша
  • офлайн-first приложения
  • панели аналитики на локальных данных

Во всех случаях ключевым фактором является необходимость синхронизации UI с IndexedDB без ручного управления состоянием.