Hoppa till innehåll

IndexedDB

IndexedDB är en databas i webbläsaren. Den rymmer mycket mer än Web Storage, kan lagra objekt utan att du gör om dem till JSON, låter dig söka i datan och blockerar inte huvudtråden. Priset är ett API som är betydligt krångligare. Den här artikeln visar begreppen och hur API:et görs om till promises, så att du kan använda det med async och await som du lärde dig i Promises och async/await.

Exemplet sparar omgångarna i memoryspelet från föregående artikel i IndexedDB i stället.

Tidsåtgång: Ungefär 15 minuter.

En origin kan ha flera databaser, var och en med ett namn och ett versionsnummer. En databas innehåller ett eller flera object stores, ungefär som tabeller i en relationsdatabas, men utan fasta kolumner. Varje object store innehåller poster, där varje post är ett värde med en nyckel.

Begrepp I memoryspelet
Databas memory, version 1
Object store games, en post per spelad omgång
Nyckel id, ett löpnummer som databasen själv delar ut
Index size, för att hitta alla omgångar med en viss spelplan
Transaktion en eller flera läsningar och skrivningar som lyckas eller inte

Ett index gör det möjligt att söka på något annat än nyckeln, till exempel alla omgångar på en spelplan med storleken 4x4, utan att läsa igenom alla poster.

All läsning och skrivning sker i en transaktion. Antingen lyckas allt i transaktionen, eller så görs ingenting. Sparar du två poster som hör ihop och den andra misslyckas, blir inte heller den första sparad.

Värdena sparas med samma teknik som structuredClone(). Objekt, arrayer, Date, Map, Set och filer sparas som de är, utan JSON.stringify(). Det enda som inte går är funktioner och DOM-noder.

IndexedDB är äldre än promises i JavaScript. I stället för att returnera ett promise returnerar varje anrop ett request-objekt, som senare utlöser händelsen success eller error:

const request = store.get(42)
request.addEventListener('success', () => {
console.log(request.result)
})
request.addEventListener('error', () => {
console.error(request.error)
})

Det blir snabbt rörigt när flera anrop ska göras efter varandra. Det går däremot att göra om varje request till ett promise, med samma teknik som när du gör om vilket händelsebaserat API som helst:

/**
* Wraps an IndexedDB request in a promise.
*
* @param {IDBRequest} request - The request to wait for.
* @returns {Promise<any>} Fulfilled with the request's result, rejected with its error.
*/
function promisify(request) {
return new Promise((resolve, reject) => {
request.addEventListener('success', () => resolve(request.result))
request.addEventListener('error', () => reject(request.error))
})
}

Nu kan resten av koden använda await:

const game = await promisify(store.get(42))

Det här är vad biblioteket idb gör, mer genomarbetat. I ett riktigt projekt är det ofta värt att använda det. Här skriver du det själv, för att se vad som händer.

När databasen öppnas med indexedDB.open() anger du namn och version. Är databasen ny, eller har den ett lägre versionsnummer än det du anger, utlöses först upgradeneeded. Det är det enda stället där du får skapa object stores och index:

js/game-history.js
const DATABASE_NAME = 'memory'
const DATABASE_VERSION = 1
let databasePromise
/**
* Opens the database once and reuses the connection.
*
* @returns {Promise<IDBDatabase>} The open database.
*/
function getDatabase() {
databasePromise ??= new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION)
request.addEventListener('upgradeneeded', () => {
const games = request.result.createObjectStore('games', { keyPath: 'id', autoIncrement: true })
games.createIndex('size', 'size')
})
request.addEventListener('success', () => resolve(request.result))
request.addEventListener('error', () => reject(request.error))
})
return databasePromise
}

keyPath: 'id' säger att nyckeln finns i postens egenskap id, och autoIncrement: true att databasen själv delar ut den. createIndex('size', 'size') skapar ett index med namnet size på egenskapen size.

Vill du senare lägga till ett index eller ett nytt object store ökar du versionsnumret till 2, och tar hand om skillnaden i upgradeneeded. Användarnas befintliga data ligger kvar.

??= gör att databasen bara öppnas första gången. Alla som anropar getDatabase() efter det får samma promise, och samma anslutning.

En transaktion skapas med transaction(), med namnet på de object stores den ska använda och om den bara ska läsa ('readonly', standardvärdet) eller också skriva ('readwrite'):

/**
* Saves a finished game.
*
* @param {{ attempts: number, size: string, playedAt: Date }} game - The finished game.
* @returns {Promise<number>} The key the database gave the game.
*/
export async function saveGame(game) {
const database = await getDatabase()
const store = database.transaction('games', 'readwrite').objectStore('games')
return promisify(store.add(game))
}
/**
* Reads all saved games played on a board of the given size.
*
* @param {string} size - The board size, e.g. "4x4".
* @returns {Promise<Array<{ id: number, attempts: number, size: string, playedAt: Date }>>} The games.
*/
export async function loadGamesBySize(size) {
const database = await getDatabase()
const index = database.transaction('games').objectStore('games').index('size')
return promisify(index.getAll(size))
}

Funktionerna har samma form som i Web Storage, men är async. Den som anropar dem måste därför använda await, och ta hand om fel med try och catch, som vid nätverksanrop:

game.addEventListener('game-over', async (event) => {
try {
await saveGame({ attempts: event.detail.attempts, size: sizeSelect.value, playedAt: new Date() })
showHistory(await loadGamesBySize(sizeSelect.value))
} catch {
status.textContent = 'Resultatet kunde inte sparas.'
}
})

Lyssnaren tar hand om sina egna fel, eftersom ingen annan kan fånga ett fel från en async-lyssnare, som i Nätverksanrop med fetch.

Omgången sparas med playedAt: new Date(). När den läses tillbaka med loadGamesBySize(), vad är games[0].playedAt för något? Och vad hade svaret varit med Web Storage?

Ett Date-objekt. IndexedDB sparar värdet med samma teknik som structuredClone(), och den klarar datum. Du kan direkt anropa till exempel games[0].playedAt.toLocaleDateString('sv-SE').

Med Web Storage hade du behövt göra om omgången till JSON, och då blir datumet en sträng. Det var därför exemplet i föregående artikel sparade playedAt som en sträng från toISOString() redan från början.

En transaktion lever bara så länge den har något att göra. När den inte har några anrop som pågår och din kod lämnar tillbaka kontrollen till händelseloopen, avslutas den av sig själv. Det gör att du inte behöver avsluta den, men också att den inte väntar på något annat.

Omgången ska sparas tillsammans med en bild på spelplanen, som hämtas från en server:

const store = database.transaction('games', 'readwrite').objectStore('games')
const response = await fetch(`/images/${game.size}.png`)
game.board = await response.blob()
await promisify(store.add(game))

Vad händer när store.add() anropas?

Det kastas ett TransactionInactiveError, och inget sparas.

Transaktionen skapades, men inget anrop gjordes i den innan await fetch() lämnade tillbaka kontrollen till händelseloopen. Svaret från servern kommer tidigast i en senare task, och under tiden har transaktionen inget att göra och avslutas. När store.add() till sist anropas finns det ingen aktiv transaktion att göra det i.

Lösningen är att hämta allt du behöver först, och skapa transaktionen först när du kan använda den direkt:

const response = await fetch(`/images/${game.size}.png`)
game.board = await response.blob()
const store = database.transaction('games', 'readwrite').objectStore('games')
await promisify(store.add(game))
  • IndexedDB är en databas i webbläsaren för stora mängder data. Den är asynkron och finns också i workers.
  • En databas har ett versionsnummer och object stores med poster och nycklar. Object stores och index skapas i upgradeneeded.
  • All läsning och skrivning sker i en transaktion, som lyckas helt eller inte alls.
  • Objekt, datum och filer sparas som de är, utan JSON.
  • API:et bygger på request-objekt och händelser. Gör om dem till promises, själv eller med biblioteket idb.
  • En transaktion avslutas när den inte har något att göra. Vänta inte på något annat mitt i den.
CCBY