项目管理工具与普通内容页面的区别,在于同一份业务数据会同时出现在许多位置:项目列表、项目详情、任务看板、成员筛选器和统计卡片都可能依赖同一个项目或任务状态。
如果每个页面自行请求并永久保存一份数据,用户更新项目以后,很容易看到列表已经变化而详情仍停留在旧状态。React Query 适合管理这类服务端状态,但它并不会自动知道哪些数据彼此相关,查询键与同步策略仍需要围绕业务设计。
先区分服务端状态和本地状态
项目数据、任务列表和成员权限来自服务端,具有缓存、过期和重新拉取的需求。抽屉是否打开、当前选中的 Tab、表单尚未提交的输入则属于本地界面状态。
交给 React Query: 项目列表、项目详情、任务状态、成员数据、统计结果留给组件或轻量状态容器: 弹层显示、视图模式、临时筛选展开、编辑中的草稿
如果把所有数据都当成全局客户端状态维护,页面会不断手写请求、缓存和同步逻辑;如果把临时交互状态也当作服务端查询处理,组件又会变得绕远。
查询键是缓存关系的基础
项目列表通常受筛选、分页和排序条件影响,详情则由项目标识决定:
const queryKeys = { projectList: params => ["projects", "list", params], projectDetail: projectId => ["projects", "detail", projectId], tasks: projectId => ["projects", projectId, "tasks"]};
function ProjectList({ filters }) { const query = useQuery({ queryKey: queryKeys.projectList(filters), queryFn: () => projectService.fetchList(filters) }); return <ProjectTable data={query.data?.items || []} />;}
查询键需要包含真正决定返回结果的条件。缺少筛选条件,会让不同列表错误共享缓存;把无关的临时对象放入键中,又会造成无意义的重复查询。
更新操作完成后要同步相关视图
例如在详情页修改项目名称,受到影响的不只有详情缓存,列表中同一个项目的名称也应该更新。最简单可靠的做法是在保存成功后让相关查询失效:
function useUpdateProject() { const queryClient = useQueryClient(); return useMutation({ mutationFn: projectService.updateProject, onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: queryKeys.projectDetail(variables.id) }); queryClient.invalidateQueries({ queryKey: ["projects", "list"] }); } });}
失效后重新请求能够保证结果以服务端为准,适合更新并不十分频繁、但正确性重要的管理场景。
即时反馈可以使用乐观更新
某些操作非常明确,例如任务完成状态切换。为了让用户立即获得反馈,可以先更新缓存,失败后再回滚:
useMutation({ mutationFn: taskService.toggleCompleted, onMutate: async variables => { const key = queryKeys.tasks(variables.projectId); await queryClient.cancelQueries({ queryKey: key }); const previous = queryClient.getQueryData(key); queryClient.setQueryData(key, old => updateTaskCompleted(old, variables.taskId, variables.completed) ); return { key, previous }; }, onError: (_, __, context) => { queryClient.setQueryData(context.key, context.previous); }, onSettled: (_, __, variables) => { queryClient.invalidateQueries({ queryKey: queryKeys.tasks(variables.projectId) }); }});
乐观更新不适合所有操作。涉及复杂权限判断、服务端计算或多对象联动的修改,先等待保存成功再刷新会更稳妥。
两个时间参数控制的是不同问题
React Query v3 中最容易混淆的两个配置是 staleTime 与 cacheTime。它们都以毫秒为单位,但解决的不是同一件事:
| 参数 | 回答的问题 | 影响的行为 |
|---|---|---|
staleTime | 这份数据在多长时间内仍可被认为是新鲜的 | 数据何时从 fresh 变为 stale,以及重新挂载、窗口聚焦等场景是否需要后台刷新 |
cacheTime | 页面不再使用这份数据以后,缓存还保留多久 | 无活跃订阅的查询何时从内存中被垃圾回收 |
这里以项目中使用的 React Query v3 为准,所以使用 cacheTime 这个参数名。后续 TanStack Query v5 将它改名为 gcTime,正是为了避免误解:它不是“数据新鲜多久”,而是查询进入不活跃状态以后“缓存多久再回收”。
React Query v3 的默认值可以简单记为:
staleTime: 0cacheTime: 5 * 60 * 1000
也就是说,接口成功返回的数据会立刻被视为 stale,但组件卸载以后仍可以在缓存中保留 5 分钟。stale 不等于“没有缓存”,也不等于页面立即清空旧数据;它表示在满足重新拉取触发条件时,可以在展示缓存数据的同时发起后台请求。
staleTime 决定多久需要再次确认服务端事实
假设用户先进入项目详情页,获取了一份项目数据,然后返回列表并很快再次进入同一个详情页:
useQuery({ queryKey: queryKeys.projectDetail(projectId), queryFn: () => projectService.fetchDetail(projectId), staleTime: 30 * 1000});
如果第二次进入发生在 30 秒内,缓存中的详情数据仍属于 fresh,通常可以直接使用,不需要因为组件重新挂载而立即再次请求。如果超过 30 秒,数据已经 stale,详情页仍然能够先读到缓存内容,但 React Query 可以在后台重新拉取最新结果。
在复杂项目工具中,staleTime 的设置取决于数据变化速度和用户对于实时性的要求:
| 数据类型 | 可考虑的 staleTime | 原因 |
|---|---|---|
| 字典、项目类型、权限枚举 | 5 - 30 分钟 | 内容很少变化,重复请求价值较低 |
| 项目列表、项目详情 | 30 秒至 2 分钟 | 需要兼顾页面切换速度和他人修改后的可见性 |
| 任务状态、审批状态 | 0 - 30 秒 | 变化相对频繁,需要较快重新确认 |
| 正在协作的实时看板 | 较短或配合推送/轮询 | 仅靠较长缓存时间无法保证及时变化 |
时间越长,请求次数越少,页面往返体验越轻;但其他用户已经改变的数据,也会更久以后才被主动重新确认。staleTime 因此是“减少无效刷新”与“及时感知变化”之间的取舍。
cacheTime 决定离开页面以后还能不能快速回来
cacheTime 只在一个查询没有任何活跃使用者以后开始计时。例如用户从项目详情返回项目列表,详情查询不再被组件订阅,才会进入 inactive 状态并开始等待回收:
useQuery({ queryKey: queryKeys.projectDetail(projectId), queryFn: () => projectService.fetchDetail(projectId), staleTime: 30 * 1000, cacheTime: 10 * 60 * 1000});
在这 10 分钟内,用户再次进入同一个项目详情,即使数据已经过了 30 秒的新鲜期,页面也能优先展示缓存中的详情,再在后台请求最新数据。这种“先显示已有内容,再校正结果”的体验,对于频繁在列表和详情之间跳转的后台系统很重要。
如果缓存已经被回收,用户再次进入详情页时就没有可复用数据,页面需要等待新的请求完成后才能完整展示。
可以将一个查询的生命周期理解为:
首次进入页面 -> 请求完成并写入缓存 -> staleTime 内数据为 fresh -> 超过 staleTime 后数据为 stale,但缓存仍可展示 -> 页面离开且无人使用查询,开始计算 cacheTime -> cacheTime 到期后缓存被回收
因此,staleTime 与 cacheTime 可以存在以下组合:
| 配置组合 | 用户体验 | 适合场景 |
|---|---|---|
较短 staleTime + 较长 cacheTime | 返回页面立即有旧数据,同时较积极刷新 | 列表、详情、协作型业务 |
较长 staleTime + 较长 cacheTime | 页面切换快,请求少 | 稳定字典和低频变化配置 |
较短 staleTime + 较短 cacheTime | 更容易重新 loading,请求更多 | 数据敏感且不应长期保留的场景 |
在多数 To B 页面中,比较常见的选择是让 cacheTime 明显长于 staleTime:允许页面短时间返回时快速看到已有结果,同时仍然按照业务需要刷新服务端数据。
数据新鲜度需要结合页面性质决定
To B 页面经常被长时间打开。用户停留十分钟后重新切回标签页,数据可能已经由其他操作改变。可以按页面特点配置刷新策略:
useQuery({ queryKey: queryKeys.projectDetail(projectId), queryFn: () => projectService.fetchDetail(projectId), staleTime: 30 * 1000, cacheTime: 10 * 60 * 1000, refetchOnWindowFocus: true});
这里表示详情数据 30 秒内可直接认为新鲜;用户离开该详情页面后,它还可在缓存中保留 10 分钟;页面重新获得焦点时,如果数据已经过期,则可以请求最新结果。
缓存与编辑状态仍然需要隔离。正在编辑的表单不能在后台刷新后直接覆盖用户输入。比较安全的方式是保持已加载版本作为编辑基线,发现服务端已有变化时提示用户重新确认,而不是静默替换草稿。
不要只靠时间参数处理数据更新
staleTime 决定的是自动重新确认的节奏,不能替代一次明确业务操作后的缓存同步。例如用户在详情页完成任务状态更新,这时不应等待 30 秒以后缓存自然过期,而应立即更新或失效相关查询:
queryClient.invalidateQueries({ queryKey: queryKeys.projectDetail(projectId)});queryClient.invalidateQueries({ queryKey: queryKeys.tasks(projectId)});queryClient.invalidateQueries({ queryKey: ["projects", "list"]});
同样,cacheTime 也不代表数据在缓存存活期间一定可信。它只决定有没有一份可用于快速展示的历史结果;历史结果是否还新鲜,仍由 staleTime、主动失效和刷新策略共同判断。
复杂项目中的一致性来自清晰规则
React Query 降低了请求缓存与状态反馈的重复工作,但数据一致性仍来自以下约定:
- 明确哪些信息属于服务端状态。
- 为列表、详情和关联资源设计稳定查询键。
- 修改成功后明确哪些缓存需要失效或局部更新。
- 对长时间打开和多人变化的数据制定刷新策略。
- 为
staleTime与cacheTime分别确定数据新鲜期和缓存保留期。 - 对编辑场景避免后台数据无提示覆盖本地输入。
在复杂项目管理工具中,页面数量不断增加并不可怕;真正危险的是同一份数据在各处呈现出不同事实。缓存策略写清楚以后,前端才有能力在体验速度和数据可靠之间取得平衡。