TypeScript og Web Storage

TypeScript og Web Storage

Denne artikel forklarer TypeScript og Web Storage.

Vi forklarer TypeScript og Web Storage, herunder praktiske eksempler.

YouTube Video

TypeScript og Web Storage

Browserens Web Storage er et nøgle/værdi-lager til strenge. Det er letvægtigt og har et synkront API, men vær opmærksom på, at det kun kan lagre strenge, og at du selv skal håndtere undtagelser såsom overskridelse af lagringskvoten. Kombineret med TypeScript kan du tilføje typesikkerhed, sikker serialisering/deserialisering, centraliseret nøglehåndtering samt udløb og versionering, hvilket resulterer i et produktionsklart design.

localStorage og sessionStorage

localStorage er et vedvarende lager, der bevares efter at browseren er lukket, mens sessionStorage er et sessionlager pr. faneblad/vindue, som ryddes, når fanen lukkes. Begge gemmer værdier som nøgle-værdi-par (strenge).

1// Simple usage example: store and retrieve a string
2// This example uses localStorage to persist a username.
3localStorage.setItem('username', 'alice');
4console.log('saved username:', localStorage.getItem('username')); // "alice"
5
6// session storage example
7sessionStorage.setItem('session-id', 'xyz-123');
8console.log('session id:', sessionStorage.getItem('session-id')); // "xyz-123"
  • Denne kode er et eksempel på at gemme og hente strenge. Da Web Storage kun kan gemme strenge, skal objekter konverteres til JSON for at blive gemt.

Eksempel med JSON-parsing

For at gemme og genskabe objekter i Web Storage skal du bruge JSON.stringify / JSON.parse.

1// Simple write & read with JSON
2// Store a small object as JSON and read it back.
3const user = { id: 1, name: "Alice" };
4localStorage.setItem("app:user", JSON.stringify(user));
5
6const raw = localStorage.getItem("app:user");
7const parsed = raw ? (JSON.parse(raw) as { id: number; name: string }) : null;
8
9console.log("user:", parsed); // user: { id: 1, name: "Alice" }
  • Denne kode er et minimalt fungerende eksempel. I virkelige applikationer skal du også tage højde for undtagelser som f.eks. parse-fejl og at løbe tør for lagringskvote.

Eksempel på undtagelseshåndtering ved JSON-parsing

Her giver vi en wrapper, der gør læsninger og skrivninger sikre og håndterer JSON.parse-fejl og setItem-undtagelser.

 1// Safe JSON helpers
 2// These helpers handle parse errors and memory/storage issues separately.
 3export function safeParseJson<T>(raw: string | null): T | null {
 4  if (raw == null) return null;
 5
 6  try {
 7    return JSON.parse(raw) as T;
 8  } catch (error: any) {
 9    if (error instanceof SyntaxError) {
10      console.error("JSON parse error:", error.message);
11      return null;
12    }
13
14    console.error("Unexpected JSON error:", error);
15    return null;
16  }
17}
18
19// Safe setter for JSON values
20export function setJson(storage: Storage, key: string, value: unknown): void {
21  try {
22    const json = JSON.stringify(value);
23    storage.setItem(key, json);
24  } catch (error: any) {
25    if (error?.name === "QuotaExceededError") {
26      console.error("Storage quota exceeded while saving JSON:", error.message);
27    } else if (error instanceof TypeError) {
28      console.error("JSON serialization failed:", error.message);
29    } else {
30      console.error("Unexpected error while setting JSON:", error);
31    }
32  }
33}
34
35// Safe getter for JSON values
36export function getJson<T>(storage: Storage, key: string, fallback?: T): T | null {
37  const parsed = safeParseJson<T>(storage.getItem(key));
38  return parsed ?? (fallback ?? null);
39}
  • Du kan genbruge dette hjælpeværktøj i hele appen. Indpak yderligere, når du vil tilføje try/catch.

Eksempel på lagring med TTL (udløb)

Da Web Storage i sig selv ikke har TTL, håndter det ved at tilføje en expiresAt til værdien.

 1// TTL wrapper
 2// Store { value, expiresAt } and automatically expire items on read.
 3type WithTTL<T> = { value: T; expiresAt: number | null };
 4
 5export function setWithTTL<T>(storage: Storage, key: string, value: T, ttlMs?: number) {
 6  const payload: WithTTL<T> = { value, expiresAt: ttlMs ? Date.now() + ttlMs : null };
 7  setJson(storage, key, payload);
 8}
 9
10export function getWithTTL<T>(storage: Storage, key: string): T | null {
11  const payload = getJson<WithTTL<T>>(storage, key);
12  if (!payload) return null;
13  if (payload.expiresAt && Date.now() > payload.expiresAt) {
14    storage.removeItem(key);
15    return null;
16  }
17  return payload.value;
18}
  • TTL er effektivt til cache og automatisk gemning (kladder) og kan reducere inkonsistenser.

Eksempel på centraliseret nøglehåndtering og navnerum (undgåelse af kollisioner)

Standardisering af nøgler som prefix + version + name reducerer kollisioner og gør migrationer enklere.

 1// Key factory with namespacing and versioning
 2// Create namespaced keys like "myapp:v1:theme" to avoid collisions.
 3const APP = "myapp";
 4const V = "v1";
 5
 6const ns = (k: string) => `${APP}:${V}:${k}`;
 7
 8const Keys = {
 9  theme: ns("theme"),
10  user: ns("user"),
11  cart: ns("cart"),
12  draft: ns("draft"),
13};
  • At inkludere et navneområde i nøglen gør det lettere at skifte versioner eller foretage oprydning senere.

Eksempel på overskredet kvote (QuotaExceededError) og fallback-strategier

Vær opmærksom på, at der kan opstå en QuotaExceededError ved kørsel af setItem, og udarbejd en fallback-strategi til, når det mislykkes at gemme data. Når du f.eks. overskrider lagringskapaciteten, kan du slette gamle data eller falde tilbage til sessionStorage eller en in-memory-cache for at bevare appens overordnede stabilitet.

 1// Quota-safe set with fallback to in-memory storage
 2// Return true if stored, false otherwise.
 3export function trySetJson(storage: Storage, key: string, value: unknown, fallback?: Map<string, string>): boolean {
 4  try {
 5    storage.setItem(key, JSON.stringify(value));
 6    return true;
 7  } catch (err) {
 8    console.warn("Failed to set item:", key, err);
 9    if (fallback) {
10      try {
11        fallback.set(key, JSON.stringify(value));
12        return true;
13      } catch {
14        return false;
15      }
16    }
17    return false;
18  }
19}
20
21// Example fallback usage
22const inMemoryFallback = new Map<string, string>();
23const ok = trySetJson(localStorage, Keys.cart, { items: [] }, inMemoryFallback);
24if (!ok) console.log("Saved to fallback map instead");
  • Afhængigt af fallback-destinationen er datapersistens ikke nødvendigvis garanteret. Vælg derfor en passende lagringsdestination i henhold til dit brugsscenarie. For eksempel kan du i privat browsing-tilstand eller under lagringsbegrænsninger opretholde funktionaliteten ved midlertidigt at bruge hukommelse eller sessionStorage.

Eksempel på synkronisering på tværs af faner (storage-event) og notifikationer i samme fane

Ved at bruge window.addEventListener('storage', …) kan du registrere lagringsændringer, der er sket i andre faner. Denne hændelse udløses dog ikke i den samme fane. Derfor bør du, for ændringsnotifikationer i den samme fane, udsende dine egne events ved hjælp af CustomEvent.

 1// Cross-tab and same-tab notification helpers
 2// storage event fires on other tabs; use CustomEvent for same-tab listeners.
 3const SAME_TAB_EVENT = "storage:changed";
 4
 5function notifyChanged(key: string) {
 6  window.dispatchEvent(new CustomEvent(SAME_TAB_EVENT, { detail: { key } }));
 7}
 8
 9function setJsonWithNotify(storage: Storage, key: string, value: unknown) {
10  setJson(storage, key, value);
11  notifyChanged(key);
12}
13
14// Listeners
15window.addEventListener("storage", (e) => {
16  if (e.key === Keys.theme) {
17    const theme = safeParseJson<string>(e.newValue);
18    console.log("Theme changed in another tab:", theme);
19  }
20});
21
22window.addEventListener(SAME_TAB_EVENT, (e: Event) => {
23  const detail = (e as CustomEvent).detail as { key: string };
24  console.log("Changed in this tab:", detail.key);
25});
  • Med denne kode kan du synkronisere lagringsændringer på tværs af både andre faner og den aktuelle fane.

Typesikkert register (strikt typning pr. nøgle)

Definér en nøgle-til-type-mapping i TypeScript for at forhindre fejl under gemning og hentning.

 1// Typed registry that enforces types per key
 2// Registry maps keys to their allowed types.
 3type Registry = {
 4  [k in typeof Keys.theme]: "light" | "dark";
 5} & {
 6  [k in typeof Keys.user]: { id: number; name: string };
 7};
 8
 9type KeyOf<R> = Extract<keyof R, string>;
10
11export const TypedStore = {
12  get<K extends KeyOf<Registry>>(key: K, storage: Storage = localStorage): Registry[K] | null {
13    return getJson<Registry[K]>(storage, key);
14  },
15  set<K extends KeyOf<Registry>>(key: K, value: Registry[K], storage: Storage = localStorage): void {
16    setJson(storage, key, value);
17  },
18  remove<K extends KeyOf<Registry>>(key: K, storage: Storage = localStorage): void {
19    storage.removeItem(key);
20  },
21};
  • {^ i18n_speak 型をキーに関連付けることで、ランタイムでの誤使用をコンパイル時に検出でき、安全性を高めることができます。 ^}

Serialisering/reviver til komplekse typer (Date/Map m.m.)

For korrekt at genskabe objekter som Date og Map, udnyt replacer og reviver i JSON.stringify.

 1// Serializing Dates with replacer and reviver
 2// Custom replacer marks Date objects for correct revival.
 3function replacer(_k: string, v: unknown) {
 4  if (v instanceof Date) return { __type: "Date", value: v.toISOString() };
 5  return v;
 6}
 7
 8function reviver(_k: string, v: any) {
 9  if (v && v.__type === "Date") return new Date(v.value);
10  return v;
11}
12
13function setJsonWithDates(storage: Storage, key: string, value: unknown) {
14  storage.setItem(key, JSON.stringify(value, replacer));
15}
16
17function getJsonWithDates<T>(storage: Storage, key: string): T | null {
18  const raw = storage.getItem(key);
19  if (!raw) return null;
20  try { return JSON.parse(raw, reviver) as T; } catch { return null; }
21}
  • Med denne metode genskabes Date-objekter korrekt som Date. Tilsvarende kan Map og Set også markeres og genskabes.

Eksempel på versionering og migrationsstrategier

Hvis du muligvis ændrer lagerformatet i fremtiden, så inkludér en version i payloaden og forbered migrationer.

 1// Versioned payload pattern for migrations
 2// Keep { v, data } and migrate on read if necessary.
 3type VersionedPayload<T> = { v: number; data: T };
 4
 5function migrateUserV1toV2(u1: { id: number; name: string }) {
 6  return { id: u1.id, profile: { displayName: u1.name } };
 7}
 8
 9function readUserAnyVersion(): { id: number; profile: { displayName: string } } | null {
10  const raw = localStorage.getItem(Keys.user);
11  if (!raw) return null;
12  try {
13    const obj = JSON.parse(raw) as VersionedPayload<any>;
14    if (obj.v === 2) {
15      return obj.data;
16    } else if (obj.v === 1) {
17      const migrated = migrateUserV1toV2(obj.data);
18      localStorage.setItem(Keys.user, JSON.stringify({ v: 2, data: migrated }));
19      return migrated;
20    }
21    return null;
22  } catch (err) {
23    console.error("migration parse error", err);
24    return null;
25  }
26}
  • Ved at stable små migrationer kan du bevare bagudkompatibilitet.

Eksempel på håndtering i SSR (server-side rendering)

I miljøer uden window vil en direkte reference til localStorage crashe, så brug miljøtjek.

1// Guard for SSR
2// Return a Storage-compatible object or null when not in browser.
3export const isBrowser = (): boolean => typeof window !== "undefined" && typeof window.localStorage !== "undefined";
4
5export const safeLocalStorage = (): Storage | null => (isBrowser() ? window.localStorage : null);
6
7// Usage
8const ls = safeLocalStorage();
9if (ls) setJson(ls, Keys.theme, "dark");
  • For kode der skal understøtte SSR, husk altid at tjekke typeof window.

Praktiske tips

  • Begræns skrivefrekvens med debounce/throttle (for at dæmpe spidser fra UI-handlinger).
  • Håndter nøgler med et navnerum + version, fx app:v1:....
  • Gem som udgangspunkt ikke følsomme oplysninger (access tokens osv.). Hvis du er nødt til det, så overvej korte levetider plus servervalidering eller WebCrypto.
  • Kapaciteten afhænger af browseren (få MB), så gem store data i IndexedDB.
  • Brug CustomEvent til notifikationer i samme fane og storage til på tværs af faner.
  • I SSR skal du altid tjekke typeof window.

Sammenfattet 'typesikker store'-klasse

Lad os se på en eksempelimplementering af en generisk klasse, der integrerer de elementer, vi hidtil har gennemgået, herunder navnerum, typesikkerhed, udløb (TTL) og undtagelseshåndtering. I faktiske produkter bør du overveje at tilføje tests, logning, LRU-baseret sletning af gamle data, kryptering m.m.

 1// Comprehensive TypedStorage store integrating many patterns shown above.
 2// - type-safe registry per key
 3// - prefix (namespace + version)
 4// - trySet with fallback
 5// - same-tab notify
 6// - TTL optional getter/setter
 7type Jsonifiable = string | number | boolean | null | Jsonifiable[] | { [k: string]: Jsonifiable };
 8
 9interface StoreOptions {
10  storage?: Storage | null;   // default: auto-detected localStorage or null
11  prefix?: string;            // e.g., "myapp:v1"
12  sameTabEvent?: string | null;
13  fallback?: Map<string, string>; // in-memory fallback
14}
  • Denne kode viser konfigurationen og typedefinitionerne til at opbygge en typesikker, funktionsrig nøgle-værdi-lagring.
 1export class TypedStorage<Reg extends Record<string, Jsonifiable | object>> {
 2  private storage: Storage | null;
 3  private prefix: string;
 4  private sameTabEvent: string | null;
 5  private fallback?: Map<string, string>;
 6
 7  constructor(private registry: Reg, opts: StoreOptions = {}) {
 8    this.storage = opts.storage ?? (typeof window !== "undefined" ? window.localStorage : null);
 9    this.prefix = (opts.prefix ?? "app:v1") + ":";
10    this.sameTabEvent = opts.sameTabEvent ?? "storage:changed";
11    this.fallback = opts.fallback;
12  }
13
14  private k(key: keyof Reg & string) { return this.prefix + key; }
  • Denne kode er kernen i TypedStorage-klassen, som leverer typesikker nøgle-værdi-lagring. Den håndterer de tilladte nøgler og deres typer baseret på registry og genererer præfiksede lagernøgler. Derudover bruger den localStorage og et in-memory-fallback og lader dig angive et eventnavn til ændringsnotifikationer i samme fane.
 1  // Basic get with optional TTL-aware retrieval
 2  get<K extends keyof Reg & string>(key: K): Reg[K] | null {
 3    const fullKey = this.k(key);
 4    try {
 5      const raw = this.storage ? this.storage.getItem(fullKey) : this.fallback?.get(fullKey) ?? null;
 6      if (!raw) return null;
 7      // Check if value is TTL-wrapped
 8      const maybe = safeParseJson<{ value: Reg[K]; expiresAt?: number }>(raw);
 9      if (maybe && typeof maybe.expiresAt === "number") {
10        if (maybe.expiresAt && Date.now() > maybe.expiresAt) {
11          this.remove(key);
12          return null;
13        }
14        return maybe.value;
15      }
16      return safeParseJson<Reg[K]>(raw);
17    } catch (err) {
18      console.error("TypedStorage.get error", err);
19      return null;
20    }
21  }
  • get-metoden henter værdien for den angivne nøgle på en typesikker måde og kan valgfrit håndtere værdier med TTL (udløb).
 1  // Basic set; returns success boolean
 2  set<K extends keyof Reg & string>(key: K, value: Reg[K]): boolean {
 3    const fullKey = this.k(key);
 4    const payload = JSON.stringify(value);
 5    try {
 6      if (this.storage) this.storage.setItem(fullKey, payload);
 7      else this.fallback?.set(fullKey, payload);
 8      if (this.sameTabEvent) window.dispatchEvent(new CustomEvent(this.sameTabEvent, { detail: { key: fullKey } }));
 9      return true;
10    } catch (err) {
11      console.warn("TypedStorage.set primary failed, trying fallback", err);
12      try {
13        if (this.fallback) {
14          this.fallback.set(fullKey, payload);
15          return true;
16        }
17        return false;
18      } catch (e) {
19        console.error("TypedStorage.set fallback failed", e);
20        return false;
21      }
22    }
23  }
  • set-metoden gemmer en værdi under den angivne nøgle og returnerer en boolsk værdi, der angiver, om det lykkedes.
1  // Set with TTL convenience
2  setWithTTL<K extends keyof Reg & string>(key: K, value: Reg[K], ttlMs?: number): boolean {
3    const payload = { value, expiresAt: ttlMs ? Date.now() + ttlMs : null };
4    return this.set(key, payload as unknown as Reg[K]);
5  }
  • setWithTTL-metoden gemmer en værdi med en TTL (udløb).
1  remove<K extends keyof Reg & string>(key: K) {
2    const fullKey = this.k(key);
3    try {
4      if (this.storage) this.storage.removeItem(fullKey);
5      this.fallback?.delete(fullKey);
6    } catch (err) { console.warn("TypedStorage.remove error", err); }
7  }
  • remove-metoden sletter værdien for den angivne nøgle fra både lageret og fallback'en.
1  clear() {
2    try {
3      if (this.storage) this.storage.clear();
4      this.fallback?.clear();
5    } catch (err) { console.warn("TypedStorage.clear error", err); }
6  }
7}
  • clear-metoden sletter alle data, der er gemt i både lageret og fallback'en.
 1// Usage example
 2type MyReg = {
 3  theme: "light" | "dark";
 4  user: { id: number; name: string };
 5  draft: string;
 6};
 7
 8const memFallback = new Map<string, string>();
 9const store = new TypedStorage<MyReg>({} as MyReg, {
10  prefix: "myapp:v1",
11  sameTabEvent: "storage:changed",
12  fallback: memFallback
13});
14store.set("theme", "dark");
15console.log(store.get("theme")); // "dark"
16store.setWithTTL("draft", "in-progress...", 1000 * 60 * 60); // keep 1 hour
  • Denne kode er et eksempel på brug af TypedStorage, som gemmer og henter værdier som "theme" og "draft" i et typesikkert nøgle-værdi-lager og understøtter også TTL og et fallback. Den konfigurerer notifikationer i samme fane og et in-memory-fallback for at udføre lagringsoperationer sikkert.

  • TypedStorage-klassen er et praktisk udgangspunkt. Implementér LRU-strategier, kryptering, komprimering og fallbacks til IndexedDB efter behov.

Sammendrag

Når du bruger Web Storage med TypeScript, så husk altid fire punkter—typesikkerhed, undtagelsesrobusthed, sikkerhed og synkronisering (flere faner)—for et robust design. De wrappers og hjælpeværktøjer, vi har set indtil nu, er eksempler på det. Du kan også migrere til andet browser-lager som IndexedDB efter behov.

Du kan følge med i ovenstående artikel ved hjælp af Visual Studio Code på vores YouTube-kanal. Husk også at tjekke YouTube-kanalen.

YouTube Video