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 更轻量

你项目里:configAtomcontactsAtommomentsAtomunreadAtomactiveChatContactIdAtom 等,都是典型的「全局 + 部分持久化」用法。


三、优点

  1. API 简单atom + useAtom/useAtomValue/useSetAtom 即可上手。
  2. 按 atom 更新:只订阅用到的 atom,避免整棵状态树导致的重渲染。
  3. TypeScript 友好:atom 即类型,推导自然。
  4. 易与持久化结合atomWithStorage 可接 localStorage、自定义 storage(如你用的 SQLite KV)。
  5. 可在 React 外使用getDefaultStore().get/set 在非组件代码里读写状态。
  6. 包体积小:比 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)

你项目里的 tabBarBadgeAtomunreadAtomchargePopupAtomeditModalAtom 等都是这种写法。

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
)

你项目里对 configAtomcontactsAtommomentsAtomloginTypeAtom 等都用 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.tsstorage/restoreFromCloud.tsutils/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/useSetAtomatomWithStoragegetDefaultStore
重难点 自定义同步 Storage、RESET、批量 reset、在非组件里用 store、避免循环依赖

你当前项目已经把「配置/联系人/动态持久化」「未读/弹窗/播放状态」「logout 全量重置」和「在 sync/widget 里读写状态」都做在了 Jotai 上,用法和上面这些点是对齐的。若你接下来想用「派生 atom」(例如从 contactsAtom 派生某个 contact 或未读汇总),可以再在那个基础上加 atom((get) => ...) 的写法。


5 个问题逐条说明。


1. getDefaultStoreStorage.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 统一做。

所以:每个 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,依赖 isSignupAtomuseEffect 等就会在「从 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,不会产生从 falsetrue 的变更,就不会误触发那部分逻辑。

能否用 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 的 KVStorage.getItemSync / setItemSync / removeItemSync),通过 SyncStorage 暴露给 Jotai。

用同步 API 的好处

  • 不需要 awaitgetItem 直接返回值,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 会:
    1. 把该 atom 的内存值设回 atomWithStorage(key, initialValue, ...) 里的 initialValue
    2. 调用 storage 的 removeItem(key),把这条持久化删掉。

所以 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)」。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐