← JavaScript/IndexedDB#132 из 383← ПредыдущийСледующий →+30 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

IndexedDB

Приложение для заметок должно работать офлайн: пользователь в самолёте редактирует заметки, а при восстановлении интернета — синхронизирует с сервером. localStorage не подходит — только строки и 5 MB. Нужна IndexedDB: браузерная база данных с транзакциями, индексами и поддержкой любых объектов.

Что решает IndexedDB

| | localStorage | IndexedDB |

|---|---|---|

| Объём | ~5 MB | Сотни MB и более |

| Тип данных | Только строки | Любые объекты, Blob, File |

| Запросы | Только по ключу | По ключу и по индексам |

| Транзакции | Нет | Есть (атомарность) |

| Асинхронность | Синхронный (блокирует UI) | Асинхронный |

На основе предыдущих уроков

  • Promise: все операции IndexedDB оборачивают в промисы
  • async/await: удобная работа с промисифицированным API
  • промисификация: превращение callback-API IndexedDB в промисы
  • объекты: хранимые записи — обычные JS-объекты
  • Основные понятия

  • Database — база данных с именем и версией
  • Object Store — аналог таблицы, хранит объекты
  • Transaction — группа операций (все выполнятся или ни одна)
  • Index — дополнительный индекс для поиска по полю (не только по ключу)
  • Открытие базы данных

    const request = indexedDB.open('NotesDB', 1)
    
    // Вызывается при первом открытии или увеличении версии
    request.onupgradeneeded = (event) => {
      const db = event.target.result
    
      const notesStore = db.createObjectStore('notes', {
        keyPath: 'id',
        autoIncrement: true,
      })
    
      notesStore.createIndex('by-folder', 'folderId', { unique: false })
      notesStore.createIndex('by-tag', 'tags', { unique: false, multiEntry: true })
    }
    
    request.onsuccess = (event) => {
      const db = event.target.result
      console.log('БД открыта:', db.name, 'v' + db.version)
    }

    CRUD операции

    function addNote(db, note) {
      return new Promise((resolve, reject) => {
        const tx = db.transaction('notes', 'readwrite')
        const req = tx.objectStore('notes').add(note)
        req.onsuccess = () => resolve(req.result)  // вернёт новый id
        req.onerror  = () => reject(req.error)
      })
    }
    
    function getNote(db, id) {
      return new Promise((resolve, reject) => {
        const tx = db.transaction('notes', 'readonly')
        const req = tx.objectStore('notes').get(id)
        req.onsuccess = () => resolve(req.result)
        req.onerror  = () => reject(req.error)
      })
    }
    
    function updateNote(db, note) {
      return new Promise((resolve, reject) => {
        const tx = db.transaction('notes', 'readwrite')
        const req = tx.objectStore('notes').put(note)  // put — добавит или обновит
        req.onsuccess = () => resolve(req.result)
        req.onerror  = () => reject(req.error)
      })
    }
    
    function deleteNote(db, id) {
      return new Promise((resolve, reject) => {
        const tx = db.transaction('notes', 'readwrite')
        const req = tx.objectStore('notes').delete(id)
        req.onsuccess = () => resolve()
        req.onerror  = () => reject(req.error)
      })
    }

    Поиск по индексу

    function getNotesByFolder(db, folderId) {
      return new Promise((resolve, reject) => {
        const tx  = db.transaction('notes', 'readonly')
        const idx = tx.objectStore('notes').index('by-folder')
        const req = idx.getAll(folderId)
        req.onsuccess = () => resolve(req.result)
        req.onerror  = () => reject(req.error)
      })
    }

    Библиотека idb — промисы вместо колбэков

    import { openDB } from 'idb'
    
    const db = await openDB('NotesDB', 1, {
      upgrade(db) {
        const store = db.createObjectStore('notes', { keyPath: 'id', autoIncrement: true })
        store.createIndex('by-folder', 'folderId')
      }
    })
    
    await db.add('notes', { title: 'Встреча', folderId: 1, tags: ['работа'] })
    const note = await db.get('notes', 1)
    const all  = await db.getAll('notes')

    Типичные ошибки

    Ошибка 1: readwrite-транзакция для чтения

    // Сломано: readwrite блокирует другие транзакции без необходимости
    const tx = db.transaction('notes', 'readwrite')  // избыточно
    
    // Исправлено:
    const tx = db.transaction('notes', 'readonly')   // для чтения достаточно

    Ошибка 2: изменение схемы без увеличения версии

    // onupgradeneeded не вызовется — версия та же!
    const request = indexedDB.open('NotesDB', 1)  // была 1, стала 1 — не обновляется
    
    // Исправлено:
    const request = indexedDB.open('NotesDB', 2)  // увеличить версию

    Ошибка 3: await между операциями одной транзакции

    // Сломано: await завершает транзакцию раньше времени
    const tx = db.transaction('notes', 'readwrite')
    await someAsyncOperation()              // транзакция уже закрыта!
    tx.objectStore('notes').add(note)      // ошибка
    
    // Исправлено: все операции транзакции — без await между ними
    const tx = db.transaction('notes', 'readwrite')
    const store = tx.objectStore('notes')
    store.add(note1)
    store.add(note2)

    В реальных проектах

  • Офлайн PWA: заметки, задачи, документы хранятся локально и синхронизируются при появлении сети
  • Кэш каталога: товары кэшируются в IndexedDB — быстрый старт без ожидания API
  • Черновики: автосохранение форм и редакторов текста каждые несколько секунд
  • Библиотека idb: на практике почти всегда используют idb или Dexie.js вместо сырого API
  • Примеры

    Симуляция IndexedDB через Map: открытие, CRUD и поиск по индексу

    // IndexedDB недоступен в sandbox — показываем тот же паттерн через Map
    // В браузере замените MemoryDB на реальный indexedDB.open(...)
    
    class MemoryDB {
      constructor() {
        this._stores = new Map()
      }
    
      createStore(name) {
        this._stores.set(name, { data: new Map(), nextId: 1, indexes: {} })
        return this
      }
    
      createIndex(storeName, indexName, field) {
        this._stores.get(storeName).indexes[indexName] = field
        return this
      }
    
      _store(name) {
        if (!this._stores.has(name)) throw new Error(`Store "${name}" не существует`)
        return this._stores.get(name)
      }
    
      add(storeName, item) {
        const store = this._store(storeName)
        const id = store.nextId++
        store.data.set(id, { ...item, id })
        return Promise.resolve(id)
      }
    
      get(storeName, id) {
        return Promise.resolve(this._store(storeName).data.get(id))
      }
    
      getAll(storeName) {
        return Promise.resolve([...this._store(storeName).data.values()])
      }
    
      getByIndex(storeName, indexName, value) {
        const store = this._store(storeName)
        const field = store.indexes[indexName]
        const results = [...store.data.values()].filter(r =>
          Array.isArray(r[field]) ? r[field].includes(value) : r[field] === value
        )
        return Promise.resolve(results)
      }
    
      put(storeName, item) {
        this._store(storeName).data.set(item.id, item)
        return Promise.resolve(item.id)
      }
    
      delete(storeName, id) {
        this._store(storeName).data.delete(id)
        return Promise.resolve()
      }
    }
    
    // Инициализация (аналог onupgradeneeded)
    const db = new MemoryDB()
      .createStore('notes')
      .createIndex('notes', 'by-folder', 'folderId')
      .createIndex('notes', 'by-tag', 'tags')
    
    async function main() {
      // add — вставить записи
      await db.add('notes', { title: 'Q1 план',    folderId: 1, tags: ['работа', 'планирование'] })
      await db.add('notes', { title: 'Ретроспектива', folderId: 1, tags: ['работа', 'встреча'] })
      await db.add('notes', { title: 'Рецепт пасты', folderId: 2, tags: ['еда'] })
      await db.add('notes', { title: 'Список покупок', folderId: 2, tags: ['еда', 'планирование'] })
      await db.add('notes', { title: 'Идеи для проекта', folderId: 1, tags: ['работа', 'идеи'] })
    
      // getAll
      const all = await db.getAll('notes')
      console.log(`Всего заметок: ${all.length}`)  // 5
    
      // get по id
      const note1 = await db.get('notes', 1)
      console.log('Заметка #1:', note1.title)  // 'Q1 план'
    
      // Поиск по индексу by-folder (аналог index.getAll в IndexedDB)
      const workNotes = await db.getByIndex('notes', 'by-folder', 1)
      console.log(`\nРабочие заметки (${workNotes.length}):`)
      workNotes.forEach(n => console.log(`  - ${n.title}`))
      // - Q1 план
      // - Ретроспектива
      // - Идеи для проекта
    
      // Поиск по тегу (multiEntry в IndexedDB)
      const planningNotes = await db.getByIndex('notes', 'by-tag', 'планирование')
      console.log(`\nЗаметки с тегом "планирование" (${planningNotes.length}):`)
      planningNotes.forEach(n => console.log(`  - ${n.title}`))
      // - Q1 план
      // - Список покупок
    
      // put — обновить
      const note = await db.get('notes', 1)
      await db.put('notes', { ...note, title: 'Q1 план (обновлён)' })
      const updated = await db.get('notes', 1)
      console.log('\nПосле обновления:', updated.title)  // 'Q1 план (обновлён)'
    
      // delete
      await db.delete('notes', 3)
      const remaining = await db.getAll('notes')
      console.log(`После удаления: ${remaining.length} заметок`)  // 4
    }
    
    main()

    IndexedDB

    Приложение для заметок должно работать офлайн: пользователь в самолёте редактирует заметки, а при восстановлении интернета — синхронизирует с сервером. localStorage не подходит — только строки и 5 MB. Нужна IndexedDB: браузерная база данных с транзакциями, индексами и поддержкой любых объектов.

    Что решает IndexedDB

    | | localStorage | IndexedDB |

    |---|---|---|

    | Объём | ~5 MB | Сотни MB и более |

    | Тип данных | Только строки | Любые объекты, Blob, File |

    | Запросы | Только по ключу | По ключу и по индексам |

    | Транзакции | Нет | Есть (атомарность) |

    | Асинхронность | Синхронный (блокирует UI) | Асинхронный |

    На основе предыдущих уроков

  • Promise: все операции IndexedDB оборачивают в промисы
  • async/await: удобная работа с промисифицированным API
  • промисификация: превращение callback-API IndexedDB в промисы
  • объекты: хранимые записи — обычные JS-объекты
  • Основные понятия

  • Database — база данных с именем и версией
  • Object Store — аналог таблицы, хранит объекты
  • Transaction — группа операций (все выполнятся или ни одна)
  • Index — дополнительный индекс для поиска по полю (не только по ключу)
  • Открытие базы данных

    const request = indexedDB.open('NotesDB', 1)
    
    // Вызывается при первом открытии или увеличении версии
    request.onupgradeneeded = (event) => {
      const db = event.target.result
    
      const notesStore = db.createObjectStore('notes', {
        keyPath: 'id',
        autoIncrement: true,
      })
    
      notesStore.createIndex('by-folder', 'folderId', { unique: false })
      notesStore.createIndex('by-tag', 'tags', { unique: false, multiEntry: true })
    }
    
    request.onsuccess = (event) => {
      const db = event.target.result
      console.log('БД открыта:', db.name, 'v' + db.version)
    }

    CRUD операции

    function addNote(db, note) {
      return new Promise((resolve, reject) => {
        const tx = db.transaction('notes', 'readwrite')
        const req = tx.objectStore('notes').add(note)
        req.onsuccess = () => resolve(req.result)  // вернёт новый id
        req.onerror  = () => reject(req.error)
      })
    }
    
    function getNote(db, id) {
      return new Promise((resolve, reject) => {
        const tx = db.transaction('notes', 'readonly')
        const req = tx.objectStore('notes').get(id)
        req.onsuccess = () => resolve(req.result)
        req.onerror  = () => reject(req.error)
      })
    }
    
    function updateNote(db, note) {
      return new Promise((resolve, reject) => {
        const tx = db.transaction('notes', 'readwrite')
        const req = tx.objectStore('notes').put(note)  // put — добавит или обновит
        req.onsuccess = () => resolve(req.result)
        req.onerror  = () => reject(req.error)
      })
    }
    
    function deleteNote(db, id) {
      return new Promise((resolve, reject) => {
        const tx = db.transaction('notes', 'readwrite')
        const req = tx.objectStore('notes').delete(id)
        req.onsuccess = () => resolve()
        req.onerror  = () => reject(req.error)
      })
    }

    Поиск по индексу

    function getNotesByFolder(db, folderId) {
      return new Promise((resolve, reject) => {
        const tx  = db.transaction('notes', 'readonly')
        const idx = tx.objectStore('notes').index('by-folder')
        const req = idx.getAll(folderId)
        req.onsuccess = () => resolve(req.result)
        req.onerror  = () => reject(req.error)
      })
    }

    Библиотека idb — промисы вместо колбэков

    import { openDB } from 'idb'
    
    const db = await openDB('NotesDB', 1, {
      upgrade(db) {
        const store = db.createObjectStore('notes', { keyPath: 'id', autoIncrement: true })
        store.createIndex('by-folder', 'folderId')
      }
    })
    
    await db.add('notes', { title: 'Встреча', folderId: 1, tags: ['работа'] })
    const note = await db.get('notes', 1)
    const all  = await db.getAll('notes')

    Типичные ошибки

    Ошибка 1: readwrite-транзакция для чтения

    // Сломано: readwrite блокирует другие транзакции без необходимости
    const tx = db.transaction('notes', 'readwrite')  // избыточно
    
    // Исправлено:
    const tx = db.transaction('notes', 'readonly')   // для чтения достаточно

    Ошибка 2: изменение схемы без увеличения версии

    // onupgradeneeded не вызовется — версия та же!
    const request = indexedDB.open('NotesDB', 1)  // была 1, стала 1 — не обновляется
    
    // Исправлено:
    const request = indexedDB.open('NotesDB', 2)  // увеличить версию

    Ошибка 3: await между операциями одной транзакции

    // Сломано: await завершает транзакцию раньше времени
    const tx = db.transaction('notes', 'readwrite')
    await someAsyncOperation()              // транзакция уже закрыта!
    tx.objectStore('notes').add(note)      // ошибка
    
    // Исправлено: все операции транзакции — без await между ними
    const tx = db.transaction('notes', 'readwrite')
    const store = tx.objectStore('notes')
    store.add(note1)
    store.add(note2)

    В реальных проектах

  • Офлайн PWA: заметки, задачи, документы хранятся локально и синхронизируются при появлении сети
  • Кэш каталога: товары кэшируются в IndexedDB — быстрый старт без ожидания API
  • Черновики: автосохранение форм и редакторов текста каждые несколько секунд
  • Библиотека idb: на практике почти всегда используют idb или Dexie.js вместо сырого API
  • Примеры

    Симуляция IndexedDB через Map: открытие, CRUD и поиск по индексу

    // IndexedDB недоступен в sandbox — показываем тот же паттерн через Map
    // В браузере замените MemoryDB на реальный indexedDB.open(...)
    
    class MemoryDB {
      constructor() {
        this._stores = new Map()
      }
    
      createStore(name) {
        this._stores.set(name, { data: new Map(), nextId: 1, indexes: {} })
        return this
      }
    
      createIndex(storeName, indexName, field) {
        this._stores.get(storeName).indexes[indexName] = field
        return this
      }
    
      _store(name) {
        if (!this._stores.has(name)) throw new Error(`Store "${name}" не существует`)
        return this._stores.get(name)
      }
    
      add(storeName, item) {
        const store = this._store(storeName)
        const id = store.nextId++
        store.data.set(id, { ...item, id })
        return Promise.resolve(id)
      }
    
      get(storeName, id) {
        return Promise.resolve(this._store(storeName).data.get(id))
      }
    
      getAll(storeName) {
        return Promise.resolve([...this._store(storeName).data.values()])
      }
    
      getByIndex(storeName, indexName, value) {
        const store = this._store(storeName)
        const field = store.indexes[indexName]
        const results = [...store.data.values()].filter(r =>
          Array.isArray(r[field]) ? r[field].includes(value) : r[field] === value
        )
        return Promise.resolve(results)
      }
    
      put(storeName, item) {
        this._store(storeName).data.set(item.id, item)
        return Promise.resolve(item.id)
      }
    
      delete(storeName, id) {
        this._store(storeName).data.delete(id)
        return Promise.resolve()
      }
    }
    
    // Инициализация (аналог onupgradeneeded)
    const db = new MemoryDB()
      .createStore('notes')
      .createIndex('notes', 'by-folder', 'folderId')
      .createIndex('notes', 'by-tag', 'tags')
    
    async function main() {
      // add — вставить записи
      await db.add('notes', { title: 'Q1 план',    folderId: 1, tags: ['работа', 'планирование'] })
      await db.add('notes', { title: 'Ретроспектива', folderId: 1, tags: ['работа', 'встреча'] })
      await db.add('notes', { title: 'Рецепт пасты', folderId: 2, tags: ['еда'] })
      await db.add('notes', { title: 'Список покупок', folderId: 2, tags: ['еда', 'планирование'] })
      await db.add('notes', { title: 'Идеи для проекта', folderId: 1, tags: ['работа', 'идеи'] })
    
      // getAll
      const all = await db.getAll('notes')
      console.log(`Всего заметок: ${all.length}`)  // 5
    
      // get по id
      const note1 = await db.get('notes', 1)
      console.log('Заметка #1:', note1.title)  // 'Q1 план'
    
      // Поиск по индексу by-folder (аналог index.getAll в IndexedDB)
      const workNotes = await db.getByIndex('notes', 'by-folder', 1)
      console.log(`\nРабочие заметки (${workNotes.length}):`)
      workNotes.forEach(n => console.log(`  - ${n.title}`))
      // - Q1 план
      // - Ретроспектива
      // - Идеи для проекта
    
      // Поиск по тегу (multiEntry в IndexedDB)
      const planningNotes = await db.getByIndex('notes', 'by-tag', 'планирование')
      console.log(`\nЗаметки с тегом "планирование" (${planningNotes.length}):`)
      planningNotes.forEach(n => console.log(`  - ${n.title}`))
      // - Q1 план
      // - Список покупок
    
      // put — обновить
      const note = await db.get('notes', 1)
      await db.put('notes', { ...note, title: 'Q1 план (обновлён)' })
      const updated = await db.get('notes', 1)
      console.log('\nПосле обновления:', updated.title)  // 'Q1 план (обновлён)'
    
      // delete
      await db.delete('notes', 3)
      const remaining = await db.getAll('notes')
      console.log(`После удаления: ${remaining.length} заметок`)  // 4
    }
    
    main()

    Задание

    Реализуй класс MemoryStore — аналог IndexedDB objectStore на основе Map. Методы: add(item) возвращает id; get(id) — запись или undefined; getAll() — массив всех записей; put(item) — обновляет по item.id; delete(id) — возвращает true если удалена.

    Подсказка

    get: return this._data.get(id); getAll: return [...this._data.values()]; put: this._data.set(item.id, item); delete: return this._data.delete(id)

    Загружаем среду выполнения...
    Загружаем AI-помощника...