基于 Jotai 的状态管理封装 — 存储解耦、跨平台、支持 React 组件外访问
@skyroc/core-state 是对 Jotai 的薄层封装,解决三个核心问题:
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 jotaiPeer dependencies:jotai >= 2.0.0,react >= 18.0.0
在应用入口(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)
});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-vite 的 resolve.dedupe 兜底,vitest 需要在各自 config 里镜像同一份 dedupe 列表。
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 早于该原子的第一次读写即可,应用入口天然满足。
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,无操作import { getAtomValue, setAtomValue } from '@skyroc/core-state';
// axios 拦截器中读取 token
const token = getAtomValue(authAtom);
// 直接写入
setAtomValue(authAtom, newAuthState);
// 函数式更新(基于当前值)走同一个入口
setAtomValue(counterAtom, prev => prev + 1);interface JotaiProviderProps {
children?: ReactNode;
}
function JotaiProvider(props: JotaiProviderProps): JSX.Element;将子树绑定到包级 globalStore,是组件外访问能力的前提。通常放在应用根节点。
const globalStore: ReturnType<typeof createStore>;Jotai store 实例,通过 JotaiProvider 注入到 React 组件树,同时供 getAtomValue / setAtomValue 等在组件外使用。
不适用于 SSR。 它是模块级单例,Node 服务端会跨请求共享同一个 store,A 用户的状态会漏进 B 用户的渲染。
服务端渲染需要每请求 createStore() 并传给 jotai 原生 <Provider>。
function getAtomValue<Value>(atom: Atom<Value>): Value;在 React 组件外读取原子当前值。
const currentTheme = getAtomValue(themeAtom);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 }));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 合并到现有状态Object.is 与当前值相等,跳过写入、不触发订阅者const adminStateAtom = atomWithPartial({
siderCollapse: false,
mixSiderFixed: false,
theme: 'dark'
});
// 组件外
setAtomValue(adminStateAtom, { siderCollapse: true });
// 组件内
const [state, setState] = useAtom(adminStateAtom);
setState({ theme: 'light' }); // 其余字段保持不变function createAtomWithStorage<T>(
key: string,
initialValue: T,
options?: CreateAtomWithStorageOptions<T>
): WritableAtom<T, [StorageAtomUpdate<T>], void>;创建持久化原子,委托 jotai/utils 的 atomWithStorage,并在上层处理惰性解析和容错:
参数:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
key | string | — | 存储键名 |
initialValue | T | — | 存储中无值时的初始值 |
options.storageName | string | 'local' | 从 registry 解析的存储名称,storage 存在时忽略 |
options.storage | AtomStorage | — | 直传适配器,绕过 registry |
options.getOnInit | boolean | true | 是否在挂载前同步读取存储;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,外部存储变更(如 localStorage 的 storage 事件)会自动推送到原子:
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);
}
});function registerStorage(name: string, storage: AtomStorage): void;注册命名存储适配器。同名重复注册会覆盖之前的适配器。
function getStorage(name: string): AtomStorage;按名称获取已注册的适配器。
抛出: Error — 若 name 未注册,错误信息格式为:
[core-state] Storage "xxx" is not registered. Call registerStorage("xxx", adapter) before use.function hasStorage(name: string): boolean;检查某个名称是否已注册,不抛出异常,适合条件注册场景:
if (!hasStorage('local')) {
registerStorage('local', localStorageAdapter);
}function unregisterStorage(name: string): boolean;移除指定名称的注册。返回 true 表示该名称存在并已移除,false 表示名称不存在。
__clearStorageRegistry(仅测试)function __clearStorageRegistry(): void;清空全部注册。不从包入口导出——测试从 src/utils/storage-registry 直接引入,运行时不应有能力抹掉全部注册。
存储适配器接口:
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 的错误。
type PartialUpdater<T extends object> = Partial<T> | ((prev: T) => Partial<T>);atomWithPartial 的写参数类型,支持直接传入补丁对象或基于当前值的 updater 函数。
interface CreateAtomWithStorageOptions<T> {
getOnInit?: boolean; // default: true
storage?: AtomStorage;
storageName?: string; // default: 'local'
validate?: (raw: unknown) => T | undefined;
}type StorageAtomUpdate<T> = T | typeof RESET | ((prev: T) => T | typeof RESET);持久化原子的写参数类型。传入 RESET(来自 jotai/utils)会删除持久化条目并回到初始值。
interface JotaiProviderProps {
/** 需要绑定到全局 Jotai store 的 React 子树 */
children?: ReactNode;
}直接在原子定义中引用 localStorage 会产生平台耦合,在测试、React Native 等环境中无法运行。Registry 模式让 @core/state 本身不依赖任何平台 API,适配器由应用层在入口注册。
原子几乎总是模块级常量,而 ESM 的求值顺序是依赖先于入口——atoms.ts 会在 main.tsx 的 registerStorage 之前执行完。
若在 createAtomWithStorage() 里立刻解析适配器,每个持久化原子都会在 import 阶段抛 "Storage not registered",
注册表也就失去了意义。因此解析推迟到首次读写,jotai 内层原子也随之延迟创建(getOnInit 是在原子创建瞬间读存储的)。
Jotai 默认使用隐式内部 store,globalStore 是显式创建的同一 store。JotaiProvider 将其注入 React 树,使组件内外操作读写同一份状态。如果不挂载 JotaiProvider,组件内的 useAtom 会使用 Jotai 默认隐式 store,与 getAtomValue / setAtomValue 的 globalStore 不同步。
写入时逐键检查补丁字段是否与当前值 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 |
globalStore | getAtomValue / setAtomValue 基本操作、连续函数式调用累积、自定义写签名原子 |
storage-registry | 注册与获取、未注册报错、同名覆盖、多名称隔离、hasStorage、unregisterStorage、__clearStorageRegistry |
Last updated on