Dune Tools Dune Tools Collection
Tools

createStorage

用于创建和管理本地存储(localStorage 或 sessionStorage)的工具

createStorage

createStorage 是一个用于创建和管理本地存储(localStorage 或 sessionStorage)的工具。它提供了类型安全的存储访问、跨标签页数据同步、以及 React Hooks 支持等特性。

特性

  • 🔒 类型安全: 完整的 TypeScript 类型支持
  • 🔄 跨标签页同步: 自动同步不同标签页之间的数据变化
  • 性能优化: 内置缓存机制,避免不必要的重新渲染
  • 🎣 React Hooks 支持: 提供 useValue Hook 订阅数据变化
  • 📦 命名空间隔离: 通过命名空间避免不同模块间的存储冲突
  • 🛡️ 数据一致性: 通过深度比较确保数据变化时才触发更新

基本用法

导入

import { createStorage } from "@dune2/tools";

创建存储实例

// 定义数据结构
class DataMap {
  token = "";
  userInfo = { name: "", age: 0 };
  theme = "light";
  preferences = {
    language: "zh-CN",
    fontSize: 14,
    notifications: true,
  };
}

// 创建存储实例
const storage = createStorage({
  DataMap,
  namespace: "myApp",
  storageType: "local", // 可选,默认为 "local"
});

基础操作

// 获取值
const token = storage.token.get(); // 获取 token,若不存在返回 ""

// 设置值
storage.token.set("new_token"); // 设置新的 token

// 删除值
storage.token.remove(); // 删除 token
storage.token.set(undefined); // 等同于 remove()

// 对象类型的存储
const userInfo = storage.userInfo.get();
storage.userInfo.set({ name: "张三", age: 25 });

// 获取存储键名
console.log(storage.token.key); // "myApp.token"

在 React 组件中使用

function ThemeSwitch() {
  const theme = storage.theme.useValue(); // 自动订阅值的变化

  const toggleTheme = () => {
    storage.theme.set(theme === "light" ? "dark" : "light");
  };

  return <button onClick={toggleTheme}>当前主题:{theme}</button>;
}

function UserProfile() {
  const userInfo = storage.userInfo.useValue();
  const token = storage.token.useValue();

  if (!token) {
    return <div>请先登录</div>;
  }

  return (
    <div>
      <h1>欢迎,{userInfo.name}</h1>
      <p>年龄:{userInfo.age}</p>
    </div>
  );
}

配置选项

CreateStorageConfig

interface CreateStorageConfig<T> {
  DataMap: new () => T;
  namespace: string;
  storageType?: "local" | "session";
}

参数说明

  • DataMap: 用于生成存储映射的类,其属性值将作为对应存储项的默认值
  • namespace: 命名空间,用于隔离不同模块的存储,会作为存储键名的前缀
  • storageType: 存储类型
    • "local": 使用 localStorage(默认值)
    • "session": 使用 sessionStorage

StorageHelper API

每个存储项都是一个 StorageHelper 实例,提供以下方法和属性:

方法

get(): T | undefined

获取存储值,若值不存在则返回默认值。

const theme = storage.theme.get(); // 返回 "light" 或存储的值

set(value: T | undefined): void

设置存储值,传入 undefined 时会删除该存储项。

storage.theme.set("dark"); // 设置主题为深色
storage.theme.set(undefined); // 删除主题设置

remove(): void

删除存储项(等同于 set(undefined))。

storage.token.remove(); // 删除 token

useValue(): T

React Hook,用于在组件中订阅存储值的变化。

function MyComponent() {
  const theme = storage.theme.useValue();
  // 当其他地方修改 theme 时,组件会自动重新渲染
  return <div className={theme}>内容</div>;
}

subscribe(listener: () => void): () => void

订阅存储值的变化。

const unsubscribe = storage.theme.subscribe(() => {
  console.log("主题已更改为:", storage.theme.get());
});

// 取消订阅
unsubscribe();

属性

key: string

完整的存储键名(包含命名空间)。

console.log(storage.token.key); // "myApp.token"

defaultValue: T

存储项的默认值。

console.log(storage.theme.defaultValue); // "light"

高级特性

缓存机制

为了优化性能和避免不必要的更新,内部实现了值缓存机制:

class DataMap {
  config = { theme: "light", fontSize: 14 };
}

const storage = createStorage({ DataMap, namespace: "app" });

// 1. 防止重复渲染
function ConfigPanel() {
  const config = storage.config.useValue();
  // config 的引用在值未变化时保持不变,不会导致不必要的重复渲染
  return <div>主题:{config.theme}</div>;
}

// 2. 智能更新
const currentConfig = storage.config.get();
storage.config.set({ ...currentConfig }); // 值未实际变化,不会触发更新事件

跨标签页同步

当在一个标签页中修改存储值时,其他标签页会自动同步更新:

// 标签页 A
function PageA() {
  const theme = storage.theme.useValue();
  return (
    <div>
      <div>当前主题:{theme}</div>
      <button onClick={() => storage.theme.set("dark")}>切换到深色</button>
    </div>
  );
}

// 标签页 B - 会自动同步更新
function PageB() {
  const theme = storage.theme.useValue();
  // 当标签页 A 中点击按钮时,这里会自动更新
  return <div>主题已更新为:{theme}</div>;
}

类型安全

通过 TypeScript 的类型推导,可以获得完整的类型提示和检查:

class DataMap {
  count: number = 0;
  user: { name: string; age: number } | null = null;
  settings: {
    theme: "light" | "dark";
    language: string;
  } = {
    theme: "light",
    language: "zh-CN",
  };
}

const storage = createStorage({ DataMap, namespace: "app" });

// ✅ 正确的使用
storage.count.set(42);
storage.user.set({ name: "张三", age: 25 });
storage.settings.set({ theme: "dark", language: "en-US" });

// ❌ 类型错误
storage.count.set("123"); // 错误:类型不匹配
storage.user.set({ name: "张三" }); // 错误:缺少必需属性 'age'
storage.settings.set({ theme: "blue" }); // 错误:theme 值不在联合类型中

实际应用示例

1. 用户认证状态管理

class AuthDataMap {
  token = "";
  user = null as { id: string; name: string; email: string } | null;
  isLoggedIn = false;
  lastLoginTime = 0;
}

const authStorage = createStorage({
  DataMap: AuthDataMap,
  namespace: "auth",
  storageType: "local",
});

// 认证服务
class AuthService {
  static login(token: string, user: AuthDataMap["user"]) {
    authStorage.token.set(token);
    authStorage.user.set(user);
    authStorage.isLoggedIn.set(true);
    authStorage.lastLoginTime.set(Date.now());
  }

  static logout() {
    authStorage.token.remove();
    authStorage.user.remove();
    authStorage.isLoggedIn.set(false);
    authStorage.lastLoginTime.remove();
  }

  static isAuthenticated(): boolean {
    return authStorage.isLoggedIn.get();
  }
}

// 使用在组件中
function LoginStatus() {
  const isLoggedIn = authStorage.isLoggedIn.useValue();
  const user = authStorage.user.useValue();

  if (isLoggedIn && user) {
    return (
      <div>
        <span>欢迎,{user.name}</span>
        <button onClick={() => AuthService.logout()}>退出</button>
      </div>
    );
  }

  return (
    <button
      onClick={() => {
        /* 打开登录弹窗 */
      }}
    >
      登录
    </button>
  );
}

2. 应用设置管理

class SettingsDataMap {
  theme = "light" as "light" | "dark";
  language = "zh-CN";
  fontSize = 14;
  notifications = {
    email: true,
    push: true,
    sms: false,
  };
  sidebar = {
    collapsed: false,
    width: 240,
  };
}

const settingsStorage = createStorage({
  DataMap: SettingsDataMap,
  namespace: "settings",
});

// 设置管理器
class SettingsManager {
  static updateTheme(theme: "light" | "dark") {
    settingsStorage.theme.set(theme);
    document.documentElement.setAttribute("data-theme", theme);
  }

  static updateNotifications(
    type: keyof SettingsDataMap["notifications"],
    enabled: boolean,
  ) {
    const current = settingsStorage.notifications.get();
    settingsStorage.notifications.set({
      ...current,
      [type]: enabled,
    });
  }

  static resetToDefault() {
    const defaultSettings = new SettingsDataMap();
    Object.keys(defaultSettings).forEach((key) => {
      settingsStorage[key].set(defaultSettings[key]);
    });
  }
}

// 设置面板组件
function SettingsPanel() {
  const theme = settingsStorage.theme.useValue();
  const language = settingsStorage.language.useValue();
  const fontSize = settingsStorage.fontSize.useValue();
  const notifications = settingsStorage.notifications.useValue();

  return (
    <div>
      <h2>应用设置</h2>

      <div>
        <label>主题:</label>
        <select
          value={theme}
          onChange={(e) =>
            SettingsManager.updateTheme(e.target.value as "light" | "dark")
          }
        >
          <option value="light">浅色</option>
          <option value="dark">深色</option>
        </select>
      </div>

      <div>
        <label>语言:</label>
        <select
          value={language}
          onChange={(e) => settingsStorage.language.set(e.target.value)}
        >
          <option value="zh-CN">中文</option>
          <option value="en-US">English</option>
        </select>
      </div>

      <div>
        <label>字体大小:</label>
        <input
          type="number"
          value={fontSize}
          onChange={(e) => settingsStorage.fontSize.set(Number(e.target.value))}
        />
      </div>

      <div>
        <h3>通知设置</h3>
        <label>
          <input
            type="checkbox"
            checked={notifications.email}
            onChange={(e) =>
              SettingsManager.updateNotifications("email", e.target.checked)
            }
          />
          邮件通知
        </label>
        <label>
          <input
            type="checkbox"
            checked={notifications.push}
            onChange={(e) =>
              SettingsManager.updateNotifications("push", e.target.checked)
            }
          />
          推送通知
        </label>
      </div>

      <button onClick={() => SettingsManager.resetToDefault()}>
        重置为默认设置
      </button>
    </div>
  );
}

3. 购物车管理

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
  image: string;
}

class CartDataMap {
  items: CartItem[] = [];
  total = 0;
  itemCount = 0;
  lastUpdated = 0;
}

const cartStorage = createStorage({
  DataMap: CartDataMap,
  namespace: "cart",
});

class CartManager {
  static addItem(item: Omit<CartItem, "quantity">, quantity = 1) {
    const items = cartStorage.items.get();
    const existingItem = items.find((i) => i.id === item.id);

    if (existingItem) {
      existingItem.quantity += quantity;
    } else {
      items.push({ ...item, quantity });
    }

    this.updateCart(items);
  }

  static removeItem(itemId: string) {
    const items = cartStorage.items.get().filter((item) => item.id !== itemId);
    this.updateCart(items);
  }

  static updateQuantity(itemId: string, quantity: number) {
    const items = cartStorage.items.get();
    const item = items.find((i) => i.id === itemId);

    if (item) {
      if (quantity <= 0) {
        this.removeItem(itemId);
      } else {
        item.quantity = quantity;
        this.updateCart(items);
      }
    }
  }

  static clear() {
    cartStorage.items.set([]);
    cartStorage.total.set(0);
    cartStorage.itemCount.set(0);
    cartStorage.lastUpdated.set(Date.now());
  }

  private static updateCart(items: CartItem[]) {
    const total = items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    );
    const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);

    cartStorage.items.set(items);
    cartStorage.total.set(total);
    cartStorage.itemCount.set(itemCount);
    cartStorage.lastUpdated.set(Date.now());
  }
}

// 购物车组件
function CartButton() {
  const itemCount = cartStorage.itemCount.useValue();

  return <button>购物车 ({itemCount})</button>;
}

function CartSummary() {
  const items = cartStorage.items.useValue();
  const total = cartStorage.total.useValue();

  return (
    <div>
      <h3>购物车</h3>
      {items.map((item) => (
        <div key={item.id}>
          <span>{item.name}</span>
          <span>
            ¥{item.price} × {item.quantity}
          </span>
          <button
            onClick={() =>
              CartManager.updateQuantity(item.id, item.quantity - 1)
            }
          >
            -
          </button>
          <button
            onClick={() =>
              CartManager.updateQuantity(item.id, item.quantity + 1)
            }
          >
            +
          </button>
          <button onClick={() => CartManager.removeItem(item.id)}>删除</button>
        </div>
      ))}
      <div>总计:¥{total}</div>
      <button onClick={() => CartManager.clear()}>清空购物车</button>
    </div>
  );
}

最佳实践

1. 合理设计数据结构

// ✅ 好的做法 - 扁平化结构
class AppDataMap {
  theme = "light";
  userId = "";
  userName = "";
  userEmail = "";
  notificationEnabled = true;
  sidebarCollapsed = false;
}

// ❌ 避免的做法 - 过度嵌套
class AppDataMap {
  user = {
    profile: {
      basic: {
        name: "",
        email: "",
      },
      settings: {
        notifications: {
          email: true,
          push: true,
        },
      },
    },
  };
}

2. 使用命名空间隔离

// 用户相关存储
const userStorage = createStorage({
  DataMap: UserDataMap,
  namespace: "user",
});

// 应用设置存储
const settingsStorage = createStorage({
  DataMap: SettingsDataMap,
  namespace: "settings",
});

// 缓存数据存储(使用 sessionStorage)
const cacheStorage = createStorage({
  DataMap: CacheDataMap,
  namespace: "cache",
  storageType: "session",
});

注意事项

  1. 存储限制: localStorage 通常有 5-10MB 的存储限制,请合理使用存储空间。

  2. 数据序列化: 复杂对象会被序列化为 JSON,确保数据可以被正确序列化和反序列化。

  3. 浏览器支持: 确保目标浏览器支持 localStorage 和 sessionStorage。

  4. 数据迁移: 当数据结构发生变化时,需要考虑向后兼容性。

  5. 隐私模式: 在浏览器隐私模式下,存储功能可能受限。

  6. 性能考虑: 避免频繁的大量数据存储操作,合理使用缓存机制。

createStorage 提供了一个强大而灵活的本地存储解决方案,特别适合需要跨组件共享状态且希望数据持久化的应用场景。