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.
localStorage và sessionStorage
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 Storagechỉ 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ác và tab 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ư Date và Map, hãy tận dụng replacer và reviver 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ự,MapvàSetcũ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
CustomEventcho thông báo cùng tab vàstoragecho 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ênregistry, và tạo ra các khóa lưu trữ có tiền tố. Ngoài ra, nó sử dụnglocalStoragevà 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
gettruy 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
setlư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
setWithTTLlư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
removexó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
clearxó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"và"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
TypedStoragelà 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.