Любое долговременное использование клиентского хранилища неизбежно
приводит к ситуации, когда первоначальная структура данных перестаёт
соответствовать новым требованиям приложения. В случае с
localForage, который абстрагирует работу над
IndexedDB, WebSQL и localStorage,
эта проблема проявляется особенно явно: данные могут храниться годами, а
код приложения обновляется значительно чаще.
Основная сложность заключается в том, что localForage не
предоставляет встроенной системы миграций схемы. Это означает, что
ответственность за согласованность структуры данных полностью лежит на
приложении.
Типичные изменения, требующие миграции:
Игнорирование этих изменений приводит к накоплению «устаревших» данных, которые начинают ломать бизнес-логику.
Перед рассмотрением миграций важно понимать, как именно
localForage хранит данные.
В зависимости от драйвера:
IndexedDB — хранение в виде object store;WebSQL — таблицы с ключ-значение;localStorage — простые пары ключ/значение.Ключевой момент: независимо от драйвера, localForage
оперирует логической моделью key-value storage, где
значение может быть сериализованным объектом.
Это создаёт иллюзию отсутствия схемы, но фактически схема существует на уровне приложения.
Пример:
localforage.setItem('user', {
id: 1,
name: 'Alex',
theme: 'dark'
});
Любое изменение структуры этого объекта требует контроля версий.
Отсутствие миграций приводит к накоплению несовместимых данных.
Рассмотрим сценарий:
{
id: 1,
name: "Alex"
}
{
id: 1,
fullName: "Alex",
settings: {
theme: "dark"
}
}
Старые записи:
settings;name вместо fullName.Если приложение начинает ожидать новую структуру, без миграции возникают:
undefined ошибки;Ключевой подход — введение версии схемы данных.
Обычно используется отдельный служебный ключ:
localforage.setItem('schemaVersion', 2);
При запуске приложения выполняется проверка:
const version = await localforage.getItem('schemaVersion');
Если версия отсутствует, считается, что данные относятся к первому релизу.
Данные загружаются, преобразуются и сохраняются заново.
async function migrateV1toV2() {
const user = await localforage.getItem('user');
if (!user) return;
const migrated = {
id: user.id,
fullName: user.name,
settings: {
theme: 'light'
}
};
await localforage.setItem('user', migrated);
}
Подходит для небольших объёмов данных.
Миграция выполняется при чтении данных.
async function getUser() {
const user = await localforage.getItem('user');
if (user && user.name && !user.fullName) {
const migrated = {
id: user.id,
fullName: user.name,
settings: { theme: 'light' }
};
await localforage.setItem('user', migrated);
return migrated;
}
return user;
}
Преимущество — распределение нагрузки по времени.
Недостаток — смешение старых и новых форматов в хранилище.
Используется цепочка версий:
const migrations = {
1: async () => {
const user = await localforage.getItem('user');
if (user) {
await localforage.setItem('user', {
id: user.id,
fullName: user.name
});
}
await localforage.setItem('schemaVersion', 2);
},
2: async () => {
const user = await localforage.getItem('user');
if (user) {
user.settings = { theme: 'dark' };
await localforage.setItem('user', user);
}
await localforage.setItem('schemaVersion', 3);
}
};
Инициализация:
async function runMigrations() {
let version = await localforage.getItem('schemaVersion');
if (!version) version = 1;
while (migrations[version]) {
await migrations[version]();
version++;
}
}
В реальных приложениях редко используется один ключ. Обычно структура включает множество сущностей:
userssettingscachesessionПример миграции набора данных:
async function migrateAllUsers() {
const keys = await localforage.keys();
for (const key of keys) {
if (!key.startsWith('user_')) continue;
const user = await localforage.getItem(key);
if (user && user.name) {
user.fullName = user.name;
delete user.name;
await localforage.setItem(key, user);
}
}
}
Здесь важно учитывать производительность, особенно при больших
наборах данных в IndexedDB.
Асинхронная природа localForage приводит к потенциальным
проблемам:
Типичный сценарий гонки:
setItem.let migrationLock = false;
async function safeMigrate() {
if (migrationLock) return;
migrationLock = true;
try {
await runMigrations();
} finally {
migrationLock = false;
}
}
В более сложных системах используется persisted-lock через storage.
Миграции должны быть идемпотентными и устойчивыми к сбоям.
Типичные причины ошибок:
Пример безопасной миграции:
async function safeMigrationStep() {
const user = await localforage.getItem('user');
if (!user || typeof user !== 'object') {
return;
}
if (!('fullName' in user)) {
user.fullName = user.name || 'unknown';
}
await localforage.setItem('user', user);
}
При больших объёмах данных важно учитывать особенности
IndexedDB:
Практика оптимизации:
setTimeout или
requestIdleCallback;Пример чанковой миграции:
async function migrateInChunks(keys, chunkSize = 50) {
for (let i = 0; i < keys.length; i += chunkSize) {
const chunk = keys.slice(i, i + chunkSize);
await Promise.all(chunk.map(async (key) => {
const value = await localforage.getItem(key);
if (value && value.oldField) {
value.newField = value.oldField;
delete value.oldField;
await localforage.setItem(key, value);
}
}));
}
}
При обновлении клиентского приложения важно синхронизировать:
Распространённый подход — хранение метаданных:
{
schemaVersion: 3,
appVersion: "2.1.0"
}
Это позволяет:
Если миграции не реализованы, со временем возникает деградация:
if (oldField || newField);В долгосрочной перспективе это приводит к тому, что локальное
хранилище становится неконтролируемым источником ошибок, несмотря на
внешнюю простоту localForage.