Eine gängige Aufgabe in JavaScript Applikationen ist es, in einen Store Werte mit einem Key zu speichern und diese dann auszulesen. Ob, man einen Cache in einer Server Applikation implementiert oder in einer Browseranwendung Werte in den LocalStorage schreibt. Man sieht: Es ist nicht entscheidend wo der JavaScript Code läuft und wohin die Daten “geschrieben” werden. Dieses abstrakte Konzept kann man überall finden. Deshalb möchte ich einen genaueren Blick darauf werfen und überlegen, wie man es idealerweise implementieren kann.
Abschauen bzw. von anderen lernen
Bei einem so gängigen Konzept ist es sicher, dass sich bereits viele andere Gedanken dazu gemacht haben. Schauen wir uns also zunächst an, was man in den Weiten der NPM Registry finden kann. Ein sehr spannendes Package ist unstorage. Es bietet Funktionen ein “Storage” Objekt zu erstellen, das ein verständliches Interface bietet für die essenziellen Interaktionen. Standardmäßig speichert der erzeugte Storage in-memory. Wohin der Storage allerdings schreibt, ist mit sogenannten “Driver” abstrahiert. Diese werden initial bei der Erstellung des Storage mitgegeben. Code Beispiel 1 zeigt, wie einfach die Verwendung von unstorage ist und implementiert einen simplen in-memory Cache.
import { createStorage } from "unstorage";
import lruCacheDriver from "unstorage/drivers/lru-cache";
const storage = createStorage({
driver: lruCacheDriver({})
});
await storage.setItem('my-key', 'my value');
const myResultFromCache = await storage.getItem('my-key');
console.log(myResultFromCache); // logs: my valueVon unstorage kann man sich die homogene Storage Interaktionen mit drunterliegenden Driver abschauen.
Wie funktioniert hier die Typisierung? Bei unstorage sind die Storage Methoden wie z.B. getItem, oder setItem generisch. Mit getItem<string>("key") kann man den erwarteten Value-Typ als String definieren. Der Rückgabetyp nach dem await ist folglich string | null, weil der Eintrag fehlen kann. Es geht jedoch bereits zu einem früheren Zeitpunkt! Schon bei der Erstellung des Storage können die Typen von Key und Value definiert werden. Code Beispiel 2, das von der offiziellen Dokumentation genommen ist, zeigt wie die Storage Einträge explizit definiert werden können. Wenn eine explizite Auflistung der Einträge nicht gewünscht ist, was wohl der gängigere Fall sein könnte, kann man “items” als Record definieren.
type StorageDefinition = {
items: {
foo: string;
baz: number;
};
};
const storage = createStorage<StorageDefinition>();
await storage.has("foo"); // TS prompts you with the optional keys "foo" or "baz"
await storage.getItem("baz"); // => number | null
await storage.setItem("foo", 12); // TypeScript error: number is not compatible with string
await storage.setItem("foo", "val"); // Check okEin weiteres spannendes Feature, das unstorage bietet sind sogenannte Namespaces. Mit ihnen lassen sich weitere Storage Objekte erzeugen, die automatisch einen prefix an einen Key eines Eintrags setzen. Dies kann nützlich sein, um zwischen Typen von Storage Einträgen zu unterscheiden.
Und nun zu etwas komplett Anderem. Wie wäre es, wenn man den Typen an einen Storage Key bindet? Diese Idee findet sich in Vue’s Dependency Injection. Hier gibt es den Generic Typ InjectionKey. Code Beispiel 3, aus der offiziellen Dokumentation, veranschaulicht wie er verwendet wird.
import { provide, inject } from "vue";
import type { InjectionKey } from "vue";
const key = Symbol() as InjectionKey<string>;
provide(key, "foo"); // providing non-string value will result in error
const foo = inject(key); // type of foo: string | undefinedImplementieren einer Abstraktion der SessionStorage Interaktionen
Wenn wir einen Storage möchten, der den SessionStorage eines Browsers verwendet, könnten wir nun den unstorage Driver “Browser” verwenden und ihn entsprechend konfigurieren. Aber aus Übungszweckend und weil wir lieber synchrone anstatt asynchroner Methoden hätten, machen wir es selbst. Mit den guten Ideen von unstorage und Vue im Gepäck, versuchen wir sehr simple helper Funktionen für den SessionStorage zu implementieren. Code Beispiel 4 legt die zwei Funktionen set und get an. Der SessionStorage erwartet, dass sowohl Key als auch Value Strings sind. Deshalb wird das zu speichernde Objekt mit JSON.stringify zu einem String umgewandelt und beim Lesen geparst.
export const sessionStorage_set = <T>(key: string, payload: T) => {
sessionStorage.setItem(key, JSON.stringify({ data: payload }));
};
export const sessionStorage_get = <T>(key: string): T | undefined => {
const item = sessionStorage.getItem(key);
if (!item) return;
try {
return JSON.parse(item).data as T;
} catch (_e) {
return undefined;
}
};Jetzt wenden wir die Idee der Vue Dependency Injection an und erlauben es, dass die Typinformation bereits im Key steckt (siehe Code Beispiel 5).
interface StorageConstraint<T> {}
export type StorageKey<T> = string & StorageConstraint<T>;
export const sessionStorage_set = <T>(key: StorageKey<T>, payload: T) => {
sessionStorage.setItem(key, JSON.stringify(payload));
};
export const sessionStorage_get = <T>(
key: StorageKey<T>,
): T | undefined => {
const item = sessionStorage.getItem(key);
if (!item) return;
try {
return JSON.parse(item) as T;
} catch (_e) {
return undefined;
}
};Verwendung unserer Beispiel helper-Funktionen
Nun können wir in unserer JavaScript Applikation komfortabel mit dem SessionStorage interagieren und auch das Namespacing umsetzen. Im Code Beispiel 6 speichern und lesen wir ein ContactData Objekt. Alle Typen werden korrekt von TypeScript erzwungen. Super!
interface ContactData {
phone: string;
mail: string;
}
const getContactDataStorageKey = (key: string): StorageKey<ContactData> =>
`MyData:${key}`;
const peterContactStorageKey = getContactDataStorageKey("peter");
sessionStorage_set(peterContactStorageKey, {
phone: "0123",
mail: "inbox@gmail.com",
invalid: "property", // TypeScript complains, which is good!
});
sessionStorage_get(peterContactStorageKey); // Return type: ContactData | undefined