TypeScript และ Web Storage
บทความนี้อธิบายเกี่ยวกับ TypeScript และ Web Storage
เราอธิบาย TypeScript และ Web Storage พร้อมตัวอย่างการใช้งานจริง
YouTube Video
TypeScript และ Web Storage
Web Storage ของเบราว์เซอร์เป็นพื้นที่เก็บข้อมูลแบบคีย์/ค่า สำหรับสตริง มันมีน้ำหนักเบาพร้อม API แบบ synchronous แต่โปรดระวังว่าสามารถเก็บได้เฉพาะ สตริงเท่านั้น และคุณต้องจัดการกับ ข้อยกเว้น (exceptions) เช่น การใช้พื้นที่เก็บข้อมูลเกินโควตา เมื่อใช้ร่วมกับ TypeScript คุณสามารถเพิ่ม ความปลอดภัยของชนิดข้อมูล (type safety), การซีเรียไลซ์/ดีซีเรียไลซ์ที่ปลอดภัย, การจัดการคีย์แบบรวมศูนย์, และ การหมดอายุและการจัดการเวอร์ชัน ซึ่งทำให้ได้การออกแบบที่พร้อมใช้งานจริงในโปรดักชัน
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" }
- โค้ดนี้เป็นตัวอย่างทำงานได้ขั้นต่ำ (minimal working example) ในการใช้งานจริง คุณต้องคำนึงถึงข้อยกเว้น เช่น การพาร์สล้มเหลว และการใช้พื้นที่เก็บข้อมูลเกินโควตาด้วย
ตัวอย่างการจัดการข้อยกเว้นสำหรับการพาร์ส 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 ช่วยลดการชนกันและทำให้ง่ายต่อการย้ายข้อมูล (migration)
 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 หรือแคชในหน่วยความจำ เพื่อคงความเสถียรโดยรวมของแอป
 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");- ขึ้นอยู่กับปลายทางของ fallback ความคงอยู่ของข้อมูลอาจไม่ถูกรับประกัน ดังนั้นควรเลือกปลายทางการจัดเก็บที่เหมาะสมตามกรณีการใช้งานของคุณ ตัวอย่างเช่น ในโหมดการท่องเว็บแบบส่วนตัว หรือเมื่อมีข้อจำกัดด้านพื้นที่จัดเก็บ คุณสามารถคงการทำงานไว้ได้โดยใช้หน่วยความจำหรือ 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});- ด้วยโค้ดนี้ คุณสามารถซิงค์การเปลี่ยนแปลงของสตอเรจได้ทั้งระหว่าง แท็บอื่น และ แท็บปัจจุบัน
รีจิสทรีแบบ type-safe (กำหนดชนิดต่อคีย์อย่างเคร่งครัด)
กำหนดแผนที่คีย์ไปยังชนิดข้อมูล (key-to-type map) ใน 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 型をキーに関連付けることで、ランタイムでの誤使用をコンパイル時に検出でき、安全性を高めることができます。 ^}
การซีเรียลไลซ์/รีไวเวอร์สำหรับชนิดที่ซับซ้อน (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ก็สามารถทำเครื่องหมายและกู้คืนได้เช่นกัน
ตัวอย่างการกำหนดเวอร์ชันและกลยุทธ์การย้ายข้อมูล (migration)
หากในอนาคตอาจมีการเปลี่ยนรูปแบบการจัดเก็บ ให้ใส่เวอร์ชันไว้ในเพย์โหลดและเตรียมไมเกรชัน
 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 (server-side rendering)
ในสภาพแวดล้อมที่ไม่มี 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)
- จัดการคีย์ด้วย namespace + เวอร์ชัน เช่น app:v1:...
- โดยหลักการแล้ว ไม่ควรเก็บข้อมูลอ่อนไหว (เช่น โทเค็นการเข้าถึง เป็นต้น) หากจำเป็นจริงๆ ให้พิจารณาอายุสั้นร่วมกับการตรวจสอบจากเซิร์ฟเวอร์หรือ WebCrypto
- ความจุขึ้นอยู่กับเบราว์เซอร์ (ไม่กี่ MB) ดังนั้นข้อมูลขนาดใหญ่ควรเก็บใน 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และ fallback ในหน่วยความจำ และให้คุณกำหนดชื่ออีเวนต์สำหรับการแจ้งเตือนการเปลี่ยนแปลงในแท็บเดียวกัน
 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จะลบค่าของคีย์ที่ระบุออกจากทั้งที่เก็บข้อมูลและ fallback
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จะลบข้อมูลทั้งหมดที่เก็บไว้ทั้งในที่เก็บข้อมูลและใน fallback
 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 และ fallback ด้วย มันตั้งค่าการแจ้งเตือนภายในแท็บเดียวกันและ fallback ในหน่วยความจำเพื่อดำเนินการกับสตอเรจอย่างปลอดภัย
- 
คลาส TypedStorageเป็นจุดเริ่มต้นที่ใช้งานได้จริง ปรับใช้กลยุทธ์ LRU การเข้ารหัส การบีบอัด และการสำรองไปยัง IndexedDB ตามความจำเป็น
สรุป
เมื่อใช้ Web Storage กับ TypeScript ควรคำนึงถึง 4 ประเด็นเสมอ—ความปลอดภัยของชนิดข้อมูล ความทนทานต่อข้อยกเว้น ความปลอดภัย และการซิงโครไนซ์ (หลายแท็บ)—เพื่อการออกแบบที่แข็งแกร่ง ตัวห่อและยูทิลิตีที่เราเห็นมาจนถึงตอนนี้เป็นตัวอย่างของแนวคิดนั้น คุณยังสามารถย้ายไปใช้พื้นที่จัดเก็บของเบราว์เซอร์แบบอื่น เช่น IndexedDB ได้ตามความจำเป็น
คุณสามารถติดตามบทความข้างต้นโดยใช้ Visual Studio Code บนช่อง YouTube ของเรา กรุณาตรวจสอบช่อง YouTube ด้วย