
چند خط localStorage معمولاً خیلی بیخطر به نظر میرسه.
یک Feature آخرین Filter کاربر رو نگه میداره.
Authentication یک Token ذخیره میکنه.
Basket هم باید بعد از Refresh باقی بمونه.
هرکدوم از این تصمیمها بهتنهایی کاملاً منطقی هستن.
اما وقتی روی یک Frontend بزرگ کار میکنیم که چند Team مختلف روی بخشهای مختلفش کار میکنن، همهی این Featureها در نهایت دارن داخل یک Global Namespace مشترک مینویسن.
اینجاست که یک Browser API ساده، کمکم تبدیل به یک مسئلهی معماری میشه.
در تجربهی کار روی یک محصول بزرگ در اسنپفود، بارها دیدم که تصمیمهای مربوط به Storage چقدر سریع داخل Codebase پخش میشن.
Teamهای مختلف با سرعتهای متفاوت جلو میرن.
Featureها Lifecycle یکسانی ندارن.
Engineerها به تیم اضافه میشن، جابهجا میشن یا بخشی از سیستم رو Refactor میکنن.
ولی Keyای که امروز داخل Browser نوشته میشه، ممکنه ماهها بعد هنوز روی Device کاربر باقی مونده باشه.
JavaScript ابزارهای مختلفی برای Persist کردن Data در اختیار ما میذاره، ولی APIهای خام Browser تقریباً هیچ Boundary مشخصی ایجاد نمیکنن:
Keyها در سطح Origin، Global هستن؛
Valueها فقط String هستن؛
Ownership مشخص نیست؛
یک clear() میتونه Data همهی Featureها رو پاک کنه؛
TypeScript بعد از عبور Data از Storage Boundary نمیتونه از ما محافظت کنه؛
و معمولاً کسی نمیتونه دقیق بگه Application چه چیزهایی رو داخل Browser ذخیره کرده.
این مشکلات همیشه در Development خودشون رو نشون نمیدن.
بعضی وقتها تا Production جلو میرن.
برای حل همین مسئله، کتابخانهی namespaced-storage رو ساختم؛ در کنار یک ESLint Plugin و یک CLI.
هدفم این نبود که فقط یک API خوشگلتر روی localStorage بسازم.
هدف این بود که Browser Storage رو Explicit، Typed، قابلکنترل و قابلبررسی کنم.
فرض کنید دو Team مختلف روی بخشهای جداگانهی یک اپلیکیشن سفارش غذا کار میکنن.
Team مربوط به Checkout این کد رو مینویسه:
localStorage.setItem("status", "pending");
بعداً Team مربوط به Authentication هم این کد رو اضافه میکنه:
localStorage.setItem("status", "verified");
هر دو تغییر داخل Pull Request خودشون درست به نظر میرسن.
هر دو ممکنه تمام Testها رو Pass کنن.
اما داخل Browser، یکی بدون هیچ Error مشخصی Data اون یکی رو Overwrite میکنه.
راهحل رایجی که معمولاً برای این مشکل انتخاب میشه، اضافه کردن Prefix به Keyهاست:
localStorage.setItem("checkout:status", "pending"); localStorage.setItem("auth:status", "verified");
این کار کمک میکنه، ولی هنوز فقط یک Convention داریم.
هر Engineer باید یادش بمونه Prefix رو اضافه کنه.
باید املای Namespace رو درست بنویسه.
باید Data رو به شکل یکسان Serialize و Deserialize کنه.
و باید حواسش باشه Data متعلق به Feature دیگهای رو پاک نکنه.
مشکل Conventionهایی که فقط داخل Documentation زندگی میکنن اینه که بهمرور فراموش میشن.
در یک Codebase بزرگ، Boundary زمانی ارزشمنده که Toolها هم بتونن اون رو Enforce کنن.
با namespaced-storage هر Feature میتونه Storage خودش رو تعریف کنه:
npm install namespaced-storage
import { createLocalStorage } from "namespaced-storage"; export const basketStorage = createLocalStorage("basket"); basketStorage.set("count", 10); basketStorage.get("count"); // 10, not "10" basketStorage.clear(); // clears basket:* only
حالا Ownership داخل Browser هم Explicit میشه:
basket:count basket:items auth:token ui:theme
Feature مربوط به Basket میتونه State خودش رو پاک کنه، بدون اینکه Authentication، تنظیمات UI یا Data متعلق به Team دیگهای از بین بره.
Storage دیگه متعلق به کل Application نیست؛ هر بخش از Storage یک Owner مشخص داره.
من ترجیح میدم Store هر Feature نزدیک به همون Feature تعریف بشه:
src/ features/ auth/auth.storage.ts basket/basket.storage.ts shared/ui/ui.storage.ts
اینجا یک Central Registry نداریم که هر Team برای اضافه کردن Storage جدید مجبور باشه اون رو تغییر بده.
Ownership همچنان Local باقی میمونه؛ ولی Tooling میتونه یک نمای کلی از تمام Storageهای Application بسازه.
این موضوع مخصوصاً در Monorepoها و پروژههایی که چند Team روی اونها کار میکنن اهمیت بیشتری پیدا میکنه.
یکی از مشکلات localStorage اینه که همیشه String برمیگردونه.
برای همین معمولاً Codeهایی شبیه این داخل پروژهها زیاد میشن:
const basket = JSON.parse( localStorage.getItem("basket") ?? "[]", ) as BasketItem[];
این Type Assertion حس امنیت میده، ولی در Runtime هیچ چیزی رو Validate نمیکنه.
ممکنه Value توسط یک Deployment قدیمی نوشته شده باشه.
ممکنه Data ناقص یا Corrupt شده باشه.
ممکنه Feature دیگهای Data رو با Shape متفاوتی داخل همون Key نوشته باشه.
TypeScript در Runtime وجود نداره.
در نتیجه Error ممکنه خیلی دیرتر، داخل Componentای اتفاق بیفته که هیچ ارتباطی با Code اولیهی Write نداشته.
با namespaced-storage میتونیم Contract مربوط به Keyها رو از روی Default Valueها Infer کنیم:
export const basketStorage = createLocalStorage("basket", { defaults: { count: 0, items: [] as BasketItem[], }, }); basketStorage.get("count"); // number, not number | undefined basketStorage.set("count", 3); // valid basketStorage.set("count", "three"); // TypeScript error
برای Dataهایی که به Runtime Validation نیاز دارن هم یک Schema API سبک داخل Package وجود داره.
همچنین میشه از Standard Schema Validatorهایی مثل Zod، Valibot و ArkType استفاده کرد:
import { createSessionStorage, t } from "namespaced-storage"; export const authStorage = createSessionStorage("auth", { schema: { token: t.string(), }, }); authStorage.set("token", "jwt-value"); authStorage.set("token", 42); // TypeScript error + Runtime ValidationError
در این مدل، هر دو سمت Boundary محافظت میشن:
TypeScript جلوی Write اشتباه رو هنگام Development میگیره؛
Runtime Validation هم Data نامعتبر Persistشده رو پیدا میکنه.
Library علاوه بر JSON Valueهای معمولی، Typeهایی مثل Date، Map، Set، BigInt، RegExp، NaN و Infinity رو هم Round-trip میکنه:
basketStorage.set("updatedAt", new Date()); const updatedAt = basketStorage.get("updatedAt"); // a real Date instance
یعنی چیزی که از Storage میگیریم فقط یک String یا Object ظاهراً Typed نیست.
واقعاً همون Typeای رو برمیگردونه که ذخیره کردیم.
در یک Repository بزرگ، معماری معمولاً با یک تصمیم بزرگ خراب نمیشه.
کمکم Drift میکنه.
یک نفر در حال Fix کردن یک Bug فوریه.
یک نفر یک Example قدیمی رو Copy میکنه.
یک نفر هم چون پیدا کردن Abstraction اصلی زمان میبره، مستقیم سراغ window.localStorage میره.
شش ماه بعد، دوباره دو مدل متفاوت برای Storage داخل پروژه داریم.
برای جلوگیری از این اتفاق، eslint-plugin-namespaced-storage رو ساختم.
npm install --save-dev eslint-plugin-namespaced-storage
برای ESLint 9 و Flat Config:
// eslint.config.js import nss from "eslint-plugin-namespaced-storage"; export default [nss.configs.recommended];
این Plugin چهار Rule اصلی داره:
no-direct-storage جلوی دسترسی مستقیم به localStorage و sessionStorage رو میگیره؛
require-namespace-literal کمک میکنه Namespaceها بهشکل Static قابلپیدا کردن باشن؛
no-reserved-key از Metadata داخلی محافظت میکنه؛
storage-file-convention در حالت Strict، Declarationها رو داخل فایلهای *.storage.ts نگه میداره.
بهجای اینکه مشکل چند روز بعد در Code Review دیده بشه، Feedback همون جایی ظاهر میشه که اشتباه ایجاد شده:
Direct use of 'localStorage' is not allowed. Create a namespaced store instead: createLocalStorage('feature').
Ruleها Scope رو هم درک میکنن.
یعنی اگر یک Local Variable به اسم localStorage داشته باشیم، با Browser Global اشتباه گرفته نمیشه.
همچنین برای Adapterهایی که عمداً باید به API خام دسترسی داشته باشن، میشه Exception مشخص تعریف کرد.
Guardrail خوب باید بهاندازهای دقیق باشه که Team مجبور نشه برای ادامهی کار خاموشش کنه.
حتی با وجود Type-safe Storeها و ESLint Ruleها، هنوز یک سؤال مهم باقی میمونه:
در کل Repository چه چیزهایی داخل Browser ذخیره میشن؟
در یک پروژهی بزرگ دوست دارم بتونم سریع به این سؤالها جواب بدم:
چه Namespaceهایی وجود دارن؟
Owner هرکدوم کدوم Team یا Feature است؟
Data داخل localStorage قرار گرفته یا sessionStorage؟
چه Keyهایی ذخیره میشن؟
آیا دو Package یک Namespace یکسان تعریف کردن؟
آیا یک Pull Request سطح Persistence سیستم رو تغییر داده؟
برای پاسخ دادن به این سؤالها، @shayan-mirzaie/cli رو ساختم.
npm install --save-dev @shayan-mirzaie/cli npx nss scan
خروجی Scan میتونه چیزی شبیه این باشه:
namespaced-storage · 3 namespaces across 3 files auth session team-identity src/features/auth/auth.storage.ts basket local team-checkout src/features/basket/basket.storage.ts ui local team-platform src/shared/ui/ui.storage.ts
CLI سورس پروژه رو بدون Execute کردن Application میخونه.
حتی اگر پروژه Compile نشه یا Module Resolution کامل در دسترس نباشه، همچنان میتونه Namespaceها رو پیدا کنه.
در یک Monorepo، Duplicate Namespaceها بین Packageهای مختلف هم شناسایی میشن:
✖ duplicate namespace "cart" on localStorage src/features/basket/basket.storage.ts src/legacy/cart/store.ts
در این حالت Command با Exit Code برابر ۱ بسته میشه؛ بنابراین میتونیم همون Check رو داخل CI اجرا کنیم:
- run: npx nss scan
همچنین میشه یک Inventory قابلخواندن برای تیم تولید کرد:
npx nss docs -o docs/storage.md git diff --exit-code docs/storage.md
حالا اگر یک Pull Request شروع به Persist کردن Field جدیدی کنه، Ownership یک Namespace رو تغییر بده یا Storage جدیدی وارد سیستم کنه، این تغییر داخل Review قابل مشاهده است.
Storage دیگه یک رفتار Runtime نامرئی نیست.
تبدیل به بخشی از Architecture میشه که میتونیم ببینیم، بررسی کنیم و دربارهش تصمیم بگیریم.
برای استفاده از این ساختار لازم نیست تمام Codebase رو یکجا Migration کنیم.
میشه مرحلهبهمرحله جلو رفت:
۱. namespaced-storage رو نصب کنیم.
۲. یک Feature با Owner مشخص انتخاب کنیم.
۳. Read و Writeهای اون Feature رو به یک فایل *.storage.ts منتقل کنیم.
۴. برای Keyها Default Value یا Schema تعریف کنیم.
۵. Recommended Config مربوط به ESLint رو فعال کنیم.
۶. برای بخشهایی که هنوز Migration نشدن، موقتاً Legacy Access رو مجاز نگه داریم.
۷. دستور nss scan رو وارد CI کنیم.
۸. بهمرور که دسترسیهای مستقیم حذف میشن، Ruleها رو Strictتر کنیم.
ارزشمندترین قدم اول این نیست که تمام localStorageهای پروژه رو تبدیل کنیم.
قدم مهمتر اینه که یک Ownership Model مشخص بسازیم که Codeهای جدید مجبور باشن از اون پیروی کنن.
Namespacing یک Discipline معماریه، نه یک راهحل Security.
هر Scriptای که داخل Page اجرا بشه، همچنان میتونه به Browser Storage دسترسی داشته باشه.
اضافه شدن Prefix باعث نمیشه Sensitive Data امن بشه.
این Package جای Secure Server-side Persistence، Authorization یا Database رو نمیگیره.
همچنین namespaced-storage یک Global State Management Library نیست.
این ابزار Persistence Boundaryها رو مدیریت میکنه.
React State، Server State، URL State و Workflow State همچنان باید Owner درست خودشون رو داشته باشن.
سؤال اصلی این نیست:
چطور Data بیشتری داخل localStorage قرار بدیم؟
سؤال بهتر اینه:
وقتی Browser Persistence انتخاب درستی است، چطور اجازه ندیم تبدیل به یک Shared State نامرئی بشه؟
هر سه بخش پروژه Open Source هستن:
Core Package هیچ Runtime Dependencyای نداره.
خروجی ESM و CJS رو همراه با TypeScript Declaration ارائه میده و برای Vanilla TypeScript، React و Next.js با SSR هم Example داره.
خیلی دوست دارم تجربهی شما رو هم بدونم:
در پروژههای بزرگ، Browser Storage رو چطور مدیریت میکنید؟
برای Namespaceها و Ownership ابزار مشخص دارید؟
یا هنوز localStorage یک Global Namespace مشترکه که با Convention و دقت Engineerها کنار هم نگه داشته شده؟