TypeScript и IndexedDB

TypeScript и IndexedDB

В этой статье объясняется, что такое TypeScript и IndexedDB.

Мы расскажем о TypeScript и IndexedDB на практических примерах.

YouTube Video

TypeScript и IndexedDB

IndexedDB — это низкоуровневое хранилище NoSQL, позволяющее сохранять структурированные данные в браузере. С помощью TypeScript вы можете описывать схемы типобезопасно, что уменьшает количество ошибок и повышает удобство поддержки кода.

Базовая терминология и рабочий процесс

IndexedDB — это небольшая база данных внутри браузера. Она управляет данными с помощью таких механизмов, как базы данных с именами и версиями, хранилища объектов, транзакции, индексы и курсоры. У баз данных есть версии, и при обновлении версии вызывается onupgradeneeded для обновления схемы, например, создания или изменения таблиц.

Открытие IndexedDB (базовый шаблон)

Сначала покажем пример открытия базы данных в IndexedDB и создания хранилища объектов в onupgradeneeded, если это необходимо.

 1// Open an IndexedDB database and create an object store if needed.
 2// This code shows the classic callback-based IndexedDB API wrapped into a Promise.
 3function openDatabase(dbName: string, version: number): Promise<IDBDatabase> {
 4  return new Promise((resolve, reject) => {
 5    const request = indexedDB.open(dbName, version);
 6
 7    request.onerror = () => {
 8      reject(request.error);
 9    };
10
11    request.onupgradeneeded = (event) => {
12      const db = request.result;
13      if (!db.objectStoreNames.contains('todos')) {
14        // Create object store with keyPath 'id'
15        db.createObjectStore('todos', { keyPath: 'id' });
16      }
17    };
18
19    request.onsuccess = () => {
20      resolve(request.result);
21    };
22  });
23}
24
25// Usage example:
26openDatabase('my-db', 1)
27  .then(db => {
28    console.log('DB opened', db.name, db.version);
29    db.close();
30  })
31  .catch(err => console.error('Failed to open DB', err));
  • Этот код открывает базу данных и записывает результат об успехе или ошибке.
  • При необходимости хранилище todos создаётся в onupgradeneeded.

Определение типов в TypeScript (модели)

Далее мы определяем типы данных с помощью TypeScript. Это обеспечивает типобезопасность при последующих операциях CRUD.

1// Define the TypeScript interface for a Todo item.
2// This helps with type safety in the database operations.
3interface Todo {
4  id: string;          // primary key
5  title: string;
6  completed: boolean;
7  createdAt: number;
8}
  • Здесь мы определяем тип Todo.

Примеры простых функций для реализации CRUD-операций

Далее представлены базовые CRUD-операции: добавление, получение, обновление и удаление объектов из хранилища. Каждая функция принимает IDBDatabase и возвращает Promise.

 1// CRUD utilities for the 'todos' object store.
 2// Each operation uses a transaction and returns a Promise for easier async/await usage.
 3
 4function addTodo(db: IDBDatabase, todo: Todo): Promise<void> {
 5  return new Promise((resolve, reject) => {
 6    const tx = db.transaction('todos', 'readwrite');
 7    const store = tx.objectStore('todos');
 8    const req = store.add(todo);
 9
10    req.onsuccess = () => {
11      console.log('Todo added', todo.id);
12    };
13    req.onerror = () => reject(req.error);
14
15    tx.oncomplete = () => resolve();
16    tx.onerror = () => reject(tx.error);
17  });
18}
  • Эта функция добавляет новый элемент Todo в хранилище todos в IndexedDB. Функция возвращает Promise для асинхронной обработки, который выполняется по завершении операции.
 1function getTodo(db: IDBDatabase, id: string): Promise<Todo | undefined> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readonly');
 4    const store = tx.objectStore('todos');
 5    const req = store.get(id);
 6
 7    req.onsuccess = () => resolve(req.result as Todo | undefined);
 8    req.onerror = () => reject(req.error);
 9  });
10}
  • Эта функция получает Todo с указанным ID и возвращает объект, если он найден. Если подходящие данные не найдены, возвращается undefined.
 1function updateTodo(db: IDBDatabase, todo: Todo): Promise<void> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readwrite');
 4    const store = tx.objectStore('todos');
 5    const req = store.put(todo);
 6
 7    req.onsuccess = () => {
 8      console.log('Todo updated', todo.id);
 9    };
10    req.onerror = () => reject(req.error);
11
12    tx.oncomplete = () => resolve();
13    tx.onerror = () => reject(tx.error);
14  });
15}
  • Эта функция обновляет существующие данные Todo. В случае успеха ID обновленного Todo записывается в журнал.
 1function deleteTodo(db: IDBDatabase, id: string): Promise<void> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readwrite');
 4    const store = tx.objectStore('todos');
 5    const req = store.delete(id);
 6
 7    req.onsuccess = () => {
 8      console.log('Todo deleted', id);
 9    };
10    req.onerror = () => reject(req.error);
11
12    tx.oncomplete = () => resolve();
13    tx.onerror = () => reject(tx.error);
14  });
15}
  • Эта функция удаляет Todo с указанным ID. При успешном удалении выводится ID удалённого элемента.

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

Индексы и сложные запросы

Используя индексы в IndexedDB, можно эффективно искать по определённым полям. Здесь мы создаём индекс по полю createdAt и приводим пример диапазонного запроса.

 1// When opening DB, create an index for createdAt.
 2// Then demonstrate a range query using the index.
 3
 4function openDatabaseWithIndex(dbName: string, version: number): Promise<IDBDatabase> {
 5  return new Promise((resolve, reject) => {
 6    const request = indexedDB.open(dbName, version);
 7
 8    request.onupgradeneeded = () => {
 9      const db = request.result;
10      if (!db.objectStoreNames.contains('todos')) {
11        const store = db.createObjectStore('todos', { keyPath: 'id' });
12        // Create an index on createdAt for sorting/filtering
13        store.createIndex('by-createdAt', 'createdAt', { unique: false });
14      } else {
15        const store = request.transaction!.objectStore('todos');
16        if (!store.indexNames.contains('by-createdAt')) {
17          store.createIndex('by-createdAt', 'createdAt', { unique: false });
18        }
19      }
20    };
21
22    request.onerror = () => reject(request.error);
23    request.onsuccess = () => resolve(request.result);
24  });
25}
  • Эта функция открывает базу данных и создаёт или проверяет индекс by-createdAt по полю createdAt. Это позволяет эффективно искать и сортировать данные по дате создания.
 1async function getTodosCreatedAfter(db: IDBDatabase, timestamp: number): Promise<Todo[]> {
 2  return new Promise((resolve, reject) => {
 3    const tx = db.transaction('todos', 'readonly');
 4    const store = tx.objectStore('todos');
 5    const index = store.index('by-createdAt');
 6    const range = IDBKeyRange.lowerBound(timestamp, true); // exclusive
 7    const req = index.openCursor(range);
 8
 9    const results: Todo[] = [];
10    req.onsuccess = (event) => {
11      const cursor = (event.target as IDBRequest).result as IDBCursorWithValue | null;
12      if (cursor) {
13        results.push(cursor.value as Todo);
14        cursor.continue();
15      } else {
16        resolve(results);
17      }
18    };
19    req.onerror = () => reject(req.error);
20  });
21}
  • Эта функция получает только те Todo, которые были созданы после указанной временной метки. Использование индекса позволяет эффективно просматривать данные в порядке даты создания.

  • В этом примере индекс by-createdAt создается при обновлении базы данных, и элементы Todo, созданные после указанного времени, перебираются с помощью курсора.

Лёгкая обёртка на основе Promise

Низкоуровневый API IndexedDB сложен для написания, а повторяющиеся похожие операции могут привести к избыточности и ошибкам. Поэтому, подготовив обобщённый обёрточный класс TypeScript, абстрагирующий операции, мы повышаем простоту и сопровождаемость кода. Ниже приведена реализация, ориентированная на базовые возможности.

1// A minimal TypeScript wrapper around IndexedDB to simplify common operations.
2// This class is generic over the store's value type and assumes 'keyPath' is 'id'.
3
4class IDBWrapper<T extends { id: string }> {
5  private dbPromise: Promise<IDBDatabase>;
6
7  constructor(private dbName: string, private version: number, private storeName: string) {
8    this.dbPromise = this.open();
9  }
  • Этот класс оборачивает операции IndexedDB и предоставляет типобезопасные методы CRUD. Предполагается, что у хранилища объектов ключом является id.
 1  private open(): Promise<IDBDatabase> {
 2    return new Promise((resolve, reject) => {
 3      const req = indexedDB.open(this.dbName, this.version);
 4      req.onerror = () => reject(req.error);
 5      req.onupgradeneeded = () => {
 6        const db = req.result;
 7        if (!db.objectStoreNames.contains(this.storeName)) {
 8          db.createObjectStore(this.storeName, { keyPath: 'id' });
 9        }
10      };
11      req.onsuccess = () => resolve(req.result);
12    });
13  }
  • Он открывает базу данных и создаёт новое хранилище объектов при необходимости. Инициализация хранилища выполняется через событие обновления.
 1  async add(item: T): Promise<void> {
 2    const db = await this.dbPromise;
 3    await new Promise<void>((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readwrite');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.add(item);
 7      req.onsuccess = () => {
 8        console.log('added', item.id);
 9      };
10      req.onerror = () => reject(req.error);
11      tx.oncomplete = () => resolve();
12      tx.onerror = () => reject(tx.error);
13    });
14  }
  • Добавляет данные в хранилище IndexedDB. После добавления ID выводится в консоль.
 1  async get(id: string): Promise<T | undefined> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readonly');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.get(id);
 7      req.onsuccess = () => resolve(req.result as T | undefined);
 8      req.onerror = () => reject(req.error);
 9    });
10  }
  • Получает данные по заданному ID. undefined возвращается, если данных не существует.
 1  async put(item: T): Promise<void> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readwrite');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.put(item);
 7      req.onsuccess = () => {
 8        console.log('put', item.id);
 9      };
10      req.onerror = () => reject(req.error);
11      tx.oncomplete = () => resolve();
12      tx.onerror = () => reject(tx.error);
13    });
14  }
  • Обновляет существующие данные или добавляет новые. После обработки выводится обновлённый ID.
 1  async delete(id: string): Promise<void> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readwrite');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.delete(id);
 7      req.onsuccess = () => {
 8        console.log('deleted', id);
 9      };
10      req.onerror = () => reject(req.error);
11      tx.oncomplete = () => resolve();
12      tx.onerror = () => reject(tx.error);
13    });
14  }
  • Удаляет данные с указанным ID. В случае успеха выводится ID удалённых данных.
 1  async getAll(): Promise<T[]> {
 2    const db = await this.dbPromise;
 3    return new Promise((resolve, reject) => {
 4      const tx = db.transaction(this.storeName, 'readonly');
 5      const store = tx.objectStore(this.storeName);
 6      const req = store.getAll();
 7      req.onsuccess = () => resolve(req.result as T[]);
 8      req.onerror = () => reject(req.error);
 9    });
10  }
11}
  • Получает все данные из хранилища. Возвращается массив типа T.
 1// Example usage with Todo type:
 2interface Todo {
 3  id: string;
 4  title: string;
 5  completed: boolean;
 6  createdAt: number;
 7}
 8
 9const todoStore = new IDBWrapper<Todo>('my-db', 1, 'todos');
10
11(async () => {
12  const newTodo: Todo = { id: '1', title: 'Learn IndexedDB', completed: false, createdAt: Date.now() };
13  await todoStore.add(newTodo);
14  const fetched = await todoStore.get('1');
15  console.log('fetched', fetched);
16  newTodo.completed = true;
17  await todoStore.put(newTodo);
18  const all = await todoStore.getAll();
19  console.log('all todos', all);
20  await todoStore.delete('1');
21})();
  • Этот код демонстрирует пример использования класса IDBWrapper на практике. Здесь показан процесс добавления, получения, обновления, вывода списка и удаления данных Todo.

  • Эта обёртка упрощает выполнение основных операций CRUD. В реальных условиях также необходимо реализовать обработку ошибок и управление схемой (индексы).

Миграция схемы (обновление версии)

Чтобы изменить схему базы данных, увеличьте второй аргумент (версию) в indexedDB.open и обновите его в onupgradeneeded. Необходимо продумать это таким образом, чтобы существующие транзакции были завершены и деструктивные изменения были предотвращены.

 1// Example of handling upgrade to version 2: add an index and perhaps migrate data.
 2// onupgradeneeded receives an event where oldVersion and newVersion are accessible.
 3
 4function upgradeToV2(dbName: string): Promise<IDBDatabase> {
 5  return new Promise((resolve, reject) => {
 6    const req = indexedDB.open(dbName, 2);
 7    req.onupgradeneeded = (ev) => {
 8      const db = req.result;
 9      const oldVersion = (ev as IDBVersionChangeEvent).oldVersion;
10      console.log('Upgrading DB from', oldVersion, 'to', db.version);
11      let store: IDBObjectStore;
12      if (!db.objectStoreNames.contains('todos')) {
13        store = db.createObjectStore('todos', { keyPath: 'id' });
14      } else {
15        store = req.transaction!.objectStore('todos');
16      }
17      // Add index if not present
18      if (!store.indexNames.contains('by-completed')) {
19        store.createIndex('by-completed', 'completed', { unique: false });
20      }
21
22      // Optional: data migration logic if necessary can go here,
23      // but heavy migrations often should be done lazily on read.
24    };
25    req.onsuccess = () => resolve(req.result);
26    req.onerror = () => reject(req.error);
27  });
28}
  • Тяжёлая обработка внутри onupgradeneeded может заблокировать UI, поэтому сводите её к минимуму и по возможности выделяйте миграции на отдельную стадию при запуске приложения.

Важные замечания по транзакциям (жизненный цикл и ошибки)

Транзакции автоматически подтверждаются до завершения выполнения скрипта, который их создал. При использовании await внутри транзакции она может быть неожиданно завершена; будьте осторожны при выполнении нескольких асинхронных операций в одной транзакции.

 1// Bad pattern: awaiting outside transaction callbacks can cause tx to auto-commit.
 2// Good pattern is to chain requests and resolve on tx.oncomplete as shown earlier.
 3
 4// Example: Do multiple operations inside single tx, avoid awaiting inside.
 5function multiOperation(db: IDBDatabase, items: Todo[]): Promise<void> {
 6  return new Promise((resolve, reject) => {
 7    const tx = db.transaction('todos', 'readwrite');
 8    const store = tx.objectStore('todos');
 9
10    for (const item of items) {
11      const req = store.put(item);
12      req.onerror = () => console.error('put error', req.error);
13      // Do NOT await here; just schedule requests synchronously.
14    }
15
16    tx.oncomplete = () => {
17      console.log('All operations in transaction completed');
18      resolve();
19    };
20    tx.onerror = () => reject(tx.error);
21  });
22}
  • Учитывайте время жизни транзакций; при необходимости используйте отдельные транзакции или выполняйте операции синхронно внутри одной транзакции.

Курсоры и пагинация

Используя курсор, вы можете последовательно обрабатывать большие объёмы данных или реализовать простую пагинацию без использования смещений.

 1// Example: fetch first N items using a cursor (ascending by key).
 2function fetchFirstN(db: IDBDatabase, n: number): Promise<Todo[]> {
 3  return new Promise((resolve, reject) => {
 4    const tx = db.transaction('todos', 'readonly');
 5    const store = tx.objectStore('todos');
 6    const req = store.openCursor();
 7    const out: Todo[] = [];
 8    req.onsuccess = (ev) => {
 9      const cursor = (ev.target as IDBRequest).result as IDBCursorWithValue | null;
10      if (cursor && out.length < n) {
11        out.push(cursor.value as Todo);
12        cursor.continue();
13      } else {
14        resolve(out);
15      }
16    };
17    req.onerror = () => reject(req.error);
18  });
19}
  • Получая элементы по одному с помощью курсора, можно снизить использование памяти. При реализации пагинации обычно запоминается последний считанный ключ.

Обработка ошибок и резервные решения

1// Feature detection
2if (!('indexedDB' in window)) {
3  console.warn('IndexedDB is not supported. Falling back to localStorage.');
4  // implement fallback logic...
5}
  • Доступ к IndexedDB может быть недоступен из-за различий в реализации между браузерами или настроек конфиденциальности пользователя, таких как приватный режим. Поэтому проверьте, существует ли indexedDB, и если нет, предоставьте запасной вариант, такой как localStorage.

Производительность и лучшие практики

IndexedDB быстро и мощно, но производительность может значительно различаться в зависимости от ее структуры и способов работы с данными. В зависимости от сценария использования, оптимизацию можно провести следующими способами:.

  • Разрабатывайте хранилище объектов согласно реальному использованию. Например, если много операций чтения, используйте индексы; если много записей — делайте ключи максимально простыми.
  • Крупные двоичные данные, такие как изображения и аудио, следует хранить как Blob, либо управлять ими с помощью File API или service workers при необходимости. При необходимости можно рассмотреть вариант сжатия данных.
  • Делайте транзакции как можно короче и выполняйте тяжелую обработку вне транзакции, чтобы минимизировать время блокировки.
  • Индексы могут ускорять поиск, но замедлять вставку и обновление, поэтому создавайте только действительно необходимые индексы.
  • При работе с большим количеством мелких данных получение их всех сразу с помощью getAll() может привести к исчерпанию памяти. Можно снизить использование памяти, обрабатывая данные частями с помощью курсоров.

Безопасность и конфиденциальность

Данные IndexedDB изолированы по домену и протоколу согласно политике единого происхождения (same-origin policy). Проектируйте систему с учетом того, что данные могут быть потеряны при удалении данных браузера или использовании приватного режима.

Резюме и рекомендуемые шаблоны проектирования

Для эффективного использования IndexedDB с TypeScript важно подготовить типы и асинхронные процессы, учитывать управление версиями и разработку транзакций, а также оборачивать общие процедуры ради повышения сопровождаемости.

  • Определение типов в TypeScript и оборачивание операций IndexedDB с помощью Promise/async/await повышает безопасность и читаемость кода.
  • Изменения схемы должны использовать управление версиями с помощью onupgradeneeded; тяжёлую обработку по возможности стоит выполнять отдельно.
  • Делайте транзакции короткими и избегайте тяжёлой асинхронной обработки в одной транзакции.
  • Создавая классы-обертки, можно уменьшить дублирование типовых операций — обработку ошибок, ведение логов и определение типов.

Вы можете следовать этой статье, используя Visual Studio Code на нашем YouTube-канале. Пожалуйста, также посмотрите наш YouTube-канал.

YouTube Video