
Lập trình viên, quản trị viên
Tạo web storage có sẵn tính reactivity bằng API reateSubscriber từ Svelte
Trên tinh thần tận dụng triệt để tính "reactivity" của Svelte 5 như đã đề cập ở bài viết "Giúp WXT Extension Storage trở nên "reactive" bằng Svelte's createSubscriber", mình xin chia sẻ một giải pháp đóng gói WebStorage (localStorage
và sessionStorage
) để có trải nghiệm lập trình tốt hơn trong các dự án Svelte.
Sao chép tệp này vào dự án của bạn...
import { createSubscriber } from 'svelte/reactivity';
// https://hackernoon.com/mastering-type-safe-json-serialization-in-typescript
type JSONPrimitive = string | number | boolean | null | undefined;
type JSONValue =
| JSONPrimitive
| JSONValue[]
| {
[key: string]: JSONValue;
};
interface ReactiveStorageConfig {
prefix: string;
storage: 'local' | 'session';
}
interface ReactiveStorageItemConfig extends ReactiveStorageConfig {
init: JSONValue;
}
interface ReactiveStorageItem extends Omit<ReactiveStorageItemConfig, 'storage'> {
key: string;
storage: Storage | null;
value: JSONValue;
update?: Parameters<Parameters<typeof createSubscriber>[0]>[0];
}
type ReactiveStorage<ValueMap extends Record<string, JSONValue>> = ValueMap & {
/**
* set each item back to initial value (if any)
**/
reset: () => void;
};
export function createReactiveStorage<ValueMap extends Record<string, JSONValue>>(
values: (keyof ValueMap | [key: keyof ValueMap, config: Partial<ReactiveStorageItemConfig>])[],
config: Partial<ReactiveStorageConfig> = {},
): ReactiveStorage<ValueMap> {
const globalConfig: ReactiveStorageConfig = {
prefix: '',
storage: 'local',
...config,
};
// resolve config for each item
const items = values.reduce(
(acc, value) => {
let key: string;
let config: ReactiveStorageItemConfig;
if (Array.isArray(value)) {
config = { init: null, ...globalConfig, ...value[1] };
key = value[0].toString();
} else {
config = { ...globalConfig, init: null };
key = value.toString();
}
let storage: Storage | null = null;
if (config.storage === 'local' && 'localStorage' in globalThis) {
storage = localStorage;
} else if (config.storage === 'session' && 'sessionStorage' in globalThis) {
storage = sessionStorage;
}
const keyWithPrefix = config.prefix + key;
acc[keyWithPrefix] = {
...config,
key,
storage,
value: config.init,
update: undefined, // will be set later
};
return acc;
},
{} as Record<string, ReactiveStorageItem>,
);
const reactive = {} as ReactiveStorage<ValueMap>;
for (const [keyWithPrefix, item] of Object.entries(items)) {
const { storage, init, key } = item;
const subscribe = createSubscriber((update) => {
items[keyWithPrefix].update = update;
});
// initialize
const json = storage?.getItem(keyWithPrefix);
if (json) {
item.value = JSON.parse(json) as JSONValue;
} else if (init !== null && init !== undefined) {
storage?.setItem(keyWithPrefix, JSON.stringify(init));
}
// proxy via getter/setter
Object.defineProperty(reactive, key, {
enumerable: true,
get() {
subscribe();
return item.value;
},
set(newValue: JSONValue) {
item.value = newValue;
if (!storage) return;
if (newValue === null) {
storage.removeItem(keyWithPrefix);
} else {
storage.setItem(keyWithPrefix, JSON.stringify(newValue));
}
item.update?.();
},
});
}
// implement reset method
Object.defineProperty(reactive, 'reset', {
enumerable: false,
writable: false,
configurable: false,
value: () => {
for (const item of Object.values(items)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(reactive as any)[item.key] = item.init;
}
},
});
// listen for storage changes from other documents
if ('addEventListener' in globalThis) {
globalThis.addEventListener('storage', (event) => {
const { key: keyWithPrefix, storageArea, newValue } = event;
if (!keyWithPrefix || !storageArea) return;
const item = items[keyWithPrefix];
if (!item || item.storage !== storageArea) return;
item.value = JSON.parse(newValue || 'null') as JSONValue;
item.update?.();
});
}
return reactive;
}
và sử dụng như ví dụ sau:
import { createReactiveStorage } from './lib';
type StorageValue = {
str: string | null;
num: number | null;
bool: boolean | null;
obj: {
str: string | null;
num: number | null;
bool: boolean | null;
};
};
export const storage = createReactiveStorage<StorageValue>(
[['str', { storage: 'session' }], ['num', { init: 0 }], ['bool', { prefix: 'special:' }], 'obj'],
{
prefix: 'app:',
storage: 'local',
},
);
storage.num
, storage.sr
, ... có thể sử dụng trong markup và tự động cập nhật khi giá trị thay đổi.
Bạn có thể tham khảo thảo luận tại Reddit với một giải pháp đề xuất từ Joy of Code, hoặc xem qua đoạn mã thử nghiệm của Rich Harris.
Về cơ bản, những hướng giải quyết này đều có ích nhưng chưa hoàn toàn thỏa mãn yêu cầu của mình.
Giải pháp giới thiệu trong bài viết này được thiết kế để giải quyết một số vấn đề sau đây:
JSON.stringify
và JSON.parse
khi lưu trữ và lấy dữ liệu từ WebStorage, vì localStorage
và sessionStorage
hiện tại chỉ hỗ trợ lưu trữ dưới dạng chuỗi (string),Bạn có thể thử nghiệm giải pháp của mình ngay sau đây
(hoặc qua Svelte REPL này).
Trước tiên, đây là cấu hình mong muốn của đối tượng storage
:
Đoạn mã cần thiết để tạo ra storage
được liệt kê tại ô storage.ts
ở phần "Cho tui code!". Tiếp theo, hãy dùng chương trình dưới đây và thử thay đổi giá trị của các phần tử:
Mở rộng ô sau đây nếu bạn muốn xem chi tiết mã nguồn của chương trình trên:
<script lang="ts">
import { storage } from './storage';
function increment(step: number = 1) {
if (storage.num === null) {
storage.num = 0;
} else {
storage.num += step;
}
}
function randomObj() {
storage.obj = {
str: Math.random().toString(36).substring(2, 15),
num: Math.floor(Math.random() * 100),
bool: Math.random() > 0.5,
};
}
</script>
<fieldset class="not-prose overflow-auto border border-outline p-4 max-w-full min-w-0">
<legend>Interactive Playground</legend>
<table class="border-collapse c-text-body-sm w-full">
<thead>
<tr class="bg-surface-subtle">
<th class="w-20" scope="col">Key</th>
<th scope="col">
<div class="flex items-center justify-between gap-2">
<span class="flex-1">Current Value</span>
<button class="c-btn py-0 px-2" onclick={storage.reset}>
<span>Reset all</span>
</button>
</div>
</th>
<th class="w-40" scope="col">Action</th>
</tr>
</thead>
<tbody>
<!-- str -->
<tr>
<th scope="row">str</th>
<td>"{storage.str}"</td>
<td>
<input class="c-text-input py-1 px-2" type="text" placeholder="Type something..." bind:value={storage.str} />
</td>
</tr>
<!-- num -->
<tr>
<th scope="row">num</th>
<td>{storage.num}</td>
<td>
<button class="c-btn c-text-body-sm py-1 px-2" onclick={() => increment(1)}>
<span>Increment</span>
</button>
<button class="c-btn c-text-body-sm py-1 px-2" onclick={() => increment(-1)}>
<span>Decrement</span>
</button>
</td>
</tr>
<!-- bool -->
<tr>
<th scope="row">bool</th>
<td>{storage.bool}</td>
<td>
<label class="flex items-center gap-2 cursor-pointer">
<input class="c-checkbox" type="checkbox" bind:checked={storage.bool} />
<span>Toggle</span>
</label>
</td>
</tr>
<!-- obj -->
<tr>
<th scope="row">obj</th>
<td>{JSON.stringify(storage.obj)}</td>
<td>
<button class="c-btn c-text-body-sm py-1 px-2" onclick={randomObj}>
<span>Randomize</span>
</button>
</td>
</tr>
</tbody>
</table>
</fieldset>
<style lang="postcss">
td, th {
padding: 0.5rem 1rem;
border: 1px solid var(--color-outline);
vertical-align: middle;
}
</style>
Khó có thể nói đâu là điểm cân bằng giữa việc thiết kế một API vừa có kiến trúc đơn giản và hiệu quả,
lại vừa cung cấp trải nghiệm người dùng API một cách tự nhiên và thân thiện nhất.
Trong trường hợp này, một trong những yêu cầu mình đặt ra là storage
được tạo ra cần giống như một
đối tượng thông thường, có thể tham chiếu trực tiếp (vd. storage.field
), chứ ko phải thông qua một lớp
trung gian nào đó (vd. storage.field.current
):
<input type="text" bind:value={storage.str}>
Tiếp theo, mình muốn có thể khởi tạo một đối tượng storage
chứa nhiều phần tử dữ liệu khác nhau,
thay vì một đối tượng cho mỗi phần tử:
const str = new ReactiveStorageItem('str');
const bool = new ReactiveStorageItem('bool');
const storage = new ReactiveStorage(['str', 'bool']);
storage.str; storage.bool;
Bên cạnh đó, mình muốn tận dụng tối đa hỗ trợ từ Typescript cho type checking, nhưng không quá phụ thuộc vào Typescript generics và các kiểu phân thích tự động (inference):
type StorageValue = {
str: string | null;
num: number | null;
/* ... */
};
const storage = new ReactiveStorage<StorageValue>({ /* ... */ })
storage.str; // <-- string
stroage.num; // <-- number
Mình có xu hướng hay đóng gói các giải pháp như thế này để tái sử dụng dễ dàng hơn — Tương tự như các thư viện tại tập hợp @svelte-put. Tuy nhiên, mình chưa sử dụng giải pháp "reactive-storage" này đủ nhiều để có thể đánh giá mức độ thực tế và tính ổn định của nó.
Có khá nhiều vấn đề cần nghĩ tới khi muốn đóng gói một giải pháp, ví dụ như:
JSON.parse
không thành công trong quá trình tham chiếu dữ
liệu,Nếu bạn nghĩ giải pháp này là hữu ích và nên được đóng gói thành thư viện, hãy cho mình biết nhé!
Để tránh dài dòng, mình không giải thích chi tiết về mã nguồn trong bài viết này. Nếu bạn muốn thảo luận thêm, hãy tìm mình tại vnphanquang trên Bluesky hoặc qua Discord của Svelte Việt Nam.
Hy vọng giải pháp được giới thiệu ở đây có ích cho bạn. Cảm ơn bạn đã đọc bài!
Bạn tìm thấy lỗi chính tả hay cần đính chính nội dung? Sửa bài viết này tại Github
Sử dụng Iconify & Tailwind V4 để hiện thị CSS icon, và @svelte-put/inline-svg cho SVG icon trong ứng dụng Vite & Svelte
Bản tin Svelte Việt Nam
Đăng ký nhận thông báo để không bỏ lỡ bài viết mới từ Blog của Svelte Việt Nam
Ứng dụng của tính reactivity trong Svelte khi xây dựng web extension bằng wxt.dev và extension storage api
Sửa trang này tại Github sveltevietnam.dev là một dự án mã nguồn mở và hoan nghênh sự đóng góp của bạn. Xin cảm ơn!