TypeScript ו-Web Storage

TypeScript ו-Web Storage

מאמר זה מסביר על TypeScript ו-Web Storage.

נסביר את TypeScript ו-Web Storage, כולל דוגמאות מעשיות.

YouTube Video

TypeScript ו-Web Storage

Web Storage של הדפדפן הוא אחסון מפתח/ערך עבור מחרוזות. זה קל משקל עם API סינכרוני, אך חשוב לזכור שהוא יכול לאחסן מחרוזות בלבד, ועליך לטפל בחריגות כגון חריגה ממכסת האחסון. כאשר משלבים עם TypeScript, ניתן להוסיף בטיחות טיפוסים, סיריאליזציה/דסיריאליזציה בטוחות, ניהול מפתחות מרוכז, וכן תפוגה וגרסאות, וכך לקבל עיצוב מוכן לפרודקשן.

localStorage ו-sessionStorage

localStorage הוא אחסון מתמשך שנשאר גם לאחר סגירת הדפדפן, בעוד sessionStorage הוא אחסון לפי לשונית/חלון שמתנקה כאשר סוגרים את הלשונית. שניהם מאחסנים ערכים כצמדי מפתח-ערך (מחרוזות).

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"
  • הקוד הזה הוא דוגמה לשמירה ולאחזור של מחרוזות. מכיוון ש-Web Storage יכול לאחסן רק מחרוזות, יש להמיר אובייקטים ל-JSON כדי לאחסנם.

דוגמה המשתמשת בניתוח JSON

כדי לאחסן ולשחזר אובייקטים ב-Web Storage, השתמשו ב-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" }
  • הקוד הזה הוא דוגמה מינימלית עובדת. ביישומים אמיתיים יש לקחת בחשבון גם חריגות כגון כשלי ניתוח (parse) ואזילה של מכסת האחסון.

דוגמה לטיפול בחריגות בעת ניתוח JSON

כאן אנו מספקים מעטפת (wrapper) כדי להפוך קריאות וכתיבות לבטוחות ולטפל בכשלי JSON.parse ובחריגות של setItem.

 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}
  • אפשר לעשות שימוש חוזר בכלי הזה בכל האפליקציה. עטפו עוד כאשר תרצו להוסיף try/catch.

דוגמה לשמירה עם TTL (תפוגה)

מאחר של-Web Storage אין TTL מובנה, נהל זאת על ידי הוספת expiresAt לערך.

 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 יעיל עבור מטמון ושמירה אוטומטית (טיוטות) ויכול לצמצם חוסר עקביות.

דוגמה לניהול מפתחות מרוכז ושימוש במרחבי שמות (הימנעות מהתנגשויות)

אחידות שמות המפתחות בפורמט prefix + version + name מפחיתה התנגשויות ומפשטת מיגרציות.

 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};
  • הוספת מרחב שמות (namespace) למפתח מקלה על החלפת גרסאות או ביצוע ניקוי בהמשך.

דוגמה לחריגה ממכסה (QuotaExceededError) ואסטרטגיות fallback

יש להביא בחשבון ש-QuotaExceededError עשוי להתרחש בעת הרצת setItem, ולתכנן אסטרטגיית גיבוי (fallback) למקרה שכשל בשמירת נתונים. לדוגמה, כאשר חוצים את קיבולת האחסון, ניתן למחוק נתונים ישנים או להסתמך על sessionStorage או מטמון בזיכרון (in-memory) כדי לשמור על יציבות כללית של היישום.

 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");
  • בהתאם ליעד הגיבוי, יתכן שלא תובטח התמדה (persistence) של הנתונים. לכן, בחרו יעד אחסון מתאים בהתאם למקרה השימוש שלכם. לדוגמה, במצב גלישה פרטית או תחת מגבלות אחסון, ניתן לשמור על פונקציונליות באמצעות שימוש זמני בזיכרון או ב-sessionStorage.

דוגמה לסנכרון בין לשוניות (אירוע storage) ולהתראות באותה לשונית

באמצעות window.addEventListener('storage', …) ניתן לזהות שינויים באחסון שהתרחשו בכרטיסיות אחרות. עם זאת, אירוע זה אינו נורה באותה כרטיסייה. לכן, עבור התראות על שינויים באותה כרטיסייה, פרסמו אירועים משלכם באמצעות 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});
  • בעזרת קוד זה ניתן לסנכרן שינויים באחסון הן בין כרטיסיות אחרות והן בכרטיסייה הנוכחית.

מרשם בטוח-טיפוסים (טיפוס מחמיר לכל מפתח)

הגדירו מיפוי מפתח-לטיפוס ב-TypeScript כדי למנוע טעויות בעת שמירה ואחזור.

 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 型をキーに関連付けることで、ランタイムでの誤使用をコンパイル時に検出でき、安全性を高めることができます。 ^}

סיריאליזציה/מחייה (reviver) לטיפוסים מורכבים (Date/Map וכו')

כדי לשחזר כראוי אובייקטים כגון Date ו-Map, נצלו את הreplacer וה-reviver של 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}
  • בשיטה זו, אובייקטי Date משוחזרים כהלכה כ-Date. באופן דומה, ניתן גם לסמן ולשחזר Map ו-Set.

דוגמה לניהול גרסאות ולאסטרטגיות מיגרציה

אם ייתכן שתשנו את פורמט האחסון בעתיד, כללו גרסה במטען הנתונים (payload) והכינו מיגרציות.

 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}
  • בערימה של מיגרציות קטנות ניתן לשמור על תאימות לאחור.

דוגמה לטיפול ב-SSR (רינדור בצד השרת)

בסביבות ללא window, הפניה ישירה אל localStorage תגרום לקריסה, לכן השתמשו בבדיקות סביבה.

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");
  • לקוד שחייב לתמוך ב-SSR, זכרו תמיד לבדוק typeof window.

טיפים מעשיים

  • הגבילו תדירות כתיבה באמצעות debounce/throttle (להפחתת עומסים מפעולות UI).
  • נהלו מפתחות עם מרחב-שם + גרסה, למשל app:v1:....
  • ככלל, אל תאחסנו מידע רגיש (אסימוני גישה וכד'). אם אין ברירה, שקלו חיי מדף קצרים בתוספת ולידציה בצד השרת או WebCrypto.
  • הקיבולת תלויה בדפדפן (כמה מגה-בייטים), לכן אחסנו נתונים גדולים ב-IndexedDB.
  • השתמשו ב-CustomEvent להתראות באותה לשונית וב-storage בין לשוניות.
  • ב-SSR, תמיד בדקו typeof window.

מחלקת 'type-safe store' מאוחדת

בואו נבחן דוגמת מימוש של מחלקה גנרית שמשלבת את המרכיבים שסקרנו עד כה, כולל מרחבי שמות, בטיחות טיפוסים, תפוגה (TTL) וטיפול בחריגות. במוצרים בפועל, שקלו להוסיף בדיקות, רישום לוגים, מחיקה של נתונים ישנים על בסיס LRU, הצפנה ועוד.

 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}
  • קוד זה מציג את התצורה והגדרות הטיפוסים לבניית אחסון מפתח-ערך בעל בטיחות טיפוסים ועשיר בתכונות.
 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; }
  • הקוד הזה הוא הליבה של המחלקה TypedStorage המספקת אחסון מפתח-ערך עם בטיחות טיפוסים. הוא מנהל את המפתחות המותרים ואת הטיפוסים שלהם על פי ה-registry, ומייצר מפתחות אחסון עם קידומת. בנוסף, היא משתמשת ב-localStorage ובגיבוי בזיכרון, ומאפשרת להגדיר שם אירוע עבור התראות שינוי באותה כרטיסייה.
 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 מאחזרת את הערך עבור המפתח שצוין באופן בטוח מבחינת טיפוסים, ויכולה גם לטפל בערכים עם TTL (תפוגה).
 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 שומרת ערך תחת המפתח שצוין ומחזירה ערך בוליאני המציין אם הפעולה הצליחה.
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 שומרת ערך עם זמן תפוגה (TTL).
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 מוחקת את הערך עבור המפתח שצוין מן האחסון ומהאחסון החלופי.
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 מוחקת את כל הנתונים המאוחסנים מן האחסון ומהאחסון החלופי.
 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
  • הקוד הזה הוא דוגמה לשימוש ב-TypedStorage, ששומר ומאחזר ערכים כגון "theme" ו-"draft" באחסון מפתח-ערך עם בטיחות טיפוסים, ותומך גם ב-TTL ובמנגנון חלופי. הוא מגדיר התראות באותה כרטיסייה וגיבוי בזיכרון כדי לבצע פעולות אחסון בצורה בטוחה.

  • המחלקה TypedStorage היא נקודת פתיחה מעשית. ממשו אסטרטגיות LRU, הצפנה, דחיסה וחלופות (fallback) ל-IndexedDB לפי הצורך.

סיכום

בעת שימוש ב-Web Storage עם TypeScript, זכרו תמיד ארבעה עקרונות—בטיחות טיפוסים, חסינות לחריגות, אבטחה וסנכרון (ריבוי לשוניות)—לשם תכנון חזק. העטיפות והכלים שראינו עד כה הם דוגמאות לכך. ניתן גם להגר לאחסון דפדפן אחר כגון IndexedDB לפי הצורך.

תוכלו לעקוב אחר המאמר שלמעלה באמצעות Visual Studio Code בערוץ היוטיוב שלנו. נא לבדוק גם את ערוץ היוטיוב.

YouTube Video