jotai
默认用 localStorage;getItemsetItemremoveItem必须是同步的,否则 Jotai 的 hydration 会不对。用把「存字符串」的接口适配成 Jotai 需要的 storage。
Jotai 分析
一、核心概念
Jotai 是「原子化」的 React 状态库,思路是:
- Atom(原子):最小的状态单元,每个 atom 是一块独立状态。
- 自底向上组合:通过「读其他 atom → 派生新 atom」组合状态,而不是一个大 Store。
- 按需订阅:组件只订阅用到的 atom,只有这些 atom 变化时才会重渲染。
和 Redux 对比:没有单一 store、没有 reducer/action,写法更接近「用 React state,但状态可以跨组件共享」。
二、适用场景
| 场景 | 说明 |
|---|---|
| 全局/跨组件状态 | 主题、配置、用户信息、当前选中的聊天对象等 |
| 需要持久化 | 配置、联系人列表、草稿等要进 AsyncStorage/SQLite |
| 派生状态 | 由 contacts 算出未读数、由 config 算出主题等 |
| 非组件里读写状态 | 在工具函数、sync、widget 里用 getDefaultStore().get/set |
| 小型/中型应用 | 不需要 Redux 那种 action/reducer 时,用 Jotai 更轻量 |
你项目里:configAtom、contactsAtom、momentsAtom、unreadAtom、activeChatContactIdAtom 等,都是典型的「全局 + 部分持久化」用法。
三、优点
- API 简单:
atom+useAtom/useAtomValue/useSetAtom即可上手。 - 按 atom 更新:只订阅用到的 atom,避免整棵状态树导致的重渲染。
- TypeScript 友好:atom 即类型,推导自然。
- 易与持久化结合:
atomWithStorage可接 localStorage、自定义 storage(如你用的 SQLite KV)。 - 可在 React 外使用:
getDefaultStore().get/set在非组件代码里读写状态。 - 包体积小:比 Redux 小很多,适合 React Native/Expo。
四、基本经典用法
1. 基础 atom(内存状态)
import { atom } from 'jotai'
// 原始 atom:可读可写
const countAtom = atom(0)
const visibleAtom = atom(false)
// 在组件里
const [count, setCount] = useAtom(countAtom)
const [visible, setVisible] = useAtom(visibleAtom)
你项目里的 tabBarBadgeAtom、unreadAtom、chargePopupAtom、editModalAtom 等都是这种写法。
2. 只读 / 只写
const [config] = useAtom(configAtom) // 只读可省略 set
const setChargePopup = useSetAtom(chargePopupAtom) // 只要 setter
const config = useAtomValue(configAtom) // 明确只读
3. 持久化:atomWithStorage
import { atomWithStorage, createJSONStorage } from 'jotai/utils'
const storage = createJSONStorage(() => SyncStorage) // 自定义 storage
export const configAtom = atomWithStorage<Config>(
STORAGE_KEYS.CONFIG,
{ apiKey: '', model: '', ... },
storage
)
你项目里对 configAtom、contactsAtom、momentsAtom、loginTypeAtom 等都用 atomWithStorage + createStorage()(内部是 SQLite KV),实现配置、联系人、动态、登录方式等的持久化。
4. 在非 React 代码里读写
import { getDefaultStore } from 'jotai'
export const getContacts = () => getDefaultStore().get(contactsAtom)
// 在 utils、sync、widget 里
const store = getDefaultStore()
store.get(contactsAtom)
store.set(configAtom, newConfig)
utils/widget.ts、storage/restoreFromCloud.ts、utils/clearStorage.ts 里需要「在组件外」访问或重置状态时,就是用 getDefaultStore()。
五、特殊与重难点用法
1. 自定义 Storage(如 SQLite)
atomWithStorage 默认用 localStorage;在 RN/Expo 里你要换成同步 KV(如 expo-sqlite):
import { atom, getDefaultStore } from 'jotai'
import { atomWithStorage, createJSONStorage, RESET } from 'jotai/utils'
export const SyncStorage = {
getItem: (key: string) => Storage.getItemSync(key),
setItem: (key: string, value: string) => Storage.setItemSync(key, value),
removeItem: (key: string) => Storage.removeItemSync(key)
}
const createStorage = <T>() => createJSONStorage<T>(() => SyncStorage)
要点:
getItem/setItem/removeItem必须是同步的,否则 Jotai 的 hydration 会不对。- 用
createJSONStorage(() => SyncStorage)把「存字符串」的接口适配成 Jotai 需要的 storage。
2. RESET:恢复为初始值
对 atomWithStorage 的 atom,写入 RESET 会清空持久化并恢复为 atomWithStorage 的第二个参数(初始值):
import { RESET } from 'jotai/utils'
store.set(configAtom, RESET) // 配置恢复为默认
你项目里 resetAll() 和 restoreFromCloud.ts 里对「需要清空或恢复」的 storage atom 都用了 store.set(atom, RESET)。
3. 批量重置(logout/清空数据)
把「要恢复默认的 storage atoms」和「要清空的内存 atoms」分别列出来,一次遍历设置:
export function resetAll() {
const store = getDefaultStore()
STORAGE_ATOMS.forEach(atom => {
//@ts-ignore
store.set(atom, RESET)
})
Object.values(MEMORY_ATOMS_MAP).forEach(([atom, initial]) => {
//@ts-ignore
store.set(atom, initial)
})
setBindCidReadyEmitted(false)
}
要点:
- Storage 类用
RESET。 - 纯内存的用「各自初始值」的 map 统一 set,避免漏清。
4. 避免重复请求的「类 Query」模式
用 useAtom + 一个「正在请求的 atom 列表」防止同一 atom 被多个组件同时拉数:
const fetchingAtom: unknown[] = []
export default function useAtomWithQuery<AtomValue>(
atom: PrimitiveAtom<AtomValue>,
query: () => Promise<AtomValue>,
disableFetchOnMount?: boolean
) {
const [value, setValue] = useAtom(atom)
const appReady = useAtomValue(readyAtom)
const refetch = useCallback(async () => {
fetchingAtom.push(atom)
return query()
.then(res => {
setValue(res)
return res
})
.finally(() => {
fetchingAtom.splice(fetchingAtom.indexOf(atom), 1)
})
}, [atom, query, setValue])
useEffect(() => {
if (disableFetchOnMount) return
if (fetchingAtom.includes(atom)) {
return undefined
}
// ...
refetch()
}, [disableFetchOnMount, refetch, appReady, atom])
// ...
}
这里 atom 既当「服务端状态缓存」又当「请求去重 key」,是 Jotai 里做「简单服务端状态」的一种写法。
5. 只 set、不触发订阅
如果某个组件只需要「改状态」、不需要在值变化时重渲染,用 useSetAtom(atom),这样该组件不会因为该 atom 的值变化而重渲染。
6. 类型与默认值
- 用
atom<T>(initial)和atomWithStorage<T>(key, initial, storage)把 T 写清楚,方便全局类型安全。 - 持久化 atom 的初始值要和服务端/迁移逻辑一致(例如
hasSyncedContactToServerAtom从 SyncStorage 读字符串再转 boolean)。
7. 容易踩的坑
- 循环依赖:派生 atom 不要形成 A → B → A;你项目用
activityIdAtom等独立 atom 给useIosProducts读,就是为了避免循环依赖。 - 默认 Store:在 React 外用的都是
getDefaultStore(),不要自己createStore除非做多 Store 或测试。 - Storage 同步:自定义 storage 必须是同步 API,否则首屏或水合可能不对。
六、小结
| 维度 | 要点 |
|---|---|
| 概念 | 原子状态 + 自底向上组合 + 按需订阅 |
| 场景 | 全局/跨组件状态、持久化、派生状态、非 React 读写 |
| 优点 | API 简单、细粒度更新、TS 友好、体积小、易接自定义 storage |
| 经典用法 | atom + useAtom/useAtomValue/useSetAtom,atomWithStorage,getDefaultStore |
| 重难点 | 自定义同步 Storage、RESET、批量 reset、在非组件里用 store、避免循环依赖 |
你当前项目已经把「配置/联系人/动态持久化」「未读/弹窗/播放状态」「logout 全量重置」和「在 sync/widget 里读写状态」都做在了 Jotai 上,用法和上面这些点是对齐的。若你接下来想用「派生 atom」(例如从 contactsAtom 派生某个 contact 或未读汇总),可以再在那个基础上加 atom((get) => ...) 的写法。
5 个问题逐条说明。
1. getDefaultStore 和 Storage.getItemSync(key) 的区别与联系
| 维度 | getDefaultStore() | Storage.getItemSync(key) |
|---|---|---|
| 是什么 | Jotai 的「默认 Store」:保存所有 atom 的当前值、依赖、订阅 | 底层持久化 API:按 key 从 SQLite KV 读字符串 |
| 作用 | 在任意地方(包括非 React)对 atom 做 get/set,参与 Jotai 的响应式更新 |
只做持久化读写,和 React/Jotai 无关 |
| 读到的内容 | 当前内存里该 atom 的当前值(可能已被其他逻辑改过) | 磁盘里该 key 的原始字符串 |
| 典型用法 | getDefaultStore().get(contactsAtom)、store.set(atom, RESET) |
初始化时拿「真实持久化值」当 initialValue、或做迁移/兼容 |
联系:
- 对
atomWithStorage(key, initialValue, createStorage())来说,Jotai 内部会用你提供的 storage(最终调用到SyncStorage.getItem/setItem)做持久化。 getDefaultStore().get(isSignupAtom)拿到的是「当前 atom 的值」;SyncStorage.getItem(STORAGE_KEYS.IS_SIGNUP)拿到的是「此刻磁盘上的字符串」。两者可能一致,也可能不一致(例如刚 set 还没 flush、或你手动改了 storage)。
总结:getDefaultStore 管的是「Jotai 状态」;Storage.getItemSync 管的是「持久化层」。一个是状态管理,一个是存储后端。
2. 为什么 atomWithStorage 都要传 createStorage()?createJSONStorage(() => SyncStorage) 在做什么?
-
为什么不能省略
atomWithStorage(key, initialValue)不传第三个参数时,Jotai 用默认 storage:在浏览器里是localStorage,在 RN 里没有默认的持久化实现。你们跑在 RN/Expo,没有localStorage,所以必须传一个自定义的 storage 对象,告诉 Jotai「用我这个后端」存/取。 -
createJSONStorage(() => SyncStorage)在做什么- 入参:一个返回「类 localStorage 接口」的函数:
getItem(key)、setItem(key, value)、removeItem(key),且 value 是字符串。 - 作用:包一层,给 Jotai 提供「带 JSON 序列化」的 storage:
- 读:
getItem(key)得到字符串 →JSON.parse→ 再交给 atom。 - 写:atom 的值 →
JSON.stringify→ 再调用setItem(key, string)。
- 读:
- 这样你传的
SyncStorage只负责存字符串,复杂对象(Config、Contact[] 等)的序列化/反序列化由createJSONStorage统一做。
- 入参:一个返回「类 localStorage 接口」的函数:
所以:每个 atomWithStorage 都要传同一个 createStorage()(即 createJSONStorage(() => SyncStorage)),是在说「这个 atom 用 SQLite KV + JSON 来持久化」,而不是用默认的 localStorage。
3. 为什么会出现「先 false 再 true」?你这种写法为何能解决?能用 getDefaultStore 解决吗?
原因(官方文档行为)atomWithStorage 默认 getOnInit: false:
- 初始化时:直接用你传的
initialValue(例如false),不会在构造 atom 时去读 storage。 - 之后:在某个时机(例如 mount 后)再从 storage 读一次,得到
true,再 set 回 atom。
所以会出现:首帧是false→ 读到持久化后再变成true,依赖isSignupAtom的useEffect等就会在「从 false→true」时多执行一次。
你的写法为什么能解决
你在模块加载时就同步读了磁盘:
const defaultIsSignup = SyncStorage.getItem(STORAGE_KEYS.IS_SIGNUP) === 'true'
export const isSignupAtom = atomWithStorage<boolean>(
STORAGE_KEYS.IS_SIGNUP,
defaultIsSignup, // 第一次给 Jotai 的「初始值」已经是真实值
createStorage()
)
这样 Jotai 的「初始值」一开始就是磁盘上的值;之后即使再按 key 从 storage 读一次,结果也是 true,不会产生从 false 到 true 的变更,就不会误触发那部分逻辑。
能否用 getDefaultStore 解决
不能。getDefaultStore() 只是在「已经创建好的 atom 和 store」上做 get/set,不改变「atom 第一次被读时用 initialValue 还是用 storage」的时机。也就是说,它不能改变 atomWithStorage 默认「先用 initialValue,再异步/后续读 storage」的行为。
真正能改变行为的是:
- 要么像你现在这样:让 initialValue 就等于持久化值(模块加载时
SyncStorage.getItem); - 要么用下面这种官方推荐方式(见第 4 点)。
可选替代写法(与当前等价)
你们用的是同步 storage,可以用 Jotai 的 getOnInit: true,让 atom 在初始化时就从 storage 读一次,首帧就是持久化值,避免一次 false→true:
export const isSignupAtom = atomWithStorage<boolean>(
STORAGE_KEYS.IS_SIGNUP,
false, // 仅当 key 不存在时的 fallback
createStorage(),
{ getOnInit: true } // 初始化时就从 storage 读,首帧即正确
)

4. 本项目的持久化:不是 localStorage,是 expo-sqlite 的 KV;同步 API 的好处
- 本项目没有用 Web 的
localStorage。 - 用的是 expo-sqlite 的 KV(
Storage.getItemSync/setItemSync/removeItemSync),通过SyncStorage暴露给 Jotai。
用同步 API 的好处
- 不需要
await:getItem直接返回值,Jotai 在第一次读 atom 时就能立刻拿到持久化结果,配合getOnInit: true可以做到「首帧即正确」。 - 和
atomWithStorage的默认设计更契合:Jotai 的 storage 接口是同步的(getItem 返回 T),若用 AsyncStorage 那种异步 API,atom 的值会变成 Promise,要用await才能拿到最终值,首帧仍然可能是 initialValue,容易有你说的 false→true 问题。
所以用 expo-sqlite 的同步 KV,能避免「先展示默认值再闪成持久化值」和到处await的写法。
5. RESET 是什么?
RESET 是 Jotai 从 jotai/utils 导出的一个 Symbol,专门给 atomWithStorage 用的「特殊写入值」:
- 你执行:
store.set(someStorageAtom, RESET)(或setValue(RESET))。 - Jotai 会:
- 把该 atom 的内存值设回
atomWithStorage(key, initialValue, ...)里的initialValue; - 调用 storage 的
removeItem(key),把这条持久化删掉。
- 把该 atom 的内存值设回
所以 RESET = 「恢复为初始值 + 从持久化里删除」,常用于登出、清空缓存、恢复默认等。你项目里的 resetAll() 对各个 storage atom 做 store.set(atom, RESET),就是在做「把所有持久化状态重置为默认并清掉存储」。
小结
| 问题 | 简短结论 |
|---|---|
| 1. getDefaultStore vs Storage.getItemSync | getDefaultStore = Jotai 状态读写;getItemSync = 持久化层读写;两者职责不同。 |
| 2. createStorage / createJSONStorage | 必须传:RN 无默认 storage。createJSONStorage 负责 JSON 序列化,SyncStorage 负责字符串存 SQLite。 |
| 3. false→true 与 defaultIsSignup | 默认 getOnInit: false,首帧用 initialValue。你用 SyncStorage 提前读做 initialValue 可避免闪变;也可用 getOnInit: true(同步 storage 下等价)。getDefaultStore 不能解决首帧问题。 |
| 4. 本地持久化 | 是 expo-sqlite KV,不是 localStorage;同步 API 便于首帧正确、无需 await。 |
| 5. RESET | Symbol,表示「恢复为 initialValue 并 removeItem(key)」。 |
更多推荐


所有评论(0)