跳转到内容

Eager

Jōtai Eager 提供了用于构建异步数据图的原语,能在值可用时立即处理(要么等待,要么同步处理)。


在 CodeSandbox 中打开

  • 当缓存的局部更新导致微短暂的 Suspense 挂起时
  • 当不必要的重新计算引发性能问题时
Terminal window
npm install jotai-eager

Jōtai 提供了强大的原语,用于在 Web 框架(如 React)之外处理异步数据,并允许 UI 和业务逻辑与数据层正确集成。许多数据获取方案通过原子提供对客户端缓存的访问。当缓存尚未填充时,原子必须解析为值的 Promise。然而,如果值已经存在于缓存中,且进行了乐观更新,则该值可以立即在下游可用。

以这些具有双重特性(有时异步、有时同步)的原子为基础构建数据图,如果处理不当,可能导致不必要的重新渲染、过时的值以及微短暂的 Suspense 挂起(在 React 中)。

假设有一个从 API 获取宠物名称的原子和一个过滤原子:

const petsAtom = atom<Promise<string[]>>(...);
const filterAtom = atom('cat');

使用普通原子创建过滤后的宠物原子,通常写法如下:

const filteredPetsAtom = atom(async (get) => {
const filter = get(filterAtom)
const pets = await get(petsAtom)
return pets.filter((name) => name.includes(filter))
}) // => Atom<Promise<string[]>>

filteredPetsAtom 始终返回 Promise,即使结果在 filterAtom 是唯一变更依赖时本可以同步计算出来。使用 jotai-eager 可以解决这个问题:

import { eagerAtom } from 'jotai-eager'
const filteredPetsAtom = eagerAtom((get) => {
const filter = get(filterAtom)
const pets = get(petsAtom) // ✨ 无需 await ✨
return pets.filter((name) => name.includes(filter))
}) // => Atom<Promise<string[]> | string[]>

现在,类型反映了该原子的 eager 行为。当 filterAtom 是唯一变更的依赖时,值为 string[];否则为 Promise<string[]>

当原子有多个异步依赖时,最好同时发起所有请求并等待结果,而不是顺序等待。在普通异步原子中,使用 Promise.all(...),而在 eager 原子中,使用 get.all() API:

const myMessages = eagerAtom((get) => {
const [user, messages] = get.all([userAtom, messagesAtom])
return messages.filter((msg) => msg.authorId === user.id)
}) // => Atom<Message[] | Promise<Message[]>>

可以在 eagerAtom 定义中使用 get.await API 等待普通 Promise,前提是确保每次调用原子读取函数时传入的 Promise 是同一个实例。

const statusAtom = eagerAtom((get) => {
const statusPromise = get(currentInvoiceAtom).getStatus() // => Promise<InvoiceStatus>
const status = get.await(statusPromise)
// ^? InvoiceStatus
return status
})

loadable API 将原子包装为统一的加载状态表示,在所有 jotai-eager API 之间共享 Promise 缓存,从而减少 Suspense 挂起次数。

import { atom } from 'jotai';
import { loadable } from 'jotai-eager';
const asyncAtom = atom(async () => 'data');
const loadableAtom = loadable(asyncAtom);
// 在组件中使用:
const state = useAtom(loadableAtom);
if (state.state === 'loading') return <div>Loading...</div>;
if (state.state === 'hasError') return <div>Error: {state.error}</div>;
return <div>{state.data}</div>;

withPending API 将原子包装为在值未解析时返回回退值,是 Jotai unwrap 的替代方案,提供了更完善的挂起状态管理。

import { atom } from 'jotai'
import { withPending } from 'jotai-eager'
const asyncAtom = atom(Promise.resolve('data'))
const wrappedAtom = withPending(asyncAtom, () => 'Loading...')
// 挂起时返回 'Loading...',解析后返回 'data'

Eager 原子内部通过抛出异常来挂起原子的计算,直到异步依赖完成(类似 React 的 Suspense 机制,但不依赖 React)。因此,在 eager 原子内使用异常处理时,需要额外调用 isEagerError 进行判断。

import { eagerAtom, isEagerError } from 'jotai-eager'
const fooAtom = eagerAtom((get) => {
try {
// ...
} catch (e) {
if (isEagerError(e)) {
// 重新抛出,交由 `jotai-eager` 处理
throw e
}
// ...
}
})

由于读取函数在等待的 Promise 完成后会”重试”,机制要求第二次调用 get.await 时传入同一个 Promise。如果 Promise 是在读取函数内部创建的,就永远无法满足这个要求,会导致无限循环。

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
// 该原子将陷入无限循环 :(
const deferredNumberAtom = eagerAtom((get) => {
get.await(sleep(1000)) // 等待一秒…
return 123
})

对于这种始终需要延迟的场景,使用 eagerAtom 相比普通异步原子没有优势。

对于更高级的用法,例如条件依赖,可以使用 soonsoonAll 函数在更细粒度上对数据执行同步/异步的 eager 转换。

import { soon } from 'jotai-eager'
// 已有如下定义:
// const queryAtom: Atom<RestrictedItem | Promise<RestrictedItem>>;
// const isAdminAtom: Atom<boolean | Promise<boolean>>;
// Atom<RestrictedItem | null | Promise<RestrictedItem | null>>
const restrictedItemAtom = atom((get) => {
const isAdmin = get(isAdminAtom)
return soon(isAdmin, (isAdmin) => (isAdmin ? get(queryAtom) : null))
})
import { soon, soonAll } from 'jotai-eager'
// 已有如下定义:
// const queryAtom: Atom<RestrictedItem | Promise<RestrictedItem>>;
// const isAdminAtom: Atom<boolean | Promise<boolean>>;
// const enabledAtom: Atom<boolean | Promise<boolean>>;
// Atom<RestrictedItem | null | Promise<RestrictedItem | null>>
const restrictedItemAtom = atom((get) => {
return soon(
soonAll(get(isAdminAtom), get(enabledAtom)),
([isAdmin, enabled]) => (isAdmin && enabled ? get(queryAtom) : null),
)
})