ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Sim 前端数据层规范:React Query 模式、Query Key 工厂与 CI 强制校验实践

Sim 前端数据层规范:React Query 模式、Query Key 工厂与 CI 强制校验实践 Sim 前端数据层规范React Query 模式、Query Key 工厂与 CI 强制校验实践【免费下载链接】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 智能体与工作流的协作工作区中前端所有服务器状态都统一经由 TanStack QueryReact Query v5管理并配套一套自研的静态检查脚本在 CI 中强制约束查询写法。读完本篇你能掌握 Sim 的 query key 分层工厂设计、staleTime常量与 signal 转发的强制规则、乐观更新的onSettled调谐模式、服务端子预取server prefetching的五条纪律以及如何用bun run check:react-query在 CI 中把上述约定固化为可执行的门禁。总则React Query 拥有全部远程数据规范原文.claude/rules/sim-queries.md确立的第一原则是所有 React Query hooks 必须放在hooks/queries/下所有服务器状态必须走 React Query——组件中禁止用useStatefetch做数据获取或变更。远程数据与可分享视图状态在 Sim 中被严格分治状态类别归属典型内容远程数据remote dataReact Query 缓存实体列表、详情、工作流定义可分享的客户端视图状态URL query paramsnuqs标签页、筛选、搜索词、分页、选中的实体 id后者的完整规则见 Sim 的另一篇规则文档 .claude/rules/sim-url-state.md。这条边界之所以重要是因为 URL 状态可序列化、可分享而 React Query 缓存是进程内的一等公民两者混用会导致分享链接后视图状态丢失或缓存键与 URL 脱钩。Query Key 工厂分层键与前缀级失效每个 query 文件都定义一个分层 key 工厂带一个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, }工厂的分层结构决定了失效粒度invalidateQueries({ queryKey: entityKeys.lists() })会命中所有以[entity, list]开头的缓存条目而不会波及详情或无关命名空间。规范明确禁止内联 query keyqueryKey: [literal, ...]必须走工厂。仓库中的真实示例印证了这一结构。apps/sim/hooks/queries/utils/folder-keys.ts 中的folderKeys比通用模板多了一个resource中间层——因为同一个 workspace 中 Knowledge、Tables、Workflows 各自拥有独立的目录树resourceType必须进入 key否则三种目录列表会共享同一条缓存并互相覆盖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, }同时注意workspaceId ?? 的写法undefined参数被归一化为空串保证enabled: false的查询与激活后的查询落在同一条缓存条目上避免先占位、后重取造成的双份缓存。铁律queryFn 转发给 fetch 的每个标识符都必须出现在 queryKey 中规范中最容易被违反、后果也最严重的一条规则是key-fetch-arg-driftqueryFn转发给 fetch 的每一个标识符都必须出现在queryKey中。查询机制自身的标识符——signal、pageParam——除外它们不是限定 fetch 范围的参数。如果请求按workspaceId、cursor、limit、org id 等做了范围限定这些值就必须是 key 的一部分——否则不同的请求参数会共享同一条缓存条目跨租户 / 按参数的缓存碰撞。唯一的例外是以全局唯一 id 作为 key、而第二个 fetch 参数只是不可能碰撞的授权范围时可以用// rq-lint-allow: reason注释豁免。这条规则背后是真实事故模式如果两个不同的workspaceId共享一条缓存A 租户的列表可能被 B 租户的缓存命中——这是数据安全问题而非普通 bug。该检查由 scripts/check-react-query-patterns.ts 中的key-fetch-arg-drift类别静态强制执行脚本解析内联箭头queryFn体的第一个调用表达式把实参中所有裸 camelCase 标识符排除signal、pageParam等机制词排除以Contract结尾的模块级常量和 PascalCase/SCREAMING 常量与queryKey表达式做整词匹配缺席者即报违规。客户端边界可被服务端导入的查询原语不能放在use client模块里这是 Sim 规则中最具 Next.js 特色的一条。Next.js 会把use client模块的每个导出改写为client reference。因此在服务端求值的代码——RSC 的page.tsx/layout.tsx、prefetch.ts、route handler、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/运行时才会暴露。因此凡是被服务端模块导入的query key 工厂、独立requestJsonfetcher、mapper 或常量必须放在非use client的模块中key 工厂 →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.tsuse client的 hook 模块再把这些导入回来给自己的 hooks 使用。永远不要在use clienthook 文件里直接定义服务端会导入的工厂/fetcher——它会打崩 SSR文档中记录这正是 tables 页面崩溃的根因。从folder-keys.ts源码注释可以看到这一约束的实际代价考量mapFolder与 key 放在 utils 里而不是 hooks 模块里是因为服务端预取文件夹列表时若导入/hooks/queries/folders会把 contracts barrel 和乐观更新机制一起拖进服务端图。并且mapFolder逐字段显式列出而非展开spread确保新增的 wire 字段不会悄悄进入缓存形状、使水合条目与客户端 fetch 结果分叉。该边界由 scripts/check-client-boundary-imports.ts 对 prefetch/route/trigger/block 文件强制执行bun run check:client-boundaryCI 中运行。确属纯浏览器路径的逃生口在 import 上一行写// client-boundary-allow: reason。Query Hook 的标准形态每个 query 文件的内部结构固定为四段1. key 工厂 → 2. 类型如需→ 3. 私有 fetch 函数接收 signal→ 4. 导出的 hooks。hook 本体的四条硬性规则每个queryFn必须解构并转发signal用于请求取消每个 query 必须有显式staleTime且值必须来自命名导出常量如ENTITY_LIST_STALE_TIME绝不允许内联数字字面量。原因是服务端子预取prefetch.ts水合同一 query key 时必须导入并复用这个常量而不是把数字再写一遍——这正是让预取缓存条目与读取它的客户端 hook 保持新鲜度同步的机制keepPreviousData只用于键会变化的查询参数可变静态键上禁用同源 JSON 调用必须走requestJson(contract, ...)来自/lib/api/client/request配合/lib/api/contracts/**中定义的契约。规范给出的完整范例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 }) }仓库里的 apps/sim/hooks/queries/folders.ts 是这一形态的直接落地fetchFolders是私有 fetch 函数接受signaluseFolders用folderKeys.list(workspaceId, scope, resourceType)作键、staleTime: FOLDER_LIST_STALE_TIME该常量定义在 utils 侧的folder-keys.ts值为60 * 1000、placeholderData: keepPreviousData注释语义与范例一致参数可变并演示了select派生第二个 hookuseFolderMap复用同一 key 与常量仅追加select: selectFolderMap——两个 hook 共享同一条缓存选择器在各自观察者上求值这正是新鲜度按观察者计的体现。requestJson的客户端实现在 apps/sim/lib/api/client/request.ts其请求类型ApiClientRequestC按契约类型推导params/query/body/headers四个可选字段外加signal?: AbortSignal——契约层面强制了参数形状staleTime常量与 key 工厂解决缓存问题signal 解决取消问题三者共同构成 Sim 查询层的最小完备约束集。Mutation Hook 与乐观更新变更类规则优先定向失效entityKeys.lists()避免宽泛失效entityKeys.all失效必须覆盖所有受影响的 key 前缀lists、details、相关视图普通变更用onSuccess失效乐观变更用onSettled保证成功与失败两条路径都会与服务器调谐缓存mutationFn同样走requestJson(contract, { body, signal })——与 query 同一边界规则。普通变更的标准形态export function useCreateEntity() { const queryClient useQueryClient() return useMutation({ mutationFn: (body: CreateEntityBody) requestJson(createEntityContract, { body }), onSuccess: () { queryClient.invalidateQueries({ queryKey: entityKeys.lists() }) }, }) }乐观更新的规范代码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) }) }, }) }注意onMutate的三步先await cancelQueries避免在途请求覆盖乐观值、再getQueryData拍快照、最后setQueryData写入乐观值并返回previous作为 context 供onError回滚。onSettled之所以优于onSuccess在于它在成功和失败时都会触发缓存永远与服务器重新对齐不会留下只读成功的半调谐状态。对于需要与 Zustand 同步的乐观变更规范要求使用/hooks/queries/utils/optimistic-mutation的createOptimisticMutationHandlers。该工具apps/sim/hooks/queries/utils/optimistic-mutation.ts把上面的模式参数化调用方提供getQueryKey、getSnapshot、createOptimisticItem、applyOptimisticUpdate、replaceOptimisticEntry、rollback等回调工具统一返回onMutate/onSuccess/onError/onSettled四件套且onSettled内部固定执行invalidateQueries。一个值得留意的细节是其generateTempId乐观占位行使用generateId()而非时间戳生成临时 id防止同一毫秒内创建两行时 id 碰撞——碰撞会让replaceOptimisticEntry用同一个服务器行覆盖两条条目。useCallback 依赖mutation 对象不是稳定引用永远不要把 mutation 对象如createEntity放进useCallback依赖数组——mutation 对象引用不稳定每次状态更新都会变。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])违反此条会在每次状态更新后重建 handler级联引发子树重渲染与事件监听抖动。服务端子预取Server Prefetching的五条纪律服务端预取填充的是客户端 hook 将填充的同一条缓存键因此它必须与客户端 fetch 不可区分。规范给出五条纪律每条都对应一个具体的失败模式读数据层绝不对自己的 API 发 HTTP。进程内读数据比发起进程内 server-to-server 调用少一次往返和一次重复认证。当 route 执行的是应用用例use case时预取应调用同一个用例、使用 route 相同认证策略下的 principal——而不是绕过策略调用其下层 manager。匹配 hook 所缓存的 wire 形状。hook 缓存的是requestJson(contract, …)的产出seed 必须与之相等。两个陷阱契约字段声明为z.coerce.date()意味着 hook 持有Date而 route 原始 JSON 是字符串透传响应 schemaz.custom意味着 hook 逐字缓存 route JSON此时用原始行 seed 会泄漏Date和服务端专有字段。当 route 在响应前做了投影就让 route 与预取共享同一个投影函数。folder-keys.ts中mapFolder的存在正是此条的落地——wire 行到客户端WorkflowFolder形状的映射字符串日期 →Date与 key 放在一起服务端预取才能水合文件夹列表。证明观看者Prove the viewer。数据层读取本身不带授权授权原本由 route 提供。必须解析 viewergetWorkspaceHostContextForViewerlayout 已将其cache零成本失败时提前返回、不缓存任何东西——让客户端 fetch 真正打到 route 拿到真实的 403。绝不扩大 viewer 可见范围。永远await。只有 settled 的 query 才会被 dehydrate未 await 的预取会静默地丢出 payload该面板照旧瀑布式加载。不要重复 layout 已经 seed 过的。getQueryClient()在每次服务端调用中构建全新 client页面重复 seed layout 的 key 是真实发生的第二次读取且HydrationBoundary会把已见过的查询推迟到 effect而 SSR 从不运行 effect所以它根本到不了服务端渲染。预取应复用 hook 导出的staleTime常量与 key 工厂——dehydrate不携带 options 或staleTime新鲜度是按观察者计的。两个补充约束仅当预取必须能拒绝创建条目时才用setQueryData如一个必须 fall-through 到 route 创建路径的空列表prefetchQuery和ensureQueryData总是创建条目。保持预取导入轻量页面预取的导入会进入该 route 的服务端图为取一个函数而引入 barrel 可能拖进数千个模块——bun run check:tool-registry-boundary按页面门禁这一点。边界类型与裸 fetch 例外hooks 从/lib/api/contracts/**导入命名类型别名如import { listEntitiesContract, type EntityList } from /lib/api/contracts/entities。绝不在 hooks 中手写z.input.../z.output...也绝不在客户端代码中import { z } from zod。裸fetch只允许出现在有文档记录的例外multipart 上传、二进制下载、流式响应、签名 URL 流程、OAuth 重定向、外部 origin。apps/sim/hooks/queries/**或apps/sim/hooks/selectors/**中的每个此类裸fetch(以及apps/sim/**下 API route handler 之外的任何同源/api/...fetch都必须由// boundary-raw-fetch: reason注释前置reason 非空容忍其上方最多三行注释。该规则由 scripts/check-api-validation-contracts.ts 强制执行bun run check:api-validation/:strict。命名约定对象命名示例Key 工厂entityKeysfolderKeys、tableKeysQuery hookuseEntity、useEntityListuseFolders、useFolderMapMutation hookuseCreateEntity、useUpdateEntity、useDeleteEntityuseCreateFolder、useDeleteFolderFetch 函数fetchEntity、fetchEntities私有fetchFolders强制执行CI 静态门禁规范不是靠自觉维持的scripts/check-react-query-patterns.ts 在 CI 中bun run check:react-query见 package.json 中的check:react-queryscript静态强制这些约定覆盖六个违规类别类别检查内容missing-stale-timeuseQuery/useInfiniteQuery/useSuspenseQuery未声明显式staleTimestale-time-literalstaleTime用了数字字面量而非命名常量0豁免——它是总是重取的哨兵值不存在可漂移的窗口queryfn-no-signal内联queryFn不接收参数无法转发 AbortSignalinline-query-keyqueryKey: [literal, ...]而非同置工厂key-factory-no-roothooks/queries/**中的*Keys工厂缺少all根键key-fetch-arg-driftqueryFn转发给 fetch 的标识符未出现在queryKey中跨租户/按参数缓存碰撞实施模型分两区严格区apps/sim/hooks/queries/**零容忍任何违规即失败棘轮区apps/sim/**其余部分对照 scripts/check-react-query-patterns.baseline.json 棘轮只有某类别计数高于记录的基线才失败——存量违规被冻结、只减不增。逃生口在被标记构造的上一行写// rq-lint-allow: reasonreason 必须非空容忍上方最多三行注释。脚本还支持--update-baseline重写基线。实现层面值得注意的稳健性设计脚本对useQuery的正则匹配显式处理了类型参数useQueryRow[]({ ... })——注释记录了动机没有这一层带泛型的查询会被整批跳过严格区内曾有十个查询因此零违规却从未被真正检查过useQueries因选项对象嵌套一层更深而单独走一遍逐条目检查key-fetch-arg-drift的检测刻意保守仅在同时存在queryKey与可识别调用的内联箭头queryFn时触发且跳过requestJson/requestText/ensureQueryData的第一参数契约常量是模块级配置而非每 hook 标识符。小结Sim 的 React Query 规范是一套约定 工具化执法的完整闭环分层 key 工厂保证失效粒度与缓存隔离staleTime命名常量打通客户端 hook 与服务端预取的同一数值来源signal 转发、契约化requestJson与裸 fetch 注解把网络边界管死onSettled与createOptimisticMutationHandlers让乐观更新可回滚、可调谐而check:react-query、check:client-boundary、check:api-validation三道 CI 门禁使每一条规则都从文档变成了可执行的事实。对新贡献者而言最短上手路径是读一个现成文件如hooks/queries/folders.ts及其 utils 侧 key 文件照抄结构再让bun run check:react-query给你最终裁决。【免费下载链接】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),仅供参考
返回列表