Оффлайн-режим в современных веб-приложениях опирается на локальное хранилище браузера, позволяющее сохранять данные без постоянного доступа к сети. В экосистеме JavaScript наиболее структурированным способом работы с IndexedDB выступает Dexie.js, предоставляющая удобный слой абстракции над низкоуровневым API.
Ключевая задача оффлайн-архитектуры заключается в обеспечении непрерывности пользовательского опыта при отсутствии соединения и последующей корректной синхронизации изменений при восстановлении сети. Dexie.js выступает центральным элементом такого подхода благодаря поддержке транзакций, индексов и асинхронных операций.
Оффлайн-режим строится вокруг локальной базы данных, в которой хранится вся необходимая информация для работы приложения без сети. Dexie.js позволяет описывать структуру данных декларативно:
import Dexie fr om "dexie";
const db = new Dexie("AppDatabase");
db.version(1).stores({
notes: "++id, title, updatedAt, isSynced",
outbox: "++id, type, payload, createdAt, status"
});
В данной структуре:
notes — основная таблица пользовательских данныхoutbox — очередь действий для последующей
синхронизацииisSynced — флаг синхронизации состояния записиТакое разделение формирует основу паттерна Offline First + Outbox.
Outbox-подход предполагает запись всех изменений не только в основную таблицу, но и в специальную очередь операций. Каждое действие пользователя фиксируется как событие, которое позже будет отправлено на сервер.
Пример добавления записи:
async function createNote(title) {
const timestamp = Date.now();
await db.transaction("rw", db.notes, db.outbox, async () => {
const id = await db.notes.add({
title,
updatedAt: timestamp,
isSynced: false
});
await db.outbox.add({
type: "CREATE_NOTE",
payload: { id, title, updatedAt: timestamp },
createdAt: timestamp,
status: "pending"
});
});
}
Двойная запись обеспечивает согласованность между локальным состоянием и будущей синхронизацией.
Обработка outbox-очереди выполняется отдельным процессом, который активируется при появлении сети или по таймеру.
async function processOutbox() {
const pendingItems = await db.outbox
.wh ere("status")
.equals("pending")
.toArray();
for (const item of pendingItems) {
try {
await sendToServer(item);
await db.outbox.update(item.id, {
status: "sent"
});
await markAsSynced(item);
} catch (e) {
await db.outbox.update(item.id, {
status: "failed"
});
}
}
}
Функция sendToServer инкапсулирует транспортный слой
(fetch, WebSocket или GraphQL-mutation).
Контроль состояния данных критичен для оффлайн-режима. Dexie.js позволяет хранить метаинформацию о каждой записи:
Пример обновления статуса:
async function markAsSynced(outboxItem) {
const { id, payload } = outboxItem;
await db.notes.update(payload.id, {
isSynced: true
});
}
Расширенная модель может включать версии объектов:
db.version(2).stores({
notes: "++id, title, updatedAt, version, isSynced"
});
Версионирование позволяет реализовать контроль конфликтов.
Работа оффлайн-синхронизации тесно связана с событиями браузера:
window.addEventListener("online", () => {
processOutbox();
});
window.addEventListener("offline", () => {
console.log("offline mode active");
});
Однако наличие сети не гарантирует доступность backend-сервера, поэтому дополнительно применяется проверка “heartbeat”.
Service Worker используется для перехвата сетевых запросов и обеспечения устойчивости при нестабильном соединении.
self.addEventListener("fetch", event => {
event.respondWith(
fetch(event.request).catch(async () => {
const cached = await caches.match(event.request);
return cached;
})
);
});
Dexie.js в этом случае выступает как долговременное хранилище состояния, а Service Worker — как промежуточный кэш сетевого уровня.
Простейшая стратегия, при которой последняя версия данных перезаписывает предыдущую.
function resolveConflict(local, remote) {
return local.updatedAt > remote.updatedAt ? local : remote;
}
Подходит для некритичных данных.
Каждая запись содержит версию, увеличиваемую при каждом изменении.
await db.notes.update(id, {
version: Dexie.increment(1)
});
Конфликты разрешаются сравнением версий.
Сервер выступает арбитром состояния, возвращая финальную версию объекта.
Синхронизация включает два направления:
async function pushChanges() {
const changes = await db.outbox.where("status").equals("pending");
for (const change of await changes.toArray()) {
await fetch("/sync", {
method: "POST",
body: JSON.stringify(change)
});
}
}
async function pullChanges() {
const response = await fetch("/sync/updates");
const serverData = await response.json();
await db.notes.bulkPut(serverData.notes);
}
Снижение количества запросов достигается группировкой событий:
const batch = await db.outbox
.where("status")
.equals("pending")
.limit(50)
.toArray();
await db.outbox
.where({ type: "UPDATE_NOTE", "payload.id": id })
.delete();
Использование debounce:
let syncTimeout;
function scheduleSync() {
clearTimeout(syncTimeout);
syncTimeout = setTimeout(processOutbox, 3000);
}
Dexie.js позволяет эффективно индексировать поля, критичные для синхронизации:
db.version(3).stores({
notes: "++id, updatedAt, isSynced, version",
outbox: "++id, status, createdAt"
});
Индексация status ускоряет выборку очереди
синхронизации, а updatedAt упрощает сортировку
изменений.
Конфликт возникает при одновременном изменении одной сущности локально и на сервере.
Типовой алгоритм:
async function resolveNoteConflict(local, remote) {
if (local.version === remote.version) {
return remote;
}
return local.updatedAt > remote.updatedAt ? local : remote;
}
Outbox может рассматриваться как журнал событий (event log). Такой подход позволяет восстанавливать состояние приложения:
async function rebuildState() {
const events = await db.outbox.orderBy("createdAt").toArray();
let state = {};
for (const event of events) {
state = applyEvent(state, event);
}
return state;
}
Некоторые данные могут оставаться только локальными, не участвуя в синхронизации. Dexie.js поддерживает разделение:
db.version(4).stores({
notes: "++id, updatedAt, isSynced",
uiCache: "key",
tempData: "++id"
});
При росте локальной базы важны стратегии оптимизации:
offset/limitwhere вместо полного сканированияawait db.outbox
.where("status")
.equals("sent")
.delete();
Dexie.js поддерживает наблюдаемые коллекции через
liveQuery:
import { liveQuery } from "dexie";
const liveNotes = liveQuery(() =>
db.notes.orderBy("updatedAt").toArray()
);
Это позволяет автоматически обновлять UI при изменении локальных данных, включая оффлайн-операции.
При оффлайн-синхронизации критично учитывать:
async function safeSend(item) {
if (!item.payload || !item.type) return;
return fetch("/sync", {
method: "POST",
headers: { "Idempotency-Key": item.id },
body: JSON.stringify(item)
});
}
В сложных системах применяется комбинация стратегий:
Dexie.js в таких сценариях выступает как единый слой локального состояния, объединяющий все потоки данных в консистентную модель.