queryKey Factory 模式

用工厂函数集中管理 TanStack Query 的 queryKey,避免硬编码字符串,保证缓存键的一致性和可维护性。

#type / pattern #status / evergreen #tech / dev / frontend #resource / react

[!info] related notes

queryKey Factory 模式

核心问题

当项目中有几十个 useQuery 调用时,queryKey 散落在各个组件中:

// 组件 A
useQuery({ queryKey: ['users'], ... });
useQuery({ queryKey: ['user', id], ... });

// 组件 B
queryClient.invalidateQueries({ queryKey: ['users'] });

// 组件 C
queryClient.setQueryData(['user', id], newData);

问题:

  • 拼写错误不会报错(['user', id] vs ['users', id]
  • 改 key 结构时要全局搜索替换
  • 无法一眼看出哪些 key 属于同一个领域

Factory 模式

基本结构

// query-keys.ts
export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: Record<string, unknown>) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
};

使用方式

// 获取列表
useQuery({ queryKey: userKeys.list({ page: 1, role: 'admin' }), queryFn: ... });

// 获取详情
useQuery({ queryKey: userKeys.detail(userId), queryFn: ... });

// 失效所有用户相关缓存
queryClient.invalidateQueries({ queryKey: userKeys.all });

// 只失效列表
queryClient.invalidateQueries({ queryKey: userKeys.lists() });

// 更新单个详情缓存
queryClient.setQueryData(userKeys.detail(userId), newData);

层级结构

userKeys.all                    → ['users']
userKeys.lists()                → ['users', 'list']
userKeys.list({ page: 1 })     → ['users', 'list', { page: 1 }]
userKeys.details()              → ['users', 'detail']
userKeys.detail('123')          → ['users', 'detail', '123']

利用 TanStack Query 的前缀匹配机制:

  • invalidateQueries({ queryKey: userKeys.all }) 会使所有以 ['users'] 开头的缓存失效
  • invalidateQueries({ queryKey: userKeys.details() }) 只会使 ['users', 'detail', ...] 失效

空值 Key 模式

当 id 可能为 null 时(如 routeConversationId),需要处理空值情况:

// ❌ 非空断言只是 TypeScript 语法,不改变运行时
useQuery({
  queryKey: ['conversation', conversationId!],  // conversationId 可能是 null
  queryFn: () => fetchConversation(conversationId!),
  enabled: !!conversationId,
});
// 当 conversationId 为 null 时,queryKey 是 ['conversation', null]
// 虽然 enabled: false 阻止了请求,但 key 不够干净

// ✅ 用空值 key 明确语义
export const consultationKeys = {
  all: ['consultation'] as const,
  conversations: () => [...consultationKeys.all, 'conversations'] as const,
  conversation: (id: string) => [...consultationKeys.all, 'conversation', id] as const,
  conversationEmpty: () => [...consultationKeys.all, 'conversation', 'empty'] as const,
  session: (id: string) => [...consultationKeys.all, 'session', id] as const,
  sessionEmpty: () => [...consultationKeys.all, 'session', 'empty'] as const,
};

// 使用时
useQuery({
  queryKey: conversationId
    ? consultationKeys.conversation(conversationId)
    : consultationKeys.conversationEmpty(),
  queryFn: () => fetchConversation(conversationId!),
  enabled: !!conversationId,
});

这样 TypeScript 类型和运行时行为都一致:有 id 时用真实 key,没 id 时用 empty 占位 key。

多领域 Factory

// 各领域独立的 keys 文件
export const consultationKeys = {
  all: ['consultation'] as const,
  conversations: () => [...consultationKeys.all, 'conversations'] as const,
  conversation: (id: string) => [...consultationKeys.all, 'conversation', id] as const,
  session: (id: string) => [...consultationKeys.all, 'session', id] as const,
};

export const trainingKeys = {
  all: ['training'] as const,
  plans: () => [...trainingKeys.all, 'plans'] as const,
  plan: (id: string) => [...trainingKeys.all, 'plan', id] as const,
  sessions: (planId: string) => [...trainingKeys.all, 'plan', planId, 'sessions'] as const,
};

export const assessmentKeys = {
  all: ['assessment'] as const,
  reports: () => [...assessmentKeys.all, 'reports'] as const,
  report: (id: string) => [...assessmentKeys.all, 'report', id] as const,
};

常见错误

层级不一致导致误删

// ❌ conversations() 的 key 长度也是 2,可能被误匹配
const consultationKeys = {
  all: ['consultation'] as const,
  conversations: () => [...consultationKeys.all, 'conversations'] as const,   // ['consultation', 'conversations']
  session: (id: string) => [...consultationKeys.all, 'session', id] as const, // ['consultation', 'session', id]
};

// 用 predicate 精确删除时可能误删
queryClient.removeQueries({
  queryKey: consultationKeys.all,
  predicate: (q) => q.queryKey.length > 1,  // conversations 也被删了
});

// ✅ 精确指定
queryClient.removeQueries({ queryKey: [...consultationKeys.all, 'conversation'] });
queryClient.removeQueries({ queryKey: [...consultationKeys.all, 'session'] });

忘记 as const

// ❌ 类型推导丢失
const userKeys = {
  all: ['users'],  // 类型是 string[],不是 readonly ['users']
};

// ✅ 保持字面量类型
const userKeys = {
  all: ['users'] as const,
};

参数顺序不一致

// ❌ 不同地方参数顺序不同
useQuery({ queryKey: ['users', page, role], ... });
queryClient.invalidateQueries({ queryKey: ['users', role, page] });  // 不会命中!

// ✅ 用 factory 保证一致
useQuery({ queryKey: userKeys.list({ page, role }), ... });
queryClient.invalidateQueries({ queryKey: userKeys.all });

速查:何时用哪种 key

场景key 结构说明
无参数列表['users', 'list']最简单
带过滤的列表['users', 'list', { filters }]过滤参数作为对象
单个详情['users', 'detail', id]id 作为最后一位
关联子资源['users', id, 'posts']嵌套结构
全局失效userKeys.all前缀匹配,一次失效所有
创建于 2026/7/3 更新于 2026/7/15