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.
Begreppen
Section titled “Begreppen”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.
Ett API från före promises
Section titled “Ett API från före promises”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.
Öppna databasen
Section titled “Öppna databasen”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:
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.
Spara och läsa
Section titled “Spara och läsa”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.
Fråga: vad är playedAt?
Section titled “Fråga: vad är playedAt?”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.
Transaktioner väntar inte
Section titled “Transaktioner väntar inte”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.
Fråga: vad händer?
Section titled “Fråga: vad händer?”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))Att ta med sig
Section titled “Att ta med sig”- 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.
Vidare läsning
Section titled “Vidare läsning”- Kapitel 15.12.3 i kursboken, som finns med i läsanvisningarna för den som vill fördjupa sig.
- Using IndexedDB och Basic terminology på MDN.
- idb, ett litet bibliotek som ger IndexedDB ett API med promises.