@skyroc/core-state

基于 Jotai 的状态管理封装 — 存储解耦、跨平台、支持 React 组件外访问

概述

@skyroc/core-state 是对 Jotai 的薄层封装,解决三个核心问题:

  • 存储解耦:通过 StorageRegistry 将持久化存储从原子定义中分离,应用层注册适配器,库代码按名称引用
  • 非 Hook 访问:暴露 globalStore 及辅助函数,支持在 axios 拦截器、事件处理器等非组件场景中读写原子
  • 部分更新atomWithPartial 封装 "合并补丁" 模式,内置无操作跳过(no-op skip),避免不必要的重渲染

架构

App Layer                          @core/state
─────────────────────────          ──────────────────────────────────
registerStorage('local', ...)  ──► StorageRegistry (Map<name, adapter>)
registerStorage('session', ...)    │

<JotaiProvider>                    globalStore (Jotai createStore)
  └─ <Provider store={globalStore}>

createAtomWithStorage(key, val) ─► 首次访问时 getStorage('local') ─► jotaiAtomWithStorage
atomWithPartial(initialValue)  ─► baseAtom + 派生读写原子(合并 + no-op 检测)

getAtomValue / setAtomValue    ─► globalStore.get / globalStore.set(React 外)

安装

pnpm add @skyroc/core-state jotai

Peer dependenciesjotai >= 2.0.0react >= 18.0.0

快速上手

1. 注册存储适配器(应用入口)

在应用入口(main.tsx 或初始化文件)完成注册,后续所有原子按名称引用。

import { registerStorage } from '@skyroc/core-state';
import { storage } from '@skyroc/storage'; // 或任意存储工具

// localStorage 适配器
registerStorage('local', {
  getItem: key => storage.get(key),
  setItem: (key, value) => storage.set(key, value),
  removeItem: key => storage.remove(key)
});

// sessionStorage 适配器(手动序列化示例)
registerStorage('session', {
  getItem: key => {
    const raw = sessionStorage.getItem(key);
    return raw ? JSON.parse(raw) : null;
  },
  setItem: (key, value) => sessionStorage.setItem(key, JSON.stringify(value)),
  removeItem: key => sessionStorage.removeItem(key)
});

2. 挂载 Provider

import { JotaiProvider } from '@skyroc/core-state';

const App = () => (
  <JotaiProvider>
    <Router />
  </JotaiProvider>
);

JotaiProvider 内部将 <Provider> 绑定到包级 globalStore,确保 React 组件内读写与组件外操作共享同一 store 实例。 组件内直接 useAtom(someAtom) 即可,不要逐个传 { store: globalStore }——那样会绕过 Provider,子树再也无法隔离到另一个 store。

该封装依赖 jotai 在打包产物里只有一份实例。pnpm 的 peer 解析分叉会给不同包各装一份 jotai, 两份 jotai 就是两个 React context,<JotaiProvider> 对另一份的 useAtom 不可见。 Vite 侧由 admin-viteresolve.dedupe 兜底,vitest 需要在各自 config 里镜像同一份 dedupe 列表。

3. 创建持久化原子

import { createAtomWithStorage } from '@skyroc/core-state';

// 默认使用 'local' 存储
const themeAtom = createAtomWithStorage('theme', { mode: 'light' });

// 使用 session 存储
const tabAtom = createAtomWithStorage('activeTab', 'home', { storageName: 'session' });

// 直传适配器(绕过 registry)
const customAtom = createAtomWithStorage('key', defaultVal, { storage: myAdapter });

存储在原子首次被读写时才解析,而不是 createAtomWithStorage() 调用时。原子通常是模块级常量, ESM 会在应用入口之前求值,若在创建时解析,每个持久化原子都会在 import 阶段抛错。 因此实际约束只有一条:registerStorage 早于该原子的第一次读写即可,应用入口天然满足。

4. 部分更新原子

import { atomWithPartial } from '@skyroc/core-state';

const uiAtom = atomWithPartial({ siderCollapse: false, mixSiderFixed: false });

// 在组件中使用
const [ui, setUi] = useAtom(uiAtom);

setUi({ siderCollapse: true }); // 只更新 siderCollapse
setUi(prev => ({ siderCollapse: !prev.siderCollapse })); // updater 函数形式
// 值未变时跳过更新,不触发重渲染
setUi({ siderCollapse: true }); // 若当前已是 true,无操作

5. React 组件外访问

import { getAtomValue, setAtomValue } from '@skyroc/core-state';

// axios 拦截器中读取 token
const token = getAtomValue(authAtom);

// 直接写入
setAtomValue(authAtom, newAuthState);

// 函数式更新(基于当前值)走同一个入口
setAtomValue(counterAtom, prev => prev + 1);

API

JotaiProvider

interface JotaiProviderProps {
  children?: ReactNode;
}

function JotaiProvider(props: JotaiProviderProps): JSX.Element;

将子树绑定到包级 globalStore,是组件外访问能力的前提。通常放在应用根节点。


globalStore

const globalStore: ReturnType<typeof createStore>;

Jotai store 实例,通过 JotaiProvider 注入到 React 组件树,同时供 getAtomValue / setAtomValue 等在组件外使用。

不适用于 SSR。 它是模块级单例,Node 服务端会跨请求共享同一个 store,A 用户的状态会漏进 B 用户的渲染。 服务端渲染需要每请求 createStore() 并传给 jotai 原生 <Provider>


getAtomValue

function getAtomValue<Value>(atom: Atom<Value>): Value;

在 React 组件外读取原子当前值。

const currentTheme = getAtomValue(themeAtom);

setAtomValue

function setAtomValue<Value, Args extends unknown[], Result>(
  atom: WritableAtom<Value, Args, Result>,
  ...args: Args
): Result;

在 React 组件外写入原子。泛型覆盖任意写签名的原子,包括 atomWithPartial(接受 PartialUpdater); 函数式更新也走同一个入口,无需单独的 API:

// 普通原子
setAtomValue(countAtom, 42);
setAtomValue(countAtom, prev => prev + 1);

// atomWithPartial — 同样通过 setAtomValue 传入部分补丁
setAtomValue(uiAtom, { siderCollapse: true });
setAtomValue(uiAtom, prev => ({ siderCollapse: !prev.siderCollapse }));

atomWithPartial

function atomWithPartial<T extends object>(initialValue: T): WritableAtom<T, [PartialUpdater<T>], void>;

type PartialUpdater<T extends object> = Partial<T> | ((prev: T) => Partial<T>);

创建支持部分更新的原子:

  • 合并语义:写入时将补丁 Object.assign 合并到现有状态
  • no-op 跳过:若补丁中所有字段通过 Object.is 与当前值相等,跳过写入、不触发订阅者
const adminStateAtom = atomWithPartial({
  siderCollapse: false,
  mixSiderFixed: false,
  theme: 'dark'
});

// 组件外
setAtomValue(adminStateAtom, { siderCollapse: true });

// 组件内
const [state, setState] = useAtom(adminStateAtom);
setState({ theme: 'light' }); // 其余字段保持不变

createAtomWithStorage

function createAtomWithStorage<T>(
  key: string,
  initialValue: T,
  options?: CreateAtomWithStorageOptions<T>
): WritableAtom<T, [StorageAtomUpdate<T>], void>;

创建持久化原子,委托 jotai/utilsatomWithStorage,并在上层处理惰性解析和容错:

参数:

参数类型默认值说明
keystring存储键名
initialValueT存储中无值时的初始值
options.storageNamestring'local'从 registry 解析的存储名称,storage 存在时忽略
options.storageAtomStorage直传适配器,绕过 registry
options.getOnInitbooleantrue是否在挂载前同步读取存储;SSR 场景可设为 false 避免 hydration 不一致
options.validate(raw: unknown) => T | undefined校验持久化数据;返回 undefined 表示拒绝并回退 initialValue

容错行为: 存储故障一律不外溢,每类故障只告警一次。

场景行为
getItem 返回 null / undefined使用 initialValue
storage 未注册回退 initialValue 并告警,不中断模块加载
getItem 抛出异常(数据损坏等)回退 initialValue 并告警
setItem 抛出异常(配额、无痕模式)原子正常更新,仅未持久化,告警
removeItem 抛出异常原子正常回到初始值,告警
同一 storage 下 key 重复绑定告警(两个原子会互相覆盖)

结构漂移: 适配器返回的是 unknown,默认直接当作 T 使用。localStorage 里可能躺着旧版本结构时用 validate 拒绝它:

const userAtom = createAtomWithStorage<User>('user', defaultUser, {
  validate: raw => (isUser(raw) ? raw : undefined)
});

跨标签页同步:

若适配器实现了 subscribe,外部存储变更(如 localStoragestorage 事件)会自动推送到原子:

registerStorage('local', {
  getItem: key => JSON.parse(localStorage.getItem(key) ?? 'null'),
  setItem: (key, value) => localStorage.setItem(key, JSON.stringify(value)),
  removeItem: key => localStorage.removeItem(key),
  subscribe: (key, callback) => {
    const handler = (e: StorageEvent) => {
      if (e.key === key) {
        callback(e.newValue ? JSON.parse(e.newValue) : null);
      }
    };
    window.addEventListener('storage', handler);
    return () => window.removeEventListener('storage', handler);
  }
});

registerStorage

function registerStorage(name: string, storage: AtomStorage): void;

注册命名存储适配器。同名重复注册会覆盖之前的适配器。


getStorage

function getStorage(name: string): AtomStorage;

按名称获取已注册的适配器。

抛出: Error — 若 name 未注册,错误信息格式为:

[core-state] Storage "xxx" is not registered. Call registerStorage("xxx", adapter) before use.

hasStorage

function hasStorage(name: string): boolean;

检查某个名称是否已注册,不抛出异常,适合条件注册场景:

if (!hasStorage('local')) {
  registerStorage('local', localStorageAdapter);
}

unregisterStorage

function unregisterStorage(name: string): boolean;

移除指定名称的注册。返回 true 表示该名称存在并已移除,false 表示名称不存在。


__clearStorageRegistry(仅测试)

function __clearStorageRegistry(): void;

清空全部注册。不从包入口导出——测试从 src/utils/storage-registry 直接引入,运行时不应有能力抹掉全部注册。

类型参考

AtomStorage

存储适配器接口:

interface AtomStorage {
  /** 读取值;键不存在时返回 null / undefined */
  getItem: (key: string) => unknown;
  /** 写入值 */
  setItem: (key: string, value: unknown) => void;
  /** 删除键 */
  removeItem: (key: string) => void;
  /**
   * 订阅外部变更(可选)
   * 适用于跨标签页同步等场景
   * 必须返回取消订阅函数
   */
  subscribe?: (key: string, callback: (value: unknown) => void, initialValue: unknown) => () => void;
}

序列化约定getItem 应返回已反序列化的值(非原始字符串),setItem 负责序列化后写入。这避免了将已解析对象再次传入 JSON.parse 的错误。

PartialUpdater

type PartialUpdater<T extends object> = Partial<T> | ((prev: T) => Partial<T>);

atomWithPartial 的写参数类型,支持直接传入补丁对象或基于当前值的 updater 函数。

CreateAtomWithStorageOptions

interface CreateAtomWithStorageOptions<T> {
  getOnInit?: boolean; // default: true
  storage?: AtomStorage;
  storageName?: string; // default: 'local'
  validate?: (raw: unknown) => T | undefined;
}

StorageAtomUpdate

type StorageAtomUpdate<T> = T | typeof RESET | ((prev: T) => T | typeof RESET);

持久化原子的写参数类型。传入 RESET(来自 jotai/utils)会删除持久化条目并回到初始值。

JotaiProviderProps

interface JotaiProviderProps {
  /** 需要绑定到全局 Jotai store 的 React 子树 */
  children?: ReactNode;
}

设计说明

为什么需要 StorageRegistry

直接在原子定义中引用 localStorage 会产生平台耦合,在测试、React Native 等环境中无法运行。Registry 模式让 @core/state 本身不依赖任何平台 API,适配器由应用层在入口注册。

为什么存储是惰性解析的

原子几乎总是模块级常量,而 ESM 的求值顺序是依赖先于入口——atoms.ts 会在 main.tsxregisterStorage 之前执行完。 若在 createAtomWithStorage() 里立刻解析适配器,每个持久化原子都会在 import 阶段抛 "Storage not registered", 注册表也就失去了意义。因此解析推迟到首次读写,jotai 内层原子也随之延迟创建(getOnInit 是在原子创建瞬间读存储的)。

globalStore 与 JotaiProvider 的关系

Jotai 默认使用隐式内部 store,globalStore 是显式创建的同一 store。JotaiProvider 将其注入 React 树,使组件内外操作读写同一份状态。如果不挂载 JotaiProvider,组件内的 useAtom 会使用 Jotai 默认隐式 store,与 getAtomValue / setAtomValue 的 globalStore 不同步。

atomWithPartial 的 no-op 跳过

写入时逐键检查补丁字段是否与当前值 Object.is 相等,若全部相等则不执行 set,不触发订阅者。这对于频繁调用但实际值很少变化的场景(如拖拽时节流更新 UI 状态)有明显收益。

测试

# 从 monorepo 根目录运行
npx vitest run packages/@core/state/__tests__

# 在包目录内运行
cd packages/@core/state && pnpm test

# 含覆盖率报告
pnpm test --coverage

测试套件覆盖以下场景:

模块覆盖场景
atomWithPartial初始值、部分更新、累积合并、no-op 引用相等、updater 函数形式
createAtomWithStorage初始值回退、读取已存在值、写入同步到 storage、updater 形式、直传适配器、模块顶层定义 + 事后注册、未注册回退与告警、removeItem/RESET、对象值不二次反序列化、subscribe 推送与退化、setItem/removeItem 抛异常、validate 接受与拒绝、重复 key 告警、getOnInit false
globalStoregetAtomValue / setAtomValue 基本操作、连续函数式调用累积、自定义写签名原子
storage-registry注册与获取、未注册报错、同名覆盖、多名称隔离、hasStorage、unregisterStorage、__clearStorageRegistry

Last updated on