ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Sim 项目 React Query 数据层工程规范实战:Key Factory、边界隔离与服务端预取

Sim 项目 React Query 数据层工程规范实战:Key Factory、边界隔离与服务端预取 Sim 项目 React Query 数据层工程规范实战Key Factory、边界隔离与服务端预取【免费下载链接】simSim is the collaborative workspace to build, deploy, and monitor AI agents and workflows. Used by 100,000 builders.项目地址: https://gitcode.com/GitHub_Trending/sim16/sim导读Sim 是一个用于构建、部署与监控 AI Agent 与工作流的协作平台当前仓库 apps/sim 即其主应用。在这样一个前后端同仓、包含海量服务端渲染页面与 6000 工具定义的大型 Next.js 应用中所有服务端数据必须且只能经由 TanStack QueryReact Query v5管理——这是apps/sim/hooks/queries/**目录的硬性红线。本文以该目录的工程规范文档为核心系统拆解其 Query Key Factory 设计、signal转发、staleTime常量、乐观更新、客户端/服务端边界隔离与服务端预取Server Prefetch六大模式并结合folder-keys.ts、folders.ts、optimistic-mutation.ts等真实实现与 check-react-query-patterns.ts 静态检查脚本帮助你理解这套可被 CI 自动强制的数据层规范并可直接迁移到自己的项目。一、规范范围与数据流分工apps/sim/hooks/queries/AGENTS.md 本身是一份极简的“范围声明”本目录适用 React Query 规则具体模式细节key factory、signal转发、staleTime、乐观更新、失效策略统一沉淀在 .claude/rules/sim-queries.md。这份规则文件对apps/sim/hooks/queries/**/*.ts全量生效并定义了 Sim 前端的数据流铁律所有服务端状态必须走 React Query组件中严禁用useStatefetch做数据获取或变更。它与另一类状态做了清晰的分工状态类型归属方案适用场景远程服务端数据remote dataReact Queryhooks/queries/列表、详情、组织、订阅、凭证、工作流等所有/api/**数据可分享的客户端视图状态nuqs URL query params标签页、筛选、搜索、分页、当前选中实体 id规则文件明确写道React Query owns remote data; nuqs owns shareable client view-state.详见 .claude/rules/sim-url-state.md。这套分工避免了“把服务器状态塞进组件局部 state”导致的跨组件不同步问题。二、Query Key Factory分层键工厂与防碰撞2.1 标准形态Sim 规定每个查询文件必须定义一个分层键工厂包含all根键与中间的复数键用于“前缀级失效”prefix-level invalidationexport const entityKeys { all: [entity] as const, lists: () [...entityKeys.all, list] as const, list: (workspaceId?: string) [...entityKeys.lists(), workspaceId ?? ] as const, details: () [...entityKeys.all, detail] as const, detail: (id?: string) [...entityKeys.details(), id ?? ] as const, }严禁使用内联 query key 字面量如queryKey: [entity, workspaceId]必须一律走工厂。all根键的存在是硬性要求——lint 规则key-factory-no-root会检查hooks/queries/**中每个*Keys工厂是否暴露all根键。2.2 真实案例folderKeys 与 tableKeys仓库中的 folder-keys.ts 展示了比模板更复杂的键设计——它把resourceType编入键中export const folderKeys { all: [folders] as const, lists: () [...folderKeys.all, list] as const, resource: (resourceType: FolderResourceType workflow) [...folderKeys.lists(), resourceType] as const, list: ( workspaceId: string | undefined, scope: FolderQueryScope active, resourceType: FolderResourceType workflow ) [...folderKeys.resource(resourceType), workspaceId ?? , scope] as const, }文件注释解释了为什么resourceType必须是键的一部分而非隐含默认值一个 workspace 内每种资源类型Workflow / Table / Knowledge Base都拥有一棵相互独立的文件夹树若不加区分三棵树的列表会共享同一个缓存条目并互相覆盖folder-keys.ts。table-keys.ts 则展示了面向复杂实体的键分层技巧rowsRoot(tableId)作为共享父前缀其下再挂infinite无限滚动分页、sample图表单页读取、find查找等子键——注释特别强调find挂在rowsRoot下但持有不同的数据形态所以任何遍历行页缓存的逻辑必须从infiniteRowsRoot开始viewsRoot故意不放在detail下因为非精确匹配的invalidateQueries会命中detail前缀行写入、schema 变更、重命名、任务事件都会触发若把 views 挂进去几乎每次表变更都会导致 views 列表重新请求从而击穿其 60s 的staleTimetable-keys.ts。2.3 key-fetch-arg-drift防“跨租户缓存碰撞”这是 Sim 数据层最重要的安全规则之一queryFn转发进 fetch 的每一个标识符identifier都必须出现在queryKey中。signal、pageParam这类查询机制参数豁免——它们不参与 fetch 作用域。但如果 fetch 受workspaceId、cursor、limit、org id 等作用域约束这些值必须进键否则不同的 fetch 参数会共享同一个缓存条目造成跨租户/按参数的缓存碰撞。唯一的例外是用一个全局唯一 id 作为键而另一个 fetch 参数只是不可能碰撞的授权作用域authz scope此时需用// rq-lint-allow: reason注释显式豁免。该规则由 check-react-query-patterns.ts 中的key-fetch-arg-drift检查强制实施保守起见只检查裸 camelCase 标识符忽略requestJson契约参数、常量与signal/pageParam机制参数。三、客户端/服务端边界use client 隔离3.1 问题根源Next.js 会把use client模块的每一个导出在服务端 bundle 中重写为client reference。因此服务端求值的代码——RSC 的page.tsx/layout.tsx、prefetch.ts、路由处理器、block 定义、triggers/workers——只能把这些导出当组件渲染或作为 prop 传递一旦调用就会在运行时抛错如Attempted to call X from the server but X is on the client对象导出则表现为X.list is not a function。而next build不会捕获这类错误只有 SSR/运行时才会暴露。3.2 强制目录划分因此任何被服务端模块导入的query-key 工厂、独立requestJsonfetcher、mapper 或常量必须放在非use client模块中key factories →hooks/queries/utils/entity-keys.ts实例folder-keys.ts、table-keys.ts、credential-keys.ts独立 fetcher/mapper →hooks/queries/utils/fetch-*.ts/*-list-query.ts实例fetch-workflow-envelope.ts、fetch-workspace-credentials.ts。然后由use client的 hook 模块再把这些原语 import 回来给 hooks 用。绝不允许在use client的 hooks 文件里直接定义会被服务端导入的工厂/fetcher——规则文档明确指出这曾导致 tables 页面崩溃this caused the tables-page crash。fetch-workflow-envelope.ts 的注释还解释了这种隔离的另一个动机它作为workflowKeys.state(id)缓存条目的唯一数据源被 registry store 通过fetchQuery与useWorkflowState/useWorkflowStates共同消费——放在独立 util 中可避免产生 store ↔ query-hook 的循环导入。对prefetch/route/trigger/block 文件该边界由 check-client-boundary-imports.ts 在 CIbun run check:client-boundary强制检查确属纯浏览器路径时可用行上方的// client-boundary-allow: reason逃生。四、Query Hook 标准写法4.1 文件结构规则文件规定了hooks/queries/**内每个文件的固定结构Query keys factory键工厂Types如需Private fetch functions接受signal参数Exported hooks导出的 hooks4.2 五条硬性要求每个queryFn必须解构并转发signal实现请求取消lintqueryfn-no-signal每个 query 必须有显式staleTime且取自命名导出常量如ENTITY_LIST_STALE_TIME严禁内联数字字面量lintstale-time-literal0除外——它是“总是重新请求”的哨兵值服务端prefetch.ts水合同一 key 时必须 import 并复用该常量而不是重述数字否则预取缓存条目的新鲜度会与读取它的客户端 hook 失同步keepPreviousData只用于可变键查询参数会变化的查询静态键查询禁用同源 JSON 调用必须走requestJson(contract, ...)来自/lib/api/client/request契约定义在/lib/api/contracts/**键必须引用同目录的键工厂而非内联字面量lintinline-query-key。规范给出的标准模板import { requestJson } from /lib/api/client/request import { listEntitiesContract, type EntityList } from /lib/api/contracts/entities export const ENTITY_LIST_STALE_TIME 60 * 1000 async function fetchEntities(workspaceId: string, signal?: AbortSignal): PromiseEntityList { const data await requestJson(listEntitiesContract, { query: { workspaceId }, signal, }) return data.entities } export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) { return useQuery({ queryKey: entityKeys.list(workspaceId), queryFn: ({ signal }) fetchEntities(workspaceId as string, signal), enabled: Boolean(workspaceId) (options?.enabled ?? true), staleTime: ENTITY_LIST_STALE_TIME, placeholderData: keepPreviousData, // OK: workspaceId varies }) }4.3 真实实现useFoldersfolders.ts 中的useFolders完全遵循该模板并展示了 mapper 的用法私有fetchFolders通过requestJson(listFoldersContract, ...)请求随后用mapFolder位于 folder-keys.ts把线上的字符串日期映射为客户端Date。mapFolder特意显式列出每个字段而非展开spread这样新增的线上字段不会静默进入缓存形态、导致水合条目与客户端 fetch 结果不一致。4.4 requestJson 契约客户端requestJsonrequest.ts是这套体系的地基它先对params/query/body/headers做可选 schema 解析契约各字段来自 Zod schema再拼接 URL、以contract.method发起fetch并透传input.signal响应非 2xx 时抛出携带状态码、错误消息与契约校验摘要的ApiClientError。这解释了为什么 hooks 层不再需要手写fetch——类型安全、请求取消与错误规范化全部内聚于此。五、Mutation Hook失效策略与乐观更新5.1 失效三原则优先定向失效entityKeys.lists()不要图省事用宽泛的entityKeys.all失效必须覆盖所有受影响的键前缀lists、details、相关视图普通 mutation 用onSuccess失效乐观 mutation 用onSettled——onSettled在成功与失败时都会触发确保缓存始终与服务器调和reconciled。规范给出的创建模板export function useCreateEntity() { const queryClient useQueryClient() return useMutation({ mutationFn: (body: CreateEntityBody) requestJson(createEntityContract, { body }), onSuccess: () { queryClient.invalidateQueries({ queryKey: entityKeys.lists() }) }, }) }5.2 乐观更新标准模板规范推荐onSettled调和 快照回滚的完整模板.claude/rules/sim-queries.md 的 Optimistic Updates 小节export function useUpdateEntity() { const queryClient useQueryClient() return useMutation({ mutationFn: async (variables) { /* ... */ }, onMutate: async (variables) { await queryClient.cancelQueries({ queryKey: entityKeys.detail(variables.id) }) const previous queryClient.getQueryData(entityKeys.detail(variables.id)) queryClient.setQueryData(entityKeys.detail(variables.id), /* optimistic value */) return { previous } }, onError: (_err, variables, context) { queryClient.setQueryData(entityKeys.detail(variables.id), context?.previous) }, onSettled: (_data, _error, variables) { queryClient.invalidateQueries({ queryKey: entityKeys.lists() }) queryClient.invalidateQueries({ queryKey: entityKeys.detail(variables.id) }) }, }) }5.3 与 Zustand 同步的工厂createOptimisticMutationHandlers对于需要与 Zustand store 同步的乐观变更Sim 封装了createOptimisticMutationHandlersoptimistic-mutation.ts。它以配置对象驱动四个阶段onMutatecancelQueries取消在途请求 →getSnapshot快照 →generateTempId生成临时 id →applyOptimisticUpdate写入乐观条目返回{ tempId, previousState }onSuccessreplaceOptimisticEntry用服务器数据替换临时条目并调用可选的onSuccessExtraonError依据previousState执行rollbackonSettled对getQueryKey(variables)定向invalidateQueries。临时 id 用generateId()而非时间戳生成${prefix}-${generateId()}注释指出原因同一毫秒内创建两条记录时时间戳会碰撞碰撞会让replaceOptimisticEntry把两条条目用同一行服务器数据覆盖optimistic-mutation.ts。useCreateFolder是该工厂的完整应用范例它把乐观文件夹插入缓存列表、用getTopInsertionSortOrder计算插入位次并仅在resourceType workflow时参考工作流排序其他资源树按文件夹行排序避免把无关排序空间引入知识库/表格树造成闪烁见 folders.ts。useDeleteFolderMutation则在onSettled里调用invalidateCascadedResourceLists按资源类型级联失效被删除子树波及的资源列表folders.ts——这正是“失效必须覆盖所有受影响键前缀”的复杂化体现。六、useCallback 依赖陷阱规则文件明确警告绝不要把 mutation 对象如createEntity放进useCallback依赖数组。mutation 对象不是引用稳定的referentially stable每次状态更新都会变化而 TanStack Query v5 中.mutate()/.mutateAsync()是稳定的。// ✗ Bad — causes unnecessary recreations const handler useCallback(() { createEntity.mutate(data) }, [createEntity]) // unstable reference // ✓ Good — omit from deps, mutate is stable const handler useCallback(() { createEntity.mutate(data) // eslint-disable-next-line react-hooks/exhaustive-deps }, [data])七、服务端预取Server Prefetch五条铁律服务端预取填充的必须是客户端 hook 会填充的同一个缓存键因此它必须与客户端 fetch“无法区分”。.claude/rules/sim-queries.md 给出五条规则读数据层绝不通过 HTTP 调自己的 API服务端到服务端的/api/...调用会多一次网络往返和二次认证。若路由运行某个应用用例use case预取应直接调用同一 use case并使用与路由声明的同一认证策略的 principal而不是其底层的 manager。匹配 hook 缓存的线上数据形态wire shapehook 的数据是requestJson(contract, ...)的产物种子数据必须与之相等。两个陷阱契约字段声明为z.coerce.date()时 hook 持有Date而裸路由 JSON 是字符串透传响应 schemaz.custom意味着 hook 原样缓存路由 JSON用原始行播种会泄漏Date与服务端专属字段。当路由在响应前做投影projection时应让路由与预取调用同一个投影函数。证明查看者身份prove the viewer数据层读取不携带授权授权原本由路由提供。预取需解析 viewergetWorkspaceHostContextForViewer已被 layoutcache化、零成本失败则提前返回且不缓存任何内容——这样客户端 fetch 才会走到路由拿到真正的 403。绝不能扩大 viewer 可见范围。必须await只有 settled 的查询才会被脱水dehydrate未 await 的预取会被静默丢弃页面仍然瀑布式请求。不要重复 layout 已播种的数据getQueryClient()每次服务端调用都会新建 client页面重复播种 layout 键是真实的一次二次读取且HydrationBoundary会把已见过的查询推迟到 effect 执行而 SSR 从不运行 effect所以它也永远不会进入服务端渲染。配套细节预取应复用 hook 导出的staleTime常量与键工厂——dehydrate既不携带 options 也不携带staleTime新鲜度是按 observer 计算的。只有预取需要拒绝创建缓存条目例如空列表必须落到路由的创建路径时才用setQueryData播种prefetchQuery与ensureQueryData总会创建条目。此外要保持预取导入轻量页面预取的导入会进入该路由的服务端图为取一个函数而拉入 barrel 可能拖入数千个模块bun run check:tool-registry-boundary会对每个页面把关。八、边界类型Boundary Typeshooks 从/lib/api/contracts/**导入命名类型别名如import { listEntitiesContract, type EntityList } from /lib/api/contracts/entitieshooks 中严禁写z.input.../z.output...客户端代码严禁import { z } from zod裸fetch仅限文档化的例外multipart 上传、二进制下载、流式响应、签名 URL 流程、OAuth 重定向、外部源。apps/sim/hooks/queries/**与apps/sim/hooks/selectors/**内每个裸fetch(以及apps/sim/**中 API 路由处理器之外的任何同源/api/...fetch必须在前一行带// boundary-raw-fetch: reason注释reason 非空最多容忍上方三行注释由 check-api-validation-contracts.ts 强制检查bun run check:api-validation/:strict。九、命名约定类别命名键工厂entityKeys查询 hooksuseEntity、useEntityList变更 hooksuseCreateEntity、useUpdateEntity、useDeleteEntity私有 fetch 函数fetchEntity、fetchEntities私有仓库中大量文件遵循该约定如 folders.ts 导出useFolders、useCreateFolder、useUpdateFolder、useDeleteFolderMutation、useRestoreFolder、useDuplicateFolderMutation、useReorderFolders私有函数为fetchFolders。十、CI 强制check-react-query-patterns以上约定并非纸面建议而是由 check-react-query-patterns.ts 静态强制bun run check:react-queryCI 中运行。它用平衡括号解析器扫描 AST检查六类反模式missing-stale-time——useQuery/useInfiniteQuery/useSuspenseQuery缺少显式staleTimequeryfn-no-signal—— 内联queryFn不接收参数、无法转发AbortSignalstale-time-literal——staleTime用数字字面量而非命名常量0豁免inline-query-key——queryKey: [literal, ...]而非键工厂key-factory-no-root——hooks/queries/**的*Keys工厂缺少all根键key-fetch-arg-drift——queryFn转发进 fetch 的标识符未出现在queryKey中。执行模型采用“严格区 棘轮ratchet”双轨check-react-query-patterns.ts严格区apps/sim/hooks/queries/**零容忍任何违规直接失败其余区域apps/sim/**其余部分对照 check-react-query-patterns.baseline.json 棘轮——仅当某类违规数超过基线记录时才失败允许存量代码渐进收敛逃生通道在违规构造的正上方一行写// rq-lint-allow: reasonreason 非空容忍至多三行注释。用法bun run scripts/check-react-query-patterns.ts输出报告--check作为 CI 门禁严格区 棘轮--update-baseline更新基线。规则文档还提到bun run check:client-boundary客户端边界与bun run check:api-validation裸 fetch 与契约边界作为相邻防线。结语把规范变成可执行的工程资产回顾整套体系Sim 的 React Query 数据层规范最值得借鉴的不是某一条孤立的技巧而是它的闭环结构AGENTS.md声明范围 →.claude/rules/sim-queries.md沉淀模式 →hooks/queries/utils/提供可复用的键工厂、fetcher 与乐观更新工具 →check-react-query-patterns.ts等脚本在 CI 中静态强制 → 严格的严格区/棘轮双轨保证存量与增量同时收敛。对于希望在自己的项目中引入 React Query 规范的团队可以按同样的路径落地先定键工厂与命名约定再把signal/staleTime/失效策略写进 lint 规则最后用静态检查守住“跨租户缓存碰撞”与“use client 边界”这两类运行时才爆发的隐患。【免费下载链接】simSim is the collaborative workspace to build, deploy, and monitor AI agents and workflows. Used by 100,000 builders.项目地址: https://gitcode.com/GitHub_Trending/sim16/sim创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表