LocalStorage
Selain osaa säilöä tietoa käyttäjän koneelle niin, että se on käytettävissä vielä silloinkin kun sivu ladataan uudelleen tai selain suljetaan välillä kokonaan. localStorage on näistä tallennustavoista yksinkertaisin: se on avain–arvo-pareja säilövä varasto, jonka koko rajapinta on viisi metodia ja yksi ominaisuus.
Tällä sivulla käsitellään myös kaksi muuta tallennustapaa: sessionStorage, joka unohtaa tiedot välilehden sulkeutuessa, ja IndexedDB, joka on tarkoitettu isommille ja monimutkaisemmille aineistoille. Lopussa on vertailutaulukko.
Varasto on lähdekohtainen
localStorage ei ole sivukohtainen vaan lähdekohtainen. Lähde (englanniksi origin) muodostuu kolmesta osasta: protokolla, isäntänimi ja portti. Kaikkien kolmen on täsmättävä, jotta kyse on samasta varastosta. Polulla ei ole mitään merkitystä.
| Sivu | Sama varasto kuin ylimmällä? |
|---|---|
| https://esimerkki.fi/sovellus/ | — |
| https://esimerkki.fi/toinen/sivu.html | kyllä — polku ei vaikuta |
| http://esimerkki.fi/sovellus/ | ei — eri protokolla |
| https://www.esimerkki.fi/sovellus/ | ei — eri isäntänimi |
| https://esimerkki.fi:8080/sovellus/ | ei — eri portti |
Tästä seuraa kaksi asiaa, jotka kannattaa pitää mielessä.
Saman palvelimen kaikki sovellukset jakavat varaston. Jos samaan lähteeseen on julkaistu useita localStoragea käyttäviä sovelluksia, ne kirjoittavat samaan säiliöön. Nimeä oman sovelluksesi avaimet niin, etteivät ne voi sekoittua muiden avaimiin — esimerkiksi etuliitteellä:
const ETULIITE = "muistilista:";
localStorage.setItem(ETULIITE + "asetukset", JSON.stringify(asetukset));
Myös kiintiö on yhteinen. Selain varaa tilaa lähdettä kohti, ei sovellusta kohti, joten toisen sovelluksen tallentama data syö samaa kiintiötä.
Rajapinta
| Kutsu | Merkitys |
|---|---|
| setItem(avain, arvo) | tallentaa arvon |
| getItem(avain) | lukee arvon, tai null jos avainta ei ole |
| removeItem(avain) | poistaa yhden avaimen |
| clear() | tyhjentää koko lähteen varaston |
| key(n) | palauttaa n:nnen avaimen nimen |
| length | avainten lukumäärä |
Huomaa, että clear() tyhjentää kaiken, myös muiden saman lähteen sovellusten tiedot. Jos haluat siivota vain omat avaimesi, käy varasto läpi ja suodata etuliitteen perusteella:
const omatAvaimet = function (etuliite) {
const avaimet = [];
for (let i = 0; i < localStorage.length; i++) {
const avain = localStorage.key(i);
if (avain.startsWith(etuliite)) {
avaimet.push(avain);
}
}
return avaimet;
};
// poistetaan omat avaimet vasta kun ne on kaikki kerätty: varaston
// muuttaminen kesken silmukan sotkisi indeksoinnin
for (const avain of omatAvaimet("muistilista:")) {
localStorage.removeItem(avain);
}
Varastoa voi käyttää myös ominaisuuksien tapaan
(localStorage.asetukset = "x"), mutta tätä kannattaa välttää: avain
length tai clear menisi päällekkäin rajapinnan omien
nimien kanssa. Käytä metodeja.
Varastoon mahtuu vain merkkijonoja
Tämä on localStoragen tärkein yksittäinen asia ja tavallisin virheen lähde. Sekä avain että arvo muunnetaan aina merkkijonoksi. Muunnos tehdään hiljaisesti: mitään virhettä ei tule, ja tieto voi kadota.
| Tallennettu arvo | Mitä getItem palauttaa |
|---|---|
| 5 | "5" |
| true | "true" |
| [1, 2, 3] | "1,2,3" |
| { nimi: "Matti" } | "[object Object]" |
| null | "null" |
Kaksi riviä ansaitsevat huomion. Objektista jää jäljelle pelkkä
"[object Object]" eli data on menetetty kokonaan. Ja luku 5 palautuu
merkkijonona, jolloin arvo === 5 on epätosi, vaikka silmä sanoo
muuta.
Objektit JSON-muodossa
Oikea tapa on muuntaa arvo itse JSON.stringify-funktiolla tallennettaessa ja JSON.parse-funktiolla luettaessa.
const asetukset = { teema: "tumma", rivejaSivulla: 25 };
localStorage.setItem("muistilista:asetukset", JSON.stringify(asetukset));
const takaisin = JSON.parse(localStorage.getItem("muistilista:asetukset"));
console.log(takaisin.rivejaSivulla + 1); // 26, ei "251"
Lukeminen kannattaa kääriä omaan funktioonsa, koska siinä on kaksi asiaa jotka voivat mennä pieleen:
/**
* Lukee ja jäsentää arvon varastosta.
*
* @param {string} avain Luettava avain
* @param {*} oletus Palautetaan jos arvoa ei ole tai se on rikki
* @returns {*} Jäsennetty arvo tai oletus
*/
const lueVarastosta = function (avain, oletus = null) {
const teksti = localStorage.getItem(avain);
// getItem palauttaa null, jos avainta ei ole. JSON.parse(null) ei heitä
// poikkeusta vaan palauttaa null, joten puuttuva avain on tarkistettava
// erikseen - muuten oletusarvo jäisi käyttämättä.
if (teksti === null) {
return oletus;
}
try {
return JSON.parse(teksti);
}
catch (virhe) {
// Varastossa oleva data voi olla rikki: käyttäjä on voinut muokata sitä
// kehittäjätyökaluilla, tai sinne on jäänyt vanhanmuotoista dataa
// sovelluksen aiemmasta versiosta.
console.error(`Avaimen ${avain} sisältö ei ole kelvollista JSONia`, virhe);
return oletus;
}
};
const asetukset = lueVarastosta("muistilista:asetukset", { teema: "vaalea" });
Muista myös, ettei JSON tunne kaikkia javascriptin tyyppejä. undefined ja funktiot katoavat, Date muuttuu merkkijonoksi, ja Map- sekä Set-oliot tyhjiksi objekteiksi. Jos tallennat päivämääriä, muunna ne itse ja rakenna takaisin luettaessa.
Onko varasto käytettävissä?
localStorage voi puuttua tai olla poissa käytöstä: käyttäjä on voinut estää sen selaimen asetuksista, tai sivu on avattu tilassa jossa sitä ei sallita. Kutsu heittää silloin poikkeuksen.
const varastoKaytettavissa = function (tyyppi) {
let varasto;
try {
varasto = window[tyyppi];
const testi = "__varastotesti__";
varasto.setItem(testi, testi);
varasto.removeItem(testi);
return true;
}
catch (virhe) {
// Varasto voi olla olemassa mutta täynnä. Silloin poikkeus on
// QuotaExceededError ja varastossa on jo dataa - varasto on siis
// käytettävissä, vaikka tämä kirjoitus epäonnistuikin.
return virhe instanceof DOMException
&& virhe.name === "QuotaExceededError"
&& varasto !== undefined
&& varasto.length !== 0;
}
};
if (varastoKaytettavissa("localStorage")) {
// sovellus voi tallentaa tietoja
}
else {
// sovelluksen on toimittava myös ilman varastoa
}
Kiintiö
Selain varaa lähdettä kohti rajallisen määrän tilaa, tyypillisesti noin viisi megatavua. Tarkka raja vaihtelee selaimittain eikä sitä ole standardoitu.
Kun kiintiö täyttyy, setItem heittää QuotaExceededError-poikkeuksen. Tallentaminen kannattaa siksi käärätä try-lohkoon aina kun tallennettavan datan koko voi kasvaa käyttäjän toiminnan myötä:
const tallennaVarastoon = function (avain, arvo) {
try {
localStorage.setItem(avain, JSON.stringify(arvo));
return true;
}
catch (virhe) {
if (virhe.name === "QuotaExceededError") {
console.error("Selaimen tallennustila on täynnä");
}
else {
console.error("Tallentaminen epäonnistui", virhe);
}
return false;
}
};
Muutokset muissa välilehdissä
Sama sovellus voi olla auki useassa välilehdessä yhtä aikaa. Kun yksi niistä muuttaa varastoa, muut saavat tiedon storage-tapahtumasta.
window.addEventListener("storage", function (e) {
console.log("avain :", e.key); // null, jos kutsuttiin clear()
console.log("vanha :", e.oldValue);
console.log("uusi :", e.newValue); // null, jos avain poistettiin
console.log("varasto :", e.storageArea);
console.log("muuttaja :", e.url);
if (e.key === "muistilista:asetukset") {
paivitaKayttoliittyma(JSON.parse(e.newValue));
}
});
Tapahtuma ei laukea siinä välilehdessä, joka muutoksen teki — vain muissa saman lähteen välilehdissä. Tämä hämmentää helposti: omaa koodia testatessa näyttää siltä ettei tapahtumaa tule lainkaan, ennen kuin avaa sivun toiseen välilehteen.
Turvallisuus ja yksityisyys
- Varasto on minkä tahansa saman lähteen javascriptin luettavissa. Jos sivulle saadaan ujutettua vierasta koodia, se pääsee kaikkeen tallennettuun käsiksi. Toisin kuin evästeissä, suojausta HttpOnly-lipun tapaan ei ole.
- Älä tallenna salasanoja, istuntotunnisteita äläkä henkilötietoja. Varasto säilyy koneella määrittämättömän ajan, myös sen jälkeen kun käyttäjä on kirjautunut ulos.
- Data ei katoa itsestään. Evästeille voi asettaa vanhenemisajan, localStoragelle ei. Tarjoa käyttäjälle tapa tyhjentää omat tietonsa.
- Yksityisen selauksen tilassa varasto on tyhjä joka kerta ja katoaa ikkunan sulkeutuessa. Sovelluksen on toimittava myös silloin.
- Kaikki toiminnot ovat synkronisia. Suuren datamäärän kirjoittaminen tai lukeminen jäädyttää sivun siksi aikaa. Jos dataa on paljon, oikea työkalu on IndexedDB.
sessionStorage
sessionStorage on rajapinnaltaan täsmälleen sama kuin localStorage: samat metodit, sama merkkijonorajoitus, sama lähdekohtaisuus. Ero on eliniässä.
sessionStorage on välilehtikohtainen. Jokaisella välilehdellä on oma varastonsa, ja se katoaa kun välilehti suljetaan. Sivun lataaminen uudelleen ei tyhjennä sitä, joten se sopii tiedolle, jonka on säilyttävä käytön ajan mutta jota ei ole syytä jättää koneelle roikkumaan.
Tyypillinen käyttö on monivaiheisen lomakkeen väliaikaistiedot: käyttäjä ei menetä syöttämiään tietoja jos sivu latautuu uudelleen, mutta seuraavalla kerralla aloitetaan puhtaalta pöydältä.
// tallennetaan lomakkeen tila jokaisen muutoksen jälkeen
lomake.addEventListener("input", function () {
const tila = {
nimi: lomake.elements.nimi.value,
sposti: lomake.elements.sposti.value,
vaihe: nykyinenVaihe
};
sessionStorage.setItem("ilmoittautuminen", JSON.stringify(tila));
});
// palautetaan tila sivun latautuessa
const tallennettu = sessionStorage.getItem("ilmoittautuminen");
if (tallennettu !== null) {
const tila = JSON.parse(tallennettu);
lomake.elements.nimi.value = tila.nimi;
lomake.elements.sposti.value = tila.sposti;
siirryVaiheeseen(tila.vaihe);
}
// lomakkeen valmistuttua väliaikaistiedot pois
sessionStorage.removeItem("ilmoittautuminen");
Huomaa, ettei storage-tapahtuma ole sessionStoragen kanssa hyödyllinen samalla tavalla: koska varasto on välilehtikohtainen, muut välilehdet eivät jaa sen sisältöä.
IndexedDB
IndexedDB on selaimen oma tietokanta. Se on paljon monimutkaisempi kuin localStorage, mutta ratkaisee kolme asiaa, joihin localStorage ei taivu:
- Koko. Tilaa on satoja megatavuja tai enemmän, ei viittä.
- Tyypit. Tallennettavaa ei tarvitse muuntaa merkkijonoksi: objektit, taulukot, päivämäärät ja jopa tiedostot menevät sisään sellaisinaan.
- Asynkronisuus. Kaikki toiminnot tapahtuvat taustalla eivätkä jäädytä käyttöliittymää. Tietokannasta voi myös hakea hakemistojen avulla ilman että koko aineistoa luetaan muistiin.
Käytä idb-kirjastoa
IndexedDB:n oma rajapinta on tapahtumapohjainen eikä palauta lupauksia: kutsu palauttaa pyyntöobjektin, jonka success- ja error-tapahtumia kuunnellaan. Se on monisanaista ja virhealtista.
Käytä siis idb-kirjastoa. Se on ohuin mahdollinen kääre: alle kilotavun kokoinen ja rakenteeltaan samanlainen kuin IndexedDB:n oma rajapinta. Se ei lisää omia kyselyitä eikä skeemoja, vaan korvaa tapahtumat lupauksilla — eli juuri sen, mikä natiivista rajapinnasta tekee hankalan. Kaikki tällä sivulla opitut käsitteet pätevät sellaisinaan.
Kirjasto otetaan käyttöön tavallisena skriptinä, jolloin se on saatavilla idb-nimisenä globaalina objektina:
<script src="https://cdn.jsdelivr.net/npm/idb@8/build/umd.js"></script>
Tietokannan avaaminen. upgrade-funktio suoritetaan vain kun tietokantaa ei vielä ole tai kun versionumero on kasvanut, ja vain siellä saa luoda ja poistaa varastoja:
const avaaTietokanta = function () {
// toinen parametri on version numero
return idb.openDB("muistilista", 1, {
upgrade(tietokanta) {
if (!tietokanta.objectStoreNames.contains("merkinnat")) {
const varasto = tietokanta.createObjectStore("merkinnat",
{ keyPath: "id", autoIncrement: true });
// hakemisto, jonka avulla voi hakea päivämäärän perusteella
varasto.createIndex("paivamaara", "paivamaara");
}
}
});
};
Lukeminen ja kirjoittaminen. Kirjasto hoitaa transaktion puolestasi silloin kun kosketaan vain yhteen varastoon:
const lisaaMerkinta = async function (merkinta) {
const tietokanta = await avaaTietokanta();
// objekti menee sisään sellaisenaan, ei merkkijonona
const id = await tietokanta.add("merkinnat", merkinta);
tietokanta.close();
return id;
};
const haeKaikki = async function () {
const tietokanta = await avaaTietokanta();
const merkinnat = await tietokanta.getAll("merkinnat");
tietokanta.close();
return merkinnat;
};
// hakemiston avulla voi hakea muullakin kuin avaimella
const haeEnnen = async function (raja) {
const tietokanta = await avaaTietokanta();
const merkinnat = await tietokanta.getAllFromIndex("merkinnat", "paivamaara",
IDBKeyRange.upperBound(raja));
tietokanta.close();
return merkinnat;
};
// käyttö
await lisaaMerkinta({
otsikko: "Osta maitoa",
paivamaara: new Date(), // Date säilyy Date-oliona
valmis: false
});
console.log(await haeKaikki());
Huomaa paivamaara-kenttä: IndexedDB palauttaa sen Date-oliona. localStoragessa se olisi muuttunut merkkijonoksi ja se pitäisi rakentaa käsin takaisin.
Jos tarvitset varsinaisia kyselyitä — hakuja useammalla ehdolla, lajittelua tai reaktiivisia tuloksia — seuraava askel on Dexie.js. Se on isompi ja tuo mukanaan oman käsitemallinsa, joten sitä kannattaa harkita vasta kun idb alkaa tuntua riittämättömältä.
Miltä sama näyttää ilman kirjastoa
Näin tietokanta avataan ja sitä käytetään pelkällä natiivilla rajapinnalla. Vertailu kannattaa lukea kerran läpi, jotta näkee mitä kirjasto tekee puolestasi — mutta älä kirjoita tätä itse.
/**
* Kääri IndexedDB-pyyntö lupaukseksi.
* Tämän tekee idb-kirjasto puolestasi.
*
* @param {IDBRequest} pyynto Käärittävä pyyntö
* @returns {Promise} Pyynnön tulos
*/
const lupaukseksi = function (pyynto) {
return new Promise(function (onnistui, epaonnistui) {
pyynto.addEventListener("success", () => onnistui(pyynto.result));
pyynto.addEventListener("error", () => epaonnistui(pyynto.error));
});
};
const avaaTietokanta = function () {
const pyynto = indexedDB.open("muistilista", 1);
pyynto.addEventListener("upgradeneeded", function () {
const tietokanta = pyynto.result;
if (!tietokanta.objectStoreNames.contains("merkinnat")) {
const varasto = tietokanta.createObjectStore("merkinnat",
{ keyPath: "id", autoIncrement: true });
varasto.createIndex("paivamaara", "paivamaara");
}
});
return lupaukseksi(pyynto);
};
const lisaaMerkinta = async function (merkinta) {
const tietokanta = await avaaTietokanta();
// transaktio on luotava itse: se kertoo mihin varastoihin kosketaan ja
// saako niitä muuttaa. "readwrite" sallii muutokset, "readonly" ei.
const transaktio = tietokanta.transaction("merkinnat", "readwrite");
const varasto = transaktio.objectStore("merkinnat");
const id = await lupaukseksi(varasto.add(merkinta));
tietokanta.close();
return id;
};
Vertailu
| localStorage | sessionStorage | IndexedDB | |
|---|---|---|---|
| Elinikä | kunnes poistetaan | välilehden sulkemiseen | kunnes poistetaan |
| Laajuus | lähde | lähde ja välilehti | lähde |
| Tallennettava tyyppi | merkkijono | merkkijono | lähes mikä tahansa |
| Koko | noin 5 Mt | noin 5 Mt | satoja megatavuja |
| Rajapinta | synkroninen | synkroninen | asynkroninen |
| Haut | vain avaimella | vain avaimella | avaimella ja hakemistoilla |
| Monimutkaisuus | vähäinen | vähäinen | huomattava |
Nyrkkisääntö: aloita localStoragesta. Jos tieto ei saa jäädä koneelle käytön jälkeen, vaihda sessionStorageen. Siirry IndexedDB:hen vasta kun törmäät kokorajaan, tarvitset hakuja muulla kuin avaimella, tai huomaat sivun nykivän tallennuksen takia.
Lisätietoa
- Using the Web Storage API — MDN
- Window.localStorage
- Window.sessionStorage
- Window: storage event
- Using IndexedDB
- Storage quotas and eviction criteria
- idb — ohut lupauspohjainen kääre IndexedDB:lle
- Dexie.js — laajempi kirjasto, jos tarvitset kyselyitä
Käyttäjien kommentit