Cherry Studio 渲染进程 DataApi 完全指南:类型安全的 React 数据请求体系
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
本篇指南以 Cherry Studio 开源仓库的渲染进程数据层为对象,系统讲解 DataApi 系统在 React 组件中的完整使用方式:从useQuery、useMutation、useInfiniteQuery、usePaginatedQuery四大 Hook,到动态路径、refresh缓存失效模式、跨窗口数据变更通知、DataApiService直连与错误处理,并辅以仓库源码与测试用例作为实现依据。读完本文,你将能在 Cherry Studio 的 Electron 渲染进程中,以端到端类型安全的方式编写可缓存、可重试、可跨窗口收敛的业务数据请求代码。
背景:什么是渲染进程的 DataApi
DataApi 是 Cherry Studio 中面向 SQLite 业务数据的类型安全 IPC 通信体系。在渲染进程一侧,DataApiService(src/renderer/data/DataApiService.ts)扮演 API 客户端/网关角色:它向上层 React 组件提供 RESTful 风格的get/post/put/patch/delete接口,内部完成请求序列化、基于指数退避的自动重试、3 秒默认超时以及错误归一化,再通过 preload 桥接的window.api.dataApi.request走 IPC 到达主进程的 IpcAdapter → ApiServer → Handler → Service → SQLite 链路。
在此基础上,src/renderer/data/hooks/useDataApi.ts 基于 SWR 封装了一组 React Hook,把缓存、去重、失效、乐观更新、分页等复杂性收敛到组件外部。该模块的 JSDoc 明确列出了全部导出:useQuery、useMutation、useInfiniteQuery、usePaginatedQuery、useDataChange、useInvalidateCache、useReadCache、useWriteCache与prefetch。
由于 DataApi 走 IPC 而非 HTTP,且DataApiService已通过DataApiError.isRetryable实现单层重试,Hook 层的默认 SWR 配置刻意关闭了 HTTP 风格的能力(useDataApi.ts 中DEFAULT_SWR_OPTIONS):
const DEFAULT_SWR_OPTIONS = { revalidateOnFocus: false, // 焦点事件不意味着数据过期 revalidateOnReconnect: false, // IPC 没有“重连”语义 dedupingInterval: 5000, // 5 秒内去重重复请求 shouldRetryOnError: false, // 重试决策唯一交给 DataApiService keepPreviousData: true // 新 key 拉取期间保留旧数据,避免搜索/翻页闪烁 } as const理解这组默认值,是理解后续所有 Hook 行为的前提:组件通过isRefreshing(后台校验)与isLoading(无缓存的首屏加载)的区分来感知数据新鲜度。
React Hooks 概览与选型
useQuery:GET 请求
useQuery负责带缓存与自动校验的读取。它的基本形态、查询参数、路径参数推断、条件请求与手动刷新如下(源码签名见 useDataApi.ts 的useQuery定义):
import { useQuery } from '@data/hooks/useDataApi' // 基本用法 const { data, isLoading, error } = useQuery('/topics') // 带查询参数 const { data: messages } = useQuery('/messages', { query: { topicId: 'abc123', page: 1, limit: 20 } }) // 路径参数(从路径自动推断,如 /topics/abc123 返回 Topic) const { data: topic } = useQuery('/topics/abc123') // 条件请求:依赖未就绪时跳过 const { data } = useQuery('/topics', { enabled: !!topicId }) // 手动刷新 const { data, mutate, refetch } = useQuery('/topics') refetch() // 或 await mutate()useQuery的返回值类型定义在 UseQueryResult:data、isLoading(首屏加载)、isRefreshing(后台校验)、error、refetch与mutate(SWR 原生 mutator,可做乐观更新或手动缓存操作)。enabled: false时 Hook 将解析后的 key 置为null,SWR 即停止请求——这是依赖查询的标准做法。
useMutation:POST / PUT / PATCH / DELETE
useMutation承载写操作并暴露加载态:
import { useMutation } from '@data/hooks/useDataApi' // 创建(POST) const { trigger: createTopic, isLoading } = useMutation('POST', '/topics') const newTopic = await createTopic({ body: { name: 'New Topic' } }) // 全量替换(PUT) const { trigger: replaceTopic } = useMutation('PUT', '/topics/abc123') await replaceTopic({ body: { name: 'Updated Name', description: '...' } }) // 局部更新(PATCH) const { trigger: updateTopic } = useMutation('PATCH', '/topics/abc123') await updateTopic({ body: { name: 'New Name' } }) // 删除 const { trigger: deleteTopic } = useMutation('DELETE', '/topics/abc123') await deleteTopic() // 成功后自动刷新其他查询 const { trigger } = useMutation('POST', '/topics', { refresh: ['/topics'], // 成功后失效这些 key onSuccess: (data) => logger.info('Created:', data) })关于函数返回值的身份稳定性,这是该层的一条官方契约:trigger、invalidate、refetch、nextPage、prevPage、reset等在重渲染之间保持稳定身份,与 SWR 自身的mutate/trigger一致,仅当有意义的输入变化时(如nextPage在翻页可用性翻转时)才改变。useMutation的trigger通过 ref 读取其 options(源码中optionsRef+useEffect同步),因此内联的 options 对象永远不会搅动trigger的身份——这使你可以放心地把trigger直接放进useCallback/useEffect的依赖数组。
源码还给出了成功的副作用执行顺序(见 useMutation 的 @remarks):
- 服务端响应 resolve;
refreshkey 被失效——覆盖useQuery、usePaginatedQuery与useInfiniteQuery/useSWRInfinite(infinite 缓存会被显式枚举,因为 SWR 的 filter API 会跳过$inf$前缀的 key);onSuccess回调执行。此时回调里触碰的useQuery处于 “stale、pending revalidation” 状态——应避免在此处手动乐观mutate(...),以免与待执行的校验竞争;- 若设置了
optimisticData,被写入的缓存 key 会被重新校验。
refresh回调若抛出异常会被捕获并记录日志,不会导致trigger的 Promise 拒绝,也不会跳过onSuccess。
useInfiniteQuery:基于游标(Cursor)的无限滚动
useInfiniteQuery面向 “加载更多” 的无限滚动 UI。与常见的“自动拼接扁平数组”不同,该 Hook 暴露的是原始响应数组pages,由消费者用useInfiniteFlatItems派生出扁平列表,并显式选择与端点分页形状、容器布局相匹配的顺序:
import { useInfiniteQuery, useInfiniteFlatItems } from '@data/hooks/useDataApi' // 简单 feed:第 0 页最新、页内降序——页序即展示序 const { pages, hasNext, loadNext, isLoading } = useInfiniteQuery('/feed') const items = useInfiniteFlatItems(pages) // 聊天容器 column-reverse 中的分支遍历:第 0 页最新、页内升序。 // reverseItems: true 翻转每一页,使扁平输出最新在前,直接喂给反转布局。 const { pages, hasNext, loadNext } = useInfiniteQuery('/topics/:topicId/messages', { params: { topicId } }) const messages = useInfiniteFlatItems(pages, { reverseItems: true }) const activeNodeId = pages[0]?.activeNodeId ?? null // 顶层元数据,无需类型断言 // 非 column-reverse 容器中的时间升序渲染:翻转页序 const items = useInfiniteFlatItems(pages, { reversePages: true })useInfiniteFlatItems提供两个相互独立的开关(源码见 useInfiniteFlatItems):reversePages在扁平化前翻转页序,reverseItems在扁平化前翻转页内条目。其输出引用在pages与开关稳定时保持稳定(内部为useMemo),配合useInfiniteQuery稳定化的pages(useMemo包裹swrResult.data)可避免下游重渲染。
实现层面的关键点(源码依据 useInfiniteQuery):
- 编译期约束:路径泛型经由
CursorPaginatedPath限制——offset 分页的路径在编译期被拒绝; getKey以previousPageData.nextCursor是否缺失作为终止条件,cursor/limit由 Hook 内部管理,limit默认 10;pages在 SWR 底层数据未变时跨重渲染保持引用稳定;hasNext由最后一页是否存在nextCursor判定;loadNext通过setSize(s => s + 1)加载下一页,快速双击由 SWR 的dedupingInterval去重;- 顶级响应元数据(如
BranchMessagesResponse的activeNodeId/rootId/assistantId)以完整精度保留在pages[0]上,无需类型断言。
usePaginatedQuery:基于偏移(Offset)的分页导航
usePaginatedQuery面向带上一页/下一页控件的页式导航,同样在编译期拒绝游标分页路径:
import { usePaginatedQuery } from '@data/hooks/useDataApi' const { items, page, total, hasNext, hasPrev, nextPage, prevPage } = usePaginatedQuery('/topics', { limit: 10 }) // items: 当前页条目(只读——排序/修改前请先复制) // page/total: 当前页码(1 基)与总数 // nextPage()/prevPage(): 翻页实现要点(源码见 usePaginatedQuery):
- 内部用
useState(1)管理currentPage,通过unstable_serialize计算查询 key,当查询内容变化时自动重置回第 1 页——key 重排如{a,b}vs{b,a}不会触发误重置; page/limit由 Hook 内部追加进 query,limit默认 10;nextPage/prevPage以hasNext/hasPrev为门控并做 useCallback 记忆化,只在翻页可用性真正翻转时改变身份;items在数据未就绪时指向冻结的空数组常量EMPTY_ITEMS(Object.freeze([])),避免空态下items身份抖动;- 完整返回还包括
isLoading、isRefreshing、error、refresh、reset。
分页 Hook 选型
| 使用场景 | 选用 Hook |
|---|---|
| 无限滚动、聊天、feed | useInfiniteQuery |
| 页式导航、表格 | usePaginatedQuery |
| 手动控制 | useQuery |
每个分页 Hook 都把其路径泛型约束到对应的分页形态:把游标路径传给usePaginatedQuery、或把 offset 路径传给useInfiniteQuery,是编译期错误而非静默的运行时挂起。其类型机制位于 CursorPaginatedPath / OffsetPaginatedPath:二者基于InferPaginationMode判别——先检查 offset 形态以打破可选nextCursor字段造成的结构兼容歧义;当路径不在ApiSchemas中导致ResponseForPath回退为any时,InferPaginationMode<any>为never,该守卫同样拒绝此路径。因此使用分页 Hook 时务必让 TypeScript 从路径字面量推断TPath,显式注入泛型可能绕过守卫。
完整的偏移 vs 游标分页模型(何时选择哪种、线上契约、服务端实现)见 数据分页指南。其核心结论:一个端点要么是 offset 要么是 cursor,在 schema 中一次性声明、不可由调用方配置——offset 用于需要精确
total的页式 UI(助手、MCP 服务器),cursor(keyset)用于无界增长或新数据写入频繁的列表(消息、会话、翻译/绘画历史),cursor 响应也可额外携带total(知识库、文件)。
动态路径:具体路径 vs 模板路径
Hook 接受两种路径形式:具体路径(id 已内联,如/providers/abc123)或模板路径(带:placeholders,配合独立params选项):
// 具体路径——当 id 在调用方稳定时使用(props、hook 参数等) const { data } = useQuery(`/providers/${providerId}`) const { data } = useQuery(providerPath(providerId)) // 等价字符串的辅助函数 // 模板路径——当同一个 hook 实例要随时间操作不同 id 时使用 // (侧边栏列表、命令面板、URL 处理器、循环内的行级操作) const { data } = useQuery('/providers/:providerId', { params: { providerId } }) const { trigger } = useMutation('DELETE', '/providers/:providerId/api-keys/:keyId', { refresh: ({ args }) => [ `/providers/${args.params.providerId}`, `/providers/${args.params.providerId}/api-keys` ] }) await trigger({ params: { providerId, keyId } })两种形式产生逐字节完全一致的 SWR 缓存 key,因此用一种形式读取、用另一种形式刷新依然保持一致。这由两条实现保证:
resolveTemplate(src/renderer/data/utils/dataApiPath.ts)把模板渲染成具体路径:贪婪占位符:name*允许值内含/;前导斜杠锚定使models:resolve这类动词风格后缀不被破坏;缺少必需占位符时抛Missing param ...。buildSWRKey(useDataApi.ts 内部工具)在 query 非空时生成[path, query]元组,空时生成[path]。
该不变量在 useDataApi.test.ts 的buildSWRKey cache-key equivalence用例 中有直接断言:resolveTemplate('/providers/:providerId', { providerId: 'abc' })与字面量/providers/abc产生相等的 key——测试注释明确指出“这里的漂移会导致极难排查的幽灵刷新漏失”。
何时用哪种形式
| 场景 | 形式 |
|---|---|
<ProviderSettings providerId={id}>(props 传入稳定 id) | 具体路径 |
| 侧边栏 “删除任意 provider” 操作 | 模板路径 |
| 命令面板 / URL 处理器操作任意 id | 模板路径 |
.map()内的行操作——每行一个 hook | 具体路径 |
注意:模板useMutation上的并发 trigger
useSWRMutation以路径为 key 记录isMutating/error状态,因此单个模板路径的useMutation实例会跨所有 params 共享加载状态。从同一个 hook 实例并发触发不同 id 会混淆它们的状态:
// ❌ 错误:isMutating 是共享的;第二个 trigger 会覆盖第一个 const { trigger, isLoading } = useMutation('DELETE', '/providers/:providerId') await Promise.all([ trigger({ params: { providerId: 'a' } }), trigger({ params: { providerId: 'b' } }) ]) // ✅ 推荐:每行挂载一个 hook,绑定具体路径 function ProviderRow({ id }) { const { trigger, isLoading } = useMutation('DELETE', providerPath(id)) return <button onClick={() => trigger()} disabled={isLoading}>Delete</button> }在开发模式下,带变化 params 的并发 trigger 会打印警告。源码实现(useMutation 内部)用inFlightParamsRef同步记录在飞 params——之所以不用 SWR 的isMutating,是因为 React 状态更新落后于渲染,Promise.all([trigger(a), trigger(b)])这类同步突发会让两个闭包都读到过期的isMutating === false,警告将永远不会触发;而 ref 在 trigger 入口同步更新。
Refresh Patterns:三种缓存失效形式
refresh声明一次成功的 mutation 之后要失效哪些 SWR 缓存 key。支持三种形式,按需选择最精确的一种。
静态路径(精确匹配)
useMutation('POST', '/topics', { refresh: ['/topics'] })只失效['/topics']。适用于确切知道受影响的路径、且这些路径不依赖 mutation 的入参或出参的场景。
/*后缀(前缀匹配)
// 失效 /providers、/providers/abc、/providers/abc/api-keys、/providers/abc/api-keys/k1…… useMutation('DELETE', '/providers/:providerId', { refresh: ({ args }) => ['/providers', `/providers/${args.params.providerId}/*`] })前缀末尾的斜杠(自动保留)防止/providers-archived这类同名兄弟资源的误伤。实现上,createKeyMatcher对/*模式切掉*、保留尾部/,用key[0].startsWith(prefix)匹配(useDataApi.ts 内部工具),测试用例明确断言/providers-archived与/providers-archived/xyz都不会命中/providers/*(见 createKeyMatcher 测试)。
/*的独特价值在于失效mutation 不知道 id 的子路径实例——例如在组件树别处订阅的useQuery('/providers/abc/api-keys/keyId-001')条目,函数形式的枚举无法命名这些 key。
函数形式(动态 key)
// 失效 key 依赖 trigger 入参 useMutation('DELETE', '/messages/:messageId', { refresh: ({ args }) => [`/topics/${args.body.topicId}/tree`] }) // 失效 key 依赖服务端响应 useMutation('POST', '/messages', { refresh: ({ result }) => [`/topics/${result.topicId}/messages`, `/messages/${result.parentId}`] })回调上下文类型为 RefreshContext:args(本次trigger的入参)与result(mutation 的服务端响应)。适用于 key 集合只有到调用时刻才知道(id 来自 args/result)的场景。
形式选择
| 需求 | 形式 |
|---|---|
| 静态、已知 key | 数组 |
| 失效某资源的所有子路径 | 数组中的/*前缀 |
| 失效由 args / result 计算出的 key | 函数 |
| 两者兼要:扇出 + 精确 | 返回精确与/*混合的函数 |
需要避免的误用
- 不要把
/*当作全缓存重置。['/*']或/m*这类短前缀在开发模式下会抛错。永远写完整的路径段(assertValidPattern的强制规则见 useDataApi.ts,测试覆盖见 dev-mode pattern assertions)。 - 静态数组够用时不要用函数形式。额外运行时开销且掩盖意图。
- 不要对高基数列表使用
/*(如/messages/*)。它会重新校验所有窗口中每个消息级查询。应改用带特定父 id 的函数形式(/topics/${id}/messages)。 - 同一模块内不要混用模板路径与辅助函数。缓存 key 虽相同,但代码评审变难。每个模块只选一种形式。
refresh只用于 DataApi 的 key。非 SQLite 数据(Cache、Preference)有自己的失效机制。
值得一提的实现细节:由于 SWR 的 filter API 会跳过$inf$前缀的 infinite key,invalidatePathPatterns(useDataApi.ts 内部工具)做了双层扇出——先用createMultiKeyMatcher走 filter 通道处理数组 key,再通过findMatchingInfiniteKeys+extractInfinitePath显式枚举并逐个mutate无限滚动 key(路径从'$inf$@"<path>",...'形态中按未转义引号边界安全解析,用户参数含"也不受影响)。
数据变更通知(Data Change Notifications)
refresh只覆盖本窗口自己的 mutation。对于其他窗口或主进程后台路径发起的写入,主进程在每次提交的写入后广播DataApiDataChangeEffect[];消费者按端点订阅,并自行决定收敛方式(重新校验 / 重建 / 忽略):
import { useDataChange } from '@data/hooks/useDataApi' // 保守的列表收敛:任何信号 → refetch const { refetch } = useQuery('/topics') useDataChange('/topics', () => refetch()) // 多端点:每个通知合并为一次回调 useDataChange(['/topics', '/topics/latest'], () => refreshAll()) // 按 id 表面:用 entityIds 过滤(缺席 = 无声明 → 视为相关) useDataChange('/topics/:id', (effects) => { if (effects.some((e) => !e.entityIds || e.entityIds.includes(myId))) mutate() }) // 非 React 代码:同一设施走 service(返回取消订阅函数) const unsubscribe = dataApiService.onDataChanged('/topics', (effects) => { ... })语义由 Phase A 契约冻结,具体如下:
- 端点精确匹配——没有前缀/通配符订阅;effect 由
endpoint+ 可选kind(projection/membership/order)+dimension+entityIds构成。 - 一个业务操作 = 一次回调:一个通知内所有匹配条目合并为单次调用送达;通知之间不做聚合。
- 端点之下的一切都是消费者策略:dimension/entityIds 过滤、收敛选择、以及对自身写入回波的幂等性(发起窗口同样会收到自己的信号)。
- 提示只做收窄:省略
dimension/entityIds意味着“无声明——假定相关”,绝不意味着“无影响”。 - 尽力送达:只送达活跃且持续订阅的渲染进程(每窗口 FIFO)。消费者订阅注册之前(含主进程启动期间)已提交的变更不会被信号化;恢复手段是端点的下一次变更、重新挂载或任意一次新查询。
useDataChange的源码实现(src/renderer/data/hooks/useDataChange.ts)通过 ref 持有最新 listener 与routeParams,按endpoints的稳定 key(join('\0'))建立订阅;传入routeParams时会对 effect 的routeParams做字段级匹配过滤。底层设施是DataApiService.onDataChanged(DataApiService.ts):它以Map<endpoint, Set<listener>>管理订阅(该 map 是设施的唯一状态),每个注册包一层唯一 wrapper 保证同 listener 多次注册相互独立;dispatchDataChange按精确端点匹配把同一通知的命中条目合并成单批回调,且单消费者抛错被隔离、不阻塞其他监听者。
DataApiService 直接使用:非 React 代码的入口
对非 React 代码或需要更多控制的场景,直接使用dataApiService单例(src/renderer/data/DataApiService.ts):
import { dataApiService } from '@data/DataApiService' // GET 请求 const topics = await dataApiService.get('/topics') const topic = await dataApiService.get('/topics/abc123') const messages = await dataApiService.get('/topics/abc123/messages', { query: { page: 1, limit: 20 } }) // POST 请求 const newTopic = await dataApiService.post('/topics', { body: { name: 'New Topic' } }) // PUT 请求(全量替换) const updatedTopic = await dataApiService.put('/topics/abc123', { body: { name: 'Updated', description: 'Full update' } }) // PATCH 请求(局部更新) const patchedTopic = await dataApiService.patch('/topics/abc123', { body: { name: 'Just update name' } }) // DELETE 请求 await dataApiService.delete('/topics/abc123')该服务的重试机制位于sendRequest(DataApiService.ts):
- 默认重试配置为
maxRetries: 2、retryDelay: 1000(毫秒)、backoffMultiplier: 2,可通过configureRetry覆盖; - 重试决策由
DataApiError.isRetryable驱动,延迟按retryDelay * backoffMultiplier^retryCount指数退避,重试会生成新 requestId; - 请求走
Promise.race与 3 秒超时竞争,超时抛出ErrorCode.TIMEOUT; - 客户端错误(4xx)不参与重试。
错误处理
使用 Hook
function TopicList() { const { data, isLoading, error } = useQuery('/topics') if (isLoading) return <Loading /> if (error) { if (error.code === ErrorCode.NOT_FOUND) { return <NotFound /> } return <Error message={error.message} /> } return <List items={data} /> }使用 try-catch
import { DataApiError, ErrorCode } from '@shared/data/api/errors' try { await dataApiService.post('/topics', { body: data }) } catch (error) { if (error instanceof DataApiError) { switch (error.code) { case ErrorCode.VALIDATION_ERROR: // 处理校验错误 const fieldErrors = error.details?.fieldErrors break case ErrorCode.NOT_FOUND: // 处理未找到 break case ErrorCode.CONFLICT: // 处理冲突 break default: // 处理其他错误 } } }可重试错误
if (error instanceof DataApiError && error.isRetryable) { // 可以安全重试:SERVICE_UNAVAILABLE、TIMEOUT 等 await retry(operation) }isRetryable是一个基于RETRYABLE_ERROR_CODES集合的 getter(src/shared/data/api/errors.ts):SERVICE_UNAVAILABLE(503)、TIMEOUT(504)、RATE_LIMIT_EXCEEDED(429)、DATABASE_ERROR(500)、INTERNAL_SERVER_ERROR(500)与RESOURCE_LOCKED(423)被视为可能随重试成功的临时失败。DataApiError还具备跨 IPC 的序列化能力(toJSON/fromJSON),渲染进程从主进程拿回的是反序列化重建的完整错误对象。
常见模式
创建表单
function CreateTopicForm() { // 用 refresh 选项在创建后自动刷新 /topics const { trigger: createTopic, isLoading } = useMutation('POST', '/topics', { refresh: ['/topics'] }) const handleSubmit = async (data: CreateTopicDto) => { try { await createTopic({ body: data }) toast.success('Topic created') } catch (error) { toast.error('Failed to create topic') } } return ( <form onSubmit={handleSubmit}> {/* form fields */} <button disabled={isLoading}> {isLoading ? 'Creating...' : 'Create'} </button> </form> ) }乐观更新
function TopicItem({ topic }: { topic: Topic }) { // 用 optimisticData 实现自动乐观更新与失败回滚 const { trigger: updateTopic } = useMutation('PATCH', `/topics/${topic.id}`, { optimisticData: { ...topic, starred: !topic.starred } }) const handleToggleStar = async () => { try { await updateTopic({ body: { starred: !topic.starred } }) } catch (error) { // 设置 optimisticData 后回滚自动发生 toast.error('Failed to update') } } return ( <div> <span>{topic.name}</span> <button onClick={handleToggleStar}> {topic.starred ? '★' : '☆'} </button> </div> ) }乐观更新的实现路径(useMutation 内部):trigger先以globalMutate([resolvedPath], optimisticData, false)立即写入缓存(第三个参数false表示覆盖值且跳过校验),成功后对同一 key 再globalMutate重新校验以对齐服务端真相,失败则重新校验完成自动回滚。trigger中执行的refresh以闭包捕获本次调用的 args/result,因此并发触发不会互相污染刷新上下文。
依赖查询
function MessageList({ topicId }: { topicId: string }) { // 第一个查询:获取 topic const { data: topic } = useQuery(`/topics/${topicId}`) // 第二个查询:依赖第一个(topic 存在时才执行) const { data: messages } = useQuery( topic ? `/topics/${topicId}/messages` : null ) if (!topic) return <Loading /> return ( <div> <h1>{topic.name}</h1> <MessageList messages={messages} /> </div> ) }轮询更新
function LiveTopicList() { const { data } = useQuery('/topics', { refreshInterval: 5000 // 每 5 秒轮询一次 }) return <List items={data} /> }类型安全
整个 API 基于 schema 定义完全类型化——类型从ApiSchemas派生,客户端调用、主进程 handler 与响应形状全程编译期校验:
// 类型从 schema 推断 const { data } = useQuery('/topics') // data 的类型为 PaginatedResponse<Topic> const { trigger } = useMutation('POST', '/topics') // trigger 期望 { body: CreateTopicDto } // 返回 Topic // 路径参数经过类型检查 const { data: topic } = useQuery('/topics/abc123') // TypeScript 知道这里返回 Topic路径参数的类型系统由 src/shared/data/api/types.ts 的ConcreteApiPaths与 src/shared/data/api/paths.ts 的BodyForPath/QueryParamsForPath/ResponseForPath/ParamsForPath/TemplateApiPaths支撑。ParamsOption(useDataApi.ts)在类型层区分模板路径(params必填)与具体路径(params禁止);TriggerArgs在此基础上叠加可选的body与query。
进阶工具:缓存读写的单一受控出口
除四大核心 Hook 外,useDataApi.ts 还提供三个面向缓存操控的官方工具,值得在编写复杂交互时优先采用:
useInvalidateCache:手动失效并触发重新校验。支持invalidate('/topics')(精确)、invalidate(['/topics', '/providers/*'])(多模式混合)与invalidate(true)(全部失效);路径形式同时覆盖普通数组 key 与useSWRInfinitekey。useReadCache:非响应式快照读取——调用不订阅、不触发重渲染,适合在回调/乐观更新 reducer 中做一次性读取。这是代码库中唯一获准触碰 SWR 内部unstable_serialize与原始 cache API 的地方,任何其他需要非响应式读缓存的 hook 都必须经由它,从而把不稳定表面限制在单文件内。useWriteCache:向 GET key 写入值且不触发校验(等价于mutate(key, value, false)),是 DataApi 层乐观覆盖的规范形式;useReorder及未来的乐观覆盖 hook 都经由它,而不是直接碰useSWRConfig。prefetch:在用户交互前预热缓存,例如onMouseEnter={() => prefetch('/topics/abc')};模板路径 + params 会生成与useQuery完全一致的缓存 key,后续useQuery立即命中缓存。
最佳实践清单
- 组件优先用 Hook:
useQuery与useMutation已处理加载/错误状态; - 选对分页 Hook:无限滚动用
useInfiniteQuery,页式导航用usePaginatedQuery; - 用
useInfiniteFlatItems派生扁平项:按端点分页形态与容器布局显式选择reversePages/reverseItems——永远不要假设“页面加载顺序”等于“条目展示顺序”; - 处理加载状态:数据加载期间始终给出反馈(区分
isLoading与isRefreshing); - 优雅处理错误:为用户提供有意义的错误信息;
- mutation 后重新校验:用
refresh选项保持 UI 同步; - 使用条件请求:依赖未就绪时设
enabled: false跳过查询; - 批量相关操作:考虑用事务(主进程侧
DbService.withWriteTx)处理多次更新; - 返回函数是依赖安全的:把
trigger、invalidate、refetch等直接放进useCallback/useEffect依赖数组——绝不要为了规避身份抖动而把它们重新包进 ref 或从依赖中省略。
延伸阅读
- DataApi 系统总览——渲染进程与主进程的完整架构分层、适用边界与“非数据副作用硬规则”;
- DataApi 主进程实现——服务端 Handler → Service → SQLite 的落地模式;
- 数据分页指南——偏移 vs 游标的权威规范、线上契约与服务端 codec;
- API 设计指南——RESTful 约定与查询参数线上格式;
- API 类型系统——分页类型、守卫与
Infer*辅助类型; - useDataApi 测试 与 DataApiService 测试——缓存 key 等价、模式断言、变更通知扇出等契约的实测验证。
【免费下载链接】cherry-studio🍒 Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考