Курьер приезжает на склад, где связи нет вовсе. Он открывает приложение на планшете, отмечает принятые позиции, фотографирует повреждения и закрывает смену. Через час по дороге в город сеть появляется, и все данные уходят на сервер сами. Такая схема работает не на нативном приложении, а на обычной странице, и держится на нескольких браузерных механизмах, о которых стоит знать.
Встроенное хранилище прошло путь от простого к полноценной базе
Первые попытки дать странице постоянное хранилище были скромными. Данные хранились парами ключ и значение, а размер ограничивался несколькими мегабайтами на сайт.
Позже появился отдельный стандарт встроенной базы объектов. Поддержка в браузерах идёт с 2011 года, первая версия стандарта стала рекомендацией в январе 2015 года, вторая в январе 2018 года, третья в разработке с марта 2021 года.
Ключевое отличие в объёме и структуре. Простое хранилище подходит для настроек и небольших записей, встроенная база рассчитана на значительные объёмы структурированных данных, включая файлы и двоичные объекты. Ограничения по размеру у неё выше, а часто определяются свободным местом на устройстве.
Второе отличие это индексы. Данные хранятся по ключу, а дополнительные индексы позволяют искать по другим полям без полного перебора.
Третье отличие это транзакции. Набор операций выполняется целиком или не выполняется вовсе. Это защищает от состояния, когда половина изменений записалась, а половина нет.
Четвёртое отличие: хранятся не тексты, а объекты, почти любые структуры, поддерживаемые механизмом клонирования, включая вложенные объекты, массивы, даты и двоичные данные.
Схема базы и версии требуют аккуратного обращения
Работа начинается с открытия базы. Каждое действие выполняется через запрос, а результат приходит асинхронно через событие.
const request = indexedDB.open("warehouse", 2);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains("orders")) {
const store = db.createObjectStore("orders", { keyPath: "id" });
store.createIndex("by_status", "status");
store.createIndex("by_created", "createdAt");
}
};
request.onsuccess = (event) => {
const db = event.target.result;
console.log("Хранилище открыто, версия", db.version);
};
request.onerror = (event) => {
console.error("Не удалось открыть хранилище", event.target.error);
};
Номер версии в вызове открытия не декорация: при его увеличении срабатывает обработчик обновления, где создаются хранилища и индексы. Если версия в коде меньше установленной на устройстве, открытие завершится ошибкой, поэтому номер меняют только вместе с изменениями схемы.
Первая тонкость связана с одновременными вкладками. Если приложение открыто в двух окнах, а в одном из них выполняется обновление схемы, второе получит событие о блокировке. Обработку этого события стоит предусмотреть, иначе обновление зависнет.
Вторая тонкость касается удаления: удаление хранилища или индекса выполняется в том же обработчике обновления, данные исчезнут безвозвратно.
Третья тонкость это область видимости: данные привязаны к источнику страницы, один домен не увидит хранилище другого, а смена адреса означает потерю доступа к старым данным.
Запись и чтение выполняются в транзакциях
Вся работа с данными идёт через транзакции на нужные хранилища в определённом режиме. Режим только для чтения допускает параллельные обращения и потому быстрее, режим чтения и записи блокирует хранилище для других операций записи, но позволяет нескольким читателям.
function saveOrder(db, order) {
return new Promise((resolve, reject) => {
const tx = db.transaction("orders", "readwrite");
const store = tx.objectStore("orders");
store.put(order);
tx.oncomplete = () => resolve(order.id);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(new Error("Транзакция прервана"));
});
}
function readOrder(db, id) {
return new Promise((resolve, reject) => {
const tx = db.transaction("orders", "readonly");
const store = tx.objectStore("orders");
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
Обёртка в промис не украшение, а необходимость. Встроенный интерфейс работает на событиях, и без обёртки код превращается в лестницу обработчиков.
Вторая тонкость это время жизни транзакции: она завершается, когда обработчик перестаёт выполнять запросы. Если внутри запустить асинхронную операцию и ждать её, транзакция успеет закрыться, и следующий запрос завершится ошибкой. Все операции выполняются подряд, без ожиданий.
function countByStatus(db, status) {
return new Promise((resolve, reject) => {
const tx = db.transaction("orders", "readonly");
const index = tx.objectStore("orders").index("by_status");
const request = index.count(status);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
Третья тонкость это массовые операции: чтение всех записей по одному через цикл работает медленно, метод получения всех записей сразу, добавленный во второй версии стандарта, ускоряет работу заметно.
Очередь изменений позволяет работать без сети
Главная задача: принимать изменения без связи и отправлять их позже.
Первый элемент это очередь. Каждое действие записывается в отдельное хранилище с признаком состояния: ожидает отправки, отправлено, отклонено.
async function enqueue(db, action) {
const item = {
id: crypto.randomUUID(),
payload: action,
status: "ожидает",
attempts: 0,
createdAt: Date.now(),
};
await saveTo(db, "queue", item);
return item;
}
Второй элемент это порядок. Изменения отправляются в том порядке, в котором были сделаны. Иначе удаление позиции уйдёт раньше её добавления, и на сервере останется мусор.
Третий элемент это повторы. Отправка может не удаться из-за сети. Число попыток ограничивается, а интервал между ними растёт. После нескольких неудач запись помечается как требующая внимания.
async function flush(db) {
const pending = await readAllByStatus(db, "queue", "ожидает");
for (const item of pending.sort((a, b) => a.createdAt - b.createdAt)) {
try {
await send(item.payload);
await updateStatus(db, item.id, "отправлено");
} catch (error) {
const attempts = item.attempts + 1;
if (attempts >= 5) {
await updateStatus(db, item.id, "требует внимания");
} else {
await updateAttempts(db, item.id, attempts);
break;
}
}
}
}
Обратите внимание на прерывание цикла после первой ошибки. Отправлять следующие изменения, пока предыдущее не ушло, нельзя: порядок нарушится.
Четвёртый элемент это слияние. Данные, полученные с сервера, объединяются с локальными. Если одна и та же запись менялась в обоих местах, нужно правило разрешения конфликта. Простейшее правило это приоритет сервера, более сложное предполагает сравнение версий.
Автоматическая отправка освобождает пользователя от лишних действий
Ручная кнопка отправки работает, но её легко забыть. Есть механизм, который берёт эту заботу на себя.
Промежуточный слой между страницей и сетью позволяет зарегистрировать задачу и выполнить её, когда связь появится. Приложение только сообщает, что нужно синхронизировать, а браузер сам решает, когда это возможно.
async function requestSync() {
const registration = await navigator.serviceWorker.ready;
if ("sync" in registration) {
await registration.sync.register("flush-queue");
return;
}
// запасной вариант для браузеров без поддержки
window.addEventListener("online", () => flushQueue(), { once: true });
}
Обработчик живёт в фоновом сценарии и получает событие по имени задачи:
self.addEventListener("sync", (event) => {
if (event.tag === "flush-queue") {
event.waitUntil(flushQueueInWorker());
}
});
Важная тонкость: поддержка механизма неполная. Поэтому запасной вариант с обработкой события появления сети обязателен. Без него часть пользователей останется без автоматической отправки.
Второй момент касается надёжности фоновой отправки. Браузер не гарантирует, что задача выполнится сразу. Он может отложить её до удобного момента. Поэтому интерфейс не должен показывать состояние "отправлено" до подтверждения от сервера.
Объём и производительность требуют планирования
Локальное хранилище кажется бездонным, но это не так.
Первое ограничение это свободное место на устройстве. Браузер может запросить у пользователя разрешение на постоянное хранение, если данных много. Без разрешения система вправе очистить хранилище при нехватке места.
async function ensurePersistent() {
if (!navigator.storage || !navigator.storage.persist) {
return false;
}
const already = await navigator.storage.persisted();
if (already) {
return true;
}
return navigator.storage.persist();
}
async function showUsage() {
const estimate = await navigator.storage.estimate();
const usedMb = (estimate.usage / 1048576).toFixed(1);
const quotaMb = (estimate.quota / 1048576).toFixed(1);
console.log("Занято " + usedMb + " МБ из " + quotaMb + " МБ");
}
Второе ограничение это скорость. Каждая операция проходит через слои безопасности браузера, и это медленнее, чем работа с памятью. Тысяча отдельных чтений выполняется заметно дольше, чем одно чтение всех записей.
Третье ограничение это размер одной записи. Очень крупные объекты лучше хранить по частям. Например, фотографию грузить отдельно, а в записи держать только ссылку.
Четвёртое ограничение это индексы. Каждый индекс ускоряет поиск, но замедляет запись и занимает место. Заводить их стоит только под реально используемые запросы.
Очистка и миграция защищают от накопления мусора
Данные копятся, и без уборки хранилище превращается в свалку.
Первое правило это срок хранения. Отправленные изменения не нужно держать вечно. Через несколько дней их стоит удалять.
Второе правило это ограничение объёма очереди. Если изменений накопилось слишком много, стоит предупредить пользователя и предложить синхронизироваться.
Третье правило это миграция при смене схемы. Новые поля заполняются значениями по умолчанию, старые переносятся. Миграцию выполняют в том же обработчике обновления версии.
Четвёртое правило это обработка ошибок хранилища. Иногда база оказывается повреждена. Приложение должно уметь определить это и предложить пересоздать локальные данные, не теряя серверные.
async function resetLocalData(dbName) {
await new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase(dbName);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
request.onblocked = () => reject(new Error("Закройте другие вкладки"));
});
location.reload();
}
Такая функция пригодится в редких случаях, но её наличие избавляет от ситуации, когда пользователь не может работать вообще.
Пятое правило это понятный интерфейс состояния. Человек должен видеть, сколько изменений ждёт отправки и когда последний раз проходила синхронизация.
Отдельно стоит сказать о кэше приложения
Локальное хранилище и кэш оболочки часто путают, хотя это разные вещи.
Кэш оболочки хранит файлы приложения: разметку, стили, сценарии, изображения. Он наполняется при установке сервис-воркера и обновляется при выходе новой версии.
Локальное хранилище содержит данные: заказы, черновики, очередь изменений. Оно живёт дольше и не сбрасывается при обновлении приложения.
Разница проявляется в поведении при сбое. Если кэш повреждён, приложение не откроется вовсе. Если повреждены данные, приложение работает, но показывает пустые списки.
Отсюда вывод: стратегии очистки у двух механизмов должны быть разными, кэш можно сбрасывать смело, а данные удаляются только по явной команде пользователя.
Локальное хранилище в браузере получилось механизмом, который незаметен, пока всё работает. Курьер на складе без связи не думает о транзакциях и индексах, он просто делает свою работу. Вся сложность остаётся на стороне разработчика, и именно она определяет, переживёт ли приложение пропажу сети или превратится в источник потерянных данных.