TypeScript và Web Storage

TypeScript và Web Storage

Bài viết này giải thích về TypeScript và Web Storage.

Chúng tôi giải thích về TypeScript và Web Storage, kèm theo các ví dụ thực tiễn.

YouTube Video

TypeScript và Web Storage

Web Storage của trình duyệt là cơ chế lưu trữ key/value dành cho chuỗi. Nó nhẹ với một API đồng bộ, nhưng lưu ý rằng nó chỉ lưu được chuỗi, và bạn phải xử lý các ngoại lệ như vượt quá hạn mức dung lượng lưu trữ. Khi kết hợp với TypeScript, bạn có thể bổ sung an toàn kiểu, tuần tự hóa/giải tuần tự an toàn, quản lý khóa tập trung, và hết hạn cùng phiên bản hóa, giúp đạt được một thiết kế sẵn sàng cho sản xuất.

localStoragesessionStorage

localStorage là bộ nhớ bền vững vẫn còn sau khi đóng trình duyệt, trong khi sessionStorage là bộ nhớ theo phiên của từng tab/cửa sổ và sẽ bị xóa khi tab đóng. Cả hai lưu giá trị dưới dạng cặp key-value (chuỗi).

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"
  • Đoạn mã này là ví dụ để lưu và truy xuất chuỗi. Vì Web Storage chỉ lưu được chuỗi, các đối tượng phải được chuyển sang JSON trước khi lưu.

Ví dụ sử dụng phân tích cú pháp JSON

Để lưu và khôi phục đối tượng trong Web Storage, hãy dùng 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" }
  • Đoạn mã này là một ví dụ hoạt động tối giản. Trong ứng dụng thực tế, bạn cũng phải tính đến các ngoại lệ như lỗi phân tích cú pháp và hết hạn mức dung lượng lưu trữ.

Ví dụ xử lý ngoại lệ khi phân tích JSON

Tại đây, chúng tôi cung cấp một lớp bọc để việc đọc/ghi an toàn và xử lý lỗi JSON.parse cũng như các ngoại lệ của 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}
  • Bạn có thể tái sử dụng tiện ích này trên toàn bộ ứng dụng. Bọc thêm khi bạn muốn thêm try/catch.

Ví dụ lưu kèm TTL (hết hạn)

Vì bản thân Web Storage không có TTL, hãy quản lý bằng cách thêm expiresAt vào giá trị.

 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 hữu ích cho cache và tự lưu (bản nháp), có thể giảm sự không nhất quán.

Ví dụ về quản lý khóa tập trung và namespacing (tránh xung đột)

Chuẩn hóa khóa theo dạng prefix + version + name giúp giảm xung đột và đơn giản hóa việc di trú (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};
  • Thêm một không gian tên vào khóa giúp dễ dàng chuyển phiên bản hoặc dọn dẹp về sau.

Ví dụ vượt hạn mức dung lượng (QuotaExceededError) và các chiến lược dự phòng

Hãy lưu ý rằng có thể xảy ra QuotaExceededError khi gọi setItem, và hãy thiết kế chiến lược dự phòng khi lưu dữ liệu thất bại. Ví dụ, khi vượt quá dung lượng lưu trữ, bạn có thể xóa dữ liệu cũ hoặc chuyển sang sessionStorage hay bộ nhớ đệm trong RAM để duy trì sự ổn định tổng thể của ứng dụng.

 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");
  • Tùy thuộc vào điểm dự phòng, tính bền vững của dữ liệu có thể không được đảm bảo. Vì vậy, hãy chọn đích lưu trữ phù hợp theo trường hợp sử dụng của bạn. Ví dụ, ở chế độ duyệt web riêng tư hoặc khi bị giới hạn lưu trữ, bạn có thể duy trì chức năng bằng cách tạm thời dùng bộ nhớ (in-memory) hoặc sessionStorage.

Ví dụ đồng bộ giữa các tab (sự kiện storage) và thông báo trong cùng tab

Sử dụng window.addEventListener('storage', …), bạn có thể phát hiện các thay đổi lưu trữ xảy ra ở các tab khác. Tuy nhiên, sự kiện này không kích hoạt trong cùng một tab. Do đó, đối với các thông báo thay đổi trong cùng một tab, hãy phát đi các sự kiện của riêng bạn bằng cách sử dụng 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});
  • Với đoạn mã này, bạn có thể đồng bộ hóa các thay đổi lưu trữ giữa cả các tab kháctab hiện tại.

Registry an toàn kiểu (gán kiểu nghiêm ngặt cho từng khóa)

Định nghĩa một ánh xạ khóa-kiểu trong TypeScript để tránh sai sót khi lưu và truy xuất.

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

Tuần tự hóa/khôi phục (reviver) cho các kiểu phức tạp (Date/Map, v.v.)

Để khôi phục đúng các đối tượng như DateMap, hãy tận dụng replacerreviver của 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}
  • Với phương pháp này, các đối tượng Date được khôi phục đúng là Date. Tương tự, MapSet cũng có thể được đánh dấu và khôi phục.

Ví dụ về phiên bản hóa và chiến lược di trú (migration)

Nếu có thể thay đổi định dạng lưu trữ trong tương lai, hãy đưa phiên bản vào payload và chuẩn bị các bước di trú.

 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}
  • Bằng cách xếp chồng các bước di trú nhỏ, bạn có thể duy trì khả năng tương thích ngược.

Ví dụ xử lý trong SSR (server-side rendering)

Trong môi trường không có window, tham chiếu trực tiếp localStorage sẽ gây sập, vì vậy hãy dùng các kiểm tra môi trường.

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");
  • Đối với mã cần hỗ trợ SSR, luôn nhớ kiểm tra typeof window.

Mẹo thực tiễn

  • Giới hạn tần suất ghi bằng debounce/throttle (để giảm các đợt ghi bùng nổ do thao tác UI).
  • Quản lý khóa với namespace + phiên bản, ví dụ: app:v1:....
  • Theo nguyên tắc, không lưu thông tin nhạy cảm (access token, v.v.). Nếu buộc phải lưu, cân nhắc thời hạn ngắn kèm xác thực phía máy chủ hoặc WebCrypto.
  • Dung lượng phụ thuộc vào trình duyệt (vài MB), vì vậy hãy lưu dữ liệu lớn trong IndexedDB.
  • Dùng CustomEvent cho thông báo cùng tab và storage cho giữa các tab.
  • Trong SSR, luôn kiểm tra typeof window.

Lớp 'type-safe store' tổng hợp

Hãy xem một ví dụ triển khai về một lớp tổng quát (generic) tích hợp các yếu tố mà chúng ta đã đề cập cho đến nay, bao gồm không gian tên, an toàn kiểu, thời hạn (TTL) và xử lý ngoại lệ. Trong sản phẩm thực tế, hãy cân nhắc bổ sung kiểm thử, ghi log, xóa dữ liệu cũ dựa trên LRU, mã hóa, v.v.

 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}
  • Đoạn mã này cho thấy cấu hình và các định nghĩa kiểu để xây dựng kho khóa-giá trị an toàn kiểu, giàu tính năng.
 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; }
  • Đoạn mã này là phần lõi của lớp TypedStorage, cung cấp kho lưu trữ key-value an toàn kiểu. Nó quản lý các khóa được phép và kiểu của chúng dựa trên registry, và tạo ra các khóa lưu trữ có tiền tố. Ngoài ra, nó sử dụng localStorage và cơ chế dự phòng trong bộ nhớ, và cho phép bạn đặt tên sự kiện cho thông báo thay đổi trong cùng tab.
 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  }
  • Phương thức get truy xuất giá trị cho khóa đã chỉ định theo cách an toàn kiểu và có thể tùy chọn xử lý các giá trị có TTL (thời hạn).
 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  }
  • Phương thức set lưu một giá trị dưới khóa đã chỉ định và trả về một boolean cho biết thao tác có thành công hay không.
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  }
  • Phương thức setWithTTL lưu một giá trị kèm TTL (thời hạn).
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  }
  • Phương thức remove xóa giá trị của khóa đã chỉ định khỏi cả kho lưu trữ và cơ chế dự phòng (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}
  • Phương thức clear xóa tất cả dữ liệu được lưu trong cả kho lưu trữ và cơ chế dự phòng.
 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
  • Đoạn mã này là ví dụ sử dụng TypedStorage, nơi lưu và truy xuất các giá trị như "theme""draft" trong một kho key-value an toàn kiểu, đồng thời hỗ trợ TTL và cơ chế dự phòng (fallback). Nó cấu hình thông báo trong cùng tab và cơ chế dự phòng trong bộ nhớ để thao tác lưu trữ một cách an toàn.

  • Lớp TypedStorage là một điểm khởi đầu thiết thực. Triển khai chiến lược LRU, mã hóa, nén, và dự phòng sang IndexedDB khi cần.

Tóm tắt

Khi dùng Web Storage với TypeScript, hãy luôn ghi nhớ bốn điểm—an toàn kiểu, khả năng chống chịu ngoại lệ, bảo mật, và đồng bộ (nhiều tab)—để có thiết kế vững chắc. Các lớp bọc và tiện ích mà chúng ta đã xem là ví dụ cho những điều đó. Bạn cũng có thể chuyển sang các cơ chế lưu trữ khác của trình duyệt như IndexedDB khi cần.

Bạn có thể làm theo bài viết trên bằng cách sử dụng Visual Studio Code trên kênh YouTube của chúng tôi. Vui lòng ghé thăm kênh YouTube.

YouTube Video