《TypeScript编程实战》14.2 乐观更新与缓存失效

本节讲清写操作之后缓存该怎么改:先用 useMutation 的四个类型参数把变量、返回值、错误与上下文串起来,再实现「先改界面、失败回滚」的乐观更新三段式,最后区分 setQueryData 精确写入与 invalidateQueries 前缀失效各自的适用场景。你会掌握 onMutate 快照的类型写法、cancelQueries 防竞态,以及失效范围过宽的常见坑。

本节目标:解决「写操作之后缓存怎么办」这个查询层里最容易写错的问题。读完本节,你应该能写出带类型快照与安全回滚的乐观更新,能在精确写入与失效重取之间做出有依据的取舍,并且知道并发点击、失败回滚、失效范围过宽这三类事故分别在代码的哪一行发生。

14.2 乐观更新与缓存失效

先看一个交互细节:用户点了一下「点赞」,按钮要等接口返回才变色。

function LikeButton({ postId }: { postId: string }) {
  const { data: post } = useQuery(postDetailQuery(postId));
  const mutation = useMutation({
    mutationFn: (liked: boolean) => api.setLike(postId, liked),
  });
  return (
    <button
      onClick={() => mutation.mutate(!post?.liked, { onSuccess: () => queryClient.invalidateQueries() })}
      disabled={mutation.isPending}
    >
      {post?.liked ? '已赞' : '点赞'}
    </button>
  );
}

功能是对的,但有两个问题。一是延迟感:本地网络 300ms,跨区可能 800ms,用户会以为没点上而重复点。二是那句 invalidateQueries() 没有参数——它把整个缓存全部标脏,页面里所有查询都会重新拉一遍。本节要解决的就是这两件事。

14.2.1 useMutation 的四个类型参数

useMutation 的签名比 useQuery 多一个参数,因为它们关心的是「写」这件事:

参数含义典型来源
TDatamutationFn 的返回类型服务端返回的资源
TError错误类型需显式声明为 ApiError
TVariablesmutate() 的入参类型从 mutationFn 参数推导
TContextonMutate 返回值类型从 onMutate 返回值推导

TContext 是这里最特殊的一个:它是 onMutate 交给 onError / onSettled 的接力棒,通常用来装「修改前的缓存快照」。它不需要手写,只要 onMutate 有返回类型,后面两个回调的 context 参数就有类型。

const like = useMutation({
  mutationFn: (vars: { postId: string; liked: boolean }) => api.setLike(vars.postId, vars.liked),
  onMutate: async (vars) => {
    // ...
  },
});
// like.mutate: (vars: { postId: string; liked: boolean }) => void

mutationFn 的参数类型一旦明确,mutate() 的入参就被约束住了:传 { id: 'x', liked: true } 会立刻报 TS2353,因为契约里没有 id 这个字段。

14.2.2 乐观更新的三段式

乐观更新的本质是「先按预期改缓存,失败再改回来」。它固定由三个回调组成:

回调职责返回值去向
onMutate取消在途请求、快照旧值、写入新值返回值即 TContext
onError用快照回滚拿到 context
onSettled无论成败都重新校验拿到 context
const like = useMutation({
  mutationFn: (vars: { postId: string; liked: boolean }) => api.setLike(vars.postId, vars.liked),
  onMutate: async (vars) => {
    await queryClient.cancelQueries({ queryKey: postKeys.detail(vars.postId) });
    const previous = queryClient.getQueryData<Post>(postKeys.detail(vars.postId));
    queryClient.setQueryData<Post>(postKeys.detail(vars.postId), (old) =>
      old ? { ...old, liked: vars.liked } : old,
    );
    return { previous };
  },
  onError: (_err, vars, context) => {
    queryClient.setQueryData(postKeys.detail(vars.postId), context?.previous);
  },
  onSettled: (_data, _err, vars) => {
    queryClient.invalidateQueries({ queryKey: postKeys.detail(vars.postId) });
  },
});

这段代码值得逐行读。cancelQueries 必须在快照之前——否则一个正在飞行的 GET 会在你写完乐观值之后落地,把乐观值覆盖掉。getQueryData<Post> 的那个显式泛型是关键:缓存是 Map<QueryKey, unknown>,不写泛型就只能拿到 unknown,{ ...old, liked } 会直接报错。回滚那一行把快照整个写回,而不是「把 liked 取反」——取反在连续点击时会算错。

14.2.3 为什么快照要装整个对象

初学者常写这样的回滚:

const configExcerpt = {
// 反例:靠反向操作回滚
onError: (_e, vars) => {
  queryClient.setQueryData<Post>(postKeys.detail(vars.postId), (old) =>
    old ? { ...old, liked: !vars.liked } : old,
  );
},
};

问题在于 onMutate 的快照可能不是当前服务端真相。如果用户连点两次,第一次的 onError 会把值翻回去,而此时第二次的乐观值已经写入了——!vars.liked 翻转的是错误的基准。快照法的语义是「恢复到操作前那一刻的完整状态」,与中间发生过几次操作无关,这才是可推理的行为。

快照还有一个类型上的好处:TContext 的形状由 onMutate 的返回值决定,{ previous: Post | undefined } 会被原样推导到 onError,不需要额外断言。

14.2.4 setQueryData 的精确写入

setQueryData 有两个重载,理解它们的差别能少写很多断言:

// 重载一:直接给值,返回值的类型由泛型决定
queryClient.setQueryData<Post>(key, newPost);

// 重载二:给更新函数,old 的类型由泛型决定
queryClient.setQueryData<Post>(key, (old) => (old ? { ...old, liked: true } : old));

第二个重载里的 old 是 Post | undefined,这个 undefined 必须处理——缓存可能还没被填充(用户直接打开了详情页但列表查询还没跑)。写法上要么像上面那样 old ? ... : old,要么 old ?? initial,但不能写 old.liked,编译器会报 TS18048: 'old' is possibly 'undefined'。

当缓存结构是分页或列表时,更新的写法会复杂一些:

queryClient.setQueryData<Post[]>(postKeys.list(filter), (old) =>
  old?.map((p) => (p.id === vars.postId ? { ...p, liked: vars.liked } : p)),
);

这里返回的 undefined(当 old 为空时)是故意的:setQueryData 收到 undefined 会删除该缓存项,语义上等价于「这份列表还没有数据,别造一份假的」。

14.2.5 失效:invalidateQueries 的前缀匹配

失效是另一条路:不手动改缓存,而是把它标脏,让挂载中的查询自己重取。

// 只失效这一条详情
queryClient.invalidateQueries({ queryKey: postKeys.detail(postId) });
// 失效所有 post 相关(列表 + 详情)
queryClient.invalidateQueries({ queryKey: postKeys.all });

匹配规则是前缀匹配:queryKey 传入的数组必须是被匹配 key 的前缀。postKeys.all 是 ['posts'],所以 ['posts', 'detail', id] 会被命中。这就是 《TypeScript编程实战》14.1 TanStack Query 类型推导 里强调 key factory 要分层的原因——层级设计直接决定了你能失众多细。

一个必须记住的细节:invalidateQueries 默认 refetchType: 'active',只会重取当前有组件在用的查询。如果你希望离屏的缓存也一并刷新,需要显式传 refetchType: 'all';反之,只想标脏不立即重取,就传 refetchType: 'none'。

14.2.6 精确写入还是失效重取

这是本节最需要判断力的一张表:

场景推荐理由
字段级改动(点赞、收藏)setQueryData结构确定,无需往返
服务端会补算字段(更新时间、版本号)失效重取客户端算不出真值
新建/删除列表项失效列表 + 精确改详情排序与分页要服务端决定
跨多个资源(下单扣库存)失效 all影响面难以穷举
离线/弱网环境乐观写入 + 队列重放见第 9 章的队列模型

判断标准只有一条:客户端能不能算出服务端的最终值。能算就精确写,不能算就失效重取。两者也可以组合——乐观写入负责「立刻好看」,onSettled 的失效负责「最终正确」。

14.2.7 把失效范围收窄

回到开头那句 invalidateQueries()。它的实际后果是整页重取,在数据量大的后台里会看到明显的闪烁。收窄的做法有三层:

// 一、按资源前缀
queryClient.invalidateQueries({ queryKey: postKeys.all });
// 二、按查询谓词(需要更细的语义时)
queryClient.invalidateQueries({
  predicate: (q) => q.queryKey[0] === 'posts' && q.queryKey.includes('list'),
});
// 三、配合 staleTime 减少无谓重取
useQuery({ ...postDetailQuery(id), staleTime: 60_000 });

predicate 的参数 q 类型是 Query,q.queryKey 是 readonly unknown[]——所以这里拿不到元素的具体类型,比较时要用 as string 或写一个类型守卫。这是失效逻辑里唯一无法完全类型化的地方,值得在代码里留一条注释说明。

14.2.8 串行化并发写操作

乐观更新在并发下最脆弱。假设用户快速连点三次点赞,服务端可能按 true, false, true 的顺序到达,也可能乱序。前端能做的只有两件事:

策略做法代价
串行化用 scope 把同一资源的 mutation 排队用户操作有等待感
只认最后一次记录 mutationId,回滚时校验实现复杂度明显上升

串行化在 TanStack Query 里靠 scope 配置实现:

const like = useMutation({
  scope: { id: `like-${postId}` },
  mutationFn: (vars: { postId: string; liked: boolean }) => api.setLike(vars.postId, vars.liked),
  onMutate: async (vars) => {
    await queryClient.cancelQueries({ queryKey: postKeys.detail(vars.postId) });
    const previous = queryClient.getQueryData<Post>(postKeys.detail(vars.postId));
    queryClient.setQueryData<Post>(postKeys.detail(vars.postId), (old) =>
      old ? { ...old, liked: vars.liked } : old,
    );
    return { previous };
  },
  onError: (_err, vars, context) => {
    queryClient.setQueryData(postKeys.detail(vars.postId), context?.previous);
  },
});

同一 scope.id 的 mutation 会按提交顺序依次执行,前一个没结束前不会发出下一个。这牺牲一点响应速度,换来「缓存状态与请求顺序一致」这个可推理的前提。绝大多数业务里,串行化是更划算的选择。

14.2.9 mutation 的错误分支与提示

mutate 的错误不会抛出,只会落到 onError 和 error 字段;mutateAsync 则会把错误抛出,需要 try/catch。两者对错误类型的处理方式不同:

// 路径一:回调式,错误类型来自 useMutation 的 TError
const like = useMutation<Post, ApiError, { postId: string; liked: boolean }>({
  mutationFn: (vars) => api.setLike(vars.postId, vars.liked),
  onError: (err) => toast.error(err.message), // err: ApiError
});

// 路径二:await 式,必须自己接住
async function handleLike(vars: { postId: string; liked: boolean }) {
  try {
    await like.mutateAsync(vars);
  } catch (e) {
    if (e instanceof ApiError && e.status === 409) toast.warn('操作过于频繁');
  }
}

推荐默认走回调式:它天然避免了「忘了 catch 导致 unhandled rejection」这个运行时隐患。只有需要按顺序编排多个写操作时才用 mutateAsync。注意两条路径的 TError 都不会自动从 mutationFn 推导出来——api.setLike 抛的是 ApiError 还是 Error,只有你知道,必须显式写进泛型。

14.2.10 用类型测试守住回滚逻辑

乐观更新的代码很难靠运行时测试覆盖——要精确模拟「请求发出后失败」这个时刻。类型测试能补上一部分:

import { expectTypeOf } from 'vitest';

const like = useLikePost(postId);

expectTypeOf(like.mutate).parameter(0).toEqualTypeOf<{ postId: string; liked: boolean }>();
expectTypeOf(like.data).toEqualTypeOf<Post | undefined>();
expectTypeOf(like.error).toEqualTypeOf<ApiError | null>();

这三行能在重构时立刻发现「mutationFn 参数改了但调用方没跟上」这类漂移。类型测试的完整玩法见 《TypeScript编程实战》4.3 类型测试与覆盖率门禁 。

14.2.11 五个常见坑

一、onMutate 忘了 await cancelQueries。 症状是「点了赞,界面闪回未赞状态」。原因就是在途 GET 落地覆盖了乐观值。cancelQueries 的 await 不能省。

二、onSettled 里无条件失效导致抖动。 如果 onMutate 已经精确写入了缓存,onSettled 的失效会触发一次重取,界面数值先乐观后回落再确认,视觉上是三次跳变。要么去掉失效,要么把 staleTime 设长一点。

三、回滚时没考虑并发 mutation。 连续两次操作的 onError 会互相覆盖。严谨的做法是在 onMutate 里记录 mutationId,回滚时只回滚最新那次;或者干脆串行化(scope 配置)。

四、getQueryData 忘了泛型。 这是本批代码里最常见的类型错误来源,报错形式是 Property 'liked' does not exist on type 'unknown'。看到 unknown 就该想到补泛型。

五、invalidateQueries 写在组件里。 失效逻辑应当收敛在 mutation 定义处或独立的 useLikePost Hook 里,散落在 onClick 中会让「哪些查询会被刷新」变得无法追踪。

14.2.12 与全书其它章节的衔接

缓存的分层与键设计是失效策略的基础,见 《TypeScript编程实战》8.1 缓存层次与键设计 ;如果失效发生在高并发场景,还要考虑 《TypeScript编程实战》8.3 穿透·击穿·雪崩防护 里那套批量合并思路。乐观更新之后的列表刷新与滚动位置保持,见 《TypeScript编程实战》14.3 分页、无限滚动与预取 。

站内专题对缓存策略有更系统的梳理,可作为延伸阅读:缓存策略与模式 、数据缓存架构 、API 缓存与性能 。HTTP 层的缓存头与浏览器缓存可读 网络 HTTP 缓存 。

小结

本节把写操作的缓存处理拆成三个层次。契约层:useMutation 的四个类型参数里,TVariables 来自 mutationFn 参数、TContext 来自 onMutate 返回值,这两个都靠推导;而 TError 必须显式声明。流程层:乐观更新固定是「取消在途 → 快照 → 写入 → 失败回滚 → 落定重校验」五步,顺序不能换,await cancelQueries 不能省。范围层:setQueryData 用于客户端能算出真值的改动,invalidateQueries 用于算不出的场景,且失效范围必须靠 key factory 的层级收窄,绝不要写无参数的 invalidateQueries()。

最值得记住的一句判断:能算出最终值就精确写,算不出就失效重取。这一条能解决本节 80% 的设计纠结。

写操作只是数据变动的一半。当列表有 20 页、用户滚到第 8 页时,失效重取会不会把滚动位置顶回去、下一页该什么时候预取,是 《TypeScript编程实战》14.3 分页、无限滚动与预取 要处理的问题。

阅读导航:上一节:14.1 TanStack Query 类型推导 · 下一节:14.3 分页、无限滚动与预取 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes