服务端状态、缓存与乐观更新:页面里的数据什么时候算数

文章标题在编辑页改好了,返回列表还是旧标题。这通常不是渲染失灵,而是列表缓存没有失效。浏览器里的文章只是服务端数据的一份副本,副本需要有更新规则。

给数据一个完整身份

查询键要覆盖影响结果的条件,例如 ['articles', userId, folderId, query, page]。漏掉 folderId,两个文件夹就可能错误共用结果;权限相关查询漏掉用户身份,切换账号时可能混用旧缓存。退出时还应清理或隔离私有缓存,不能只更换头像。

下面是 TanStack Query 风格的配置片段,需在已配置 QueryClientProvider 的应用中使用,fetchArticles 是项目自己的接口函数:

TypeScript
const options = { queryKey: ['articles', userId, folderId, query, page], queryFn: ({ signal }: { signal: AbortSignal }) => fetchArticles({ folderId, query, page, signal }), staleTime: 30_000, }

查询键告诉缓存“这是谁”,staleTime 表达一段时间内是否当作新鲜数据,不代表到时一定立刻发请求。重新获取还受挂载、焦点、网络恢复和配置影响;无观察者缓存何时回收,是另一套规则。

至少设计四种界面

首次加载、成功有数据、成功为空、失败。重新获取时可能已有旧数据,因此“有数据且后台加载中”也是正常状态。把所有加载都换成全屏 spinner,会让列表闪来闪去,还可能丢掉用户的阅读位置。

请求错误应能重试,但重试策略要按操作性质决定。GET 重试通常较容易处理;创建文章或付款不能在没有幂等协议时无限自动重试。401 应处理会话,403 应解释权限,429 应尊重限流,而不是给所有错误一个相同弹窗。

乐观更新需要准备撤销

收藏按钮适合先改变视觉状态,让点击感觉即时。完整流程是:记录旧状态、应用临时变化、提交服务端、成功后对齐权威结果、失败则恢复并解释。并发点击时还要避免旧失败回滚掉新成功;可以串行化同一文章的修改,或记录操作版本。

文章正文发布通常更谨慎。用户需要明确知道保存是否成功,失败时必须保留草稿。用 updated_at 或版本号做条件更新,可检测“另一个窗口先保存了”的冲突。前端防重复点击只改善体验,最终一致性由服务端协议保证。

在本知识库中观察

阅读 useKnowledge 的数据获取与 useKnowledgeRealtime 的实时事件处理,画出“文章变更—列表刷新—正文重新获取”的链路。实时通知不是离线可靠队列,断线重连后仍可能需要重新拉取权威快照。

练习与参考

给收藏接口注入一次失败,页面应恢复并提示;给列表接口返回空数组,应显示空状态而不是错误。再模拟退出后登录另一用户,检查私有数据不会从旧缓存短暂闪现。

缓存里到底存了什么

把查询缓存理解为“某组查询条件对应的一份结果及其生命周期”。数据是否新鲜、请求是否进行中、上次错误、有没有组件正在使用,可能都属于这一层。它与本地表单草稿不同:草稿是用户尚未提交的编辑意图,缓存通常是服务器数据的副本。

假设编辑文章 A 成功。列表、正文、目录摘要可能各有缓存键。你需要更新对应缓存或标记失效后重新获取,不能只改当前输入框就认为其他入口已经同步。实时事件可以触发刷新,但事件丢失、重连和权限改变后仍需权威读取。

乐观收藏的并发反例是:操作 1 从未收藏变收藏,操作 2 又取消收藏;操作 2 成功后,操作 1 失败却把旧快照恢复成错误状态。解决方案可以是同一文章串行提交、禁用期间不接受新操作,或给每次变更编号并只回滚仍由该操作拥有的状态。选择哪一种应同时解释用户体验和服务端协议。

验证缓存隔离时,不只检查退出后的新网络请求,还要检查旧请求晚回、持久化缓存和浏览器返回。权限变化之后旧数据是否应立刻清除,也属于产品与安全规则,不能只以“最终会刷新”为理由忽略短暂泄露。