首页 / 文章

在实时 Next.js 单体仓库中,Zustand 与 TanStack Query 的边界问题

学习如何使用 Zustand、TanStack Query 和套接字,在一个协作的 Next.js 单体仓库中为 UI 状态、服务器状态和实时事件制定明确的所有权规则。

发布者:REACTAPP.TOP阅读约 10 分钟2359 词

来源: Zustand for UI, TanStack Query for the Server: The Split That Finally Made Our App Sane

许多 Next.js 项目最初都会为“全局状态”创建一个小型 Zustand 存储,随后逐渐将其发展成一个包罗万象的存储方案:先是当前用户信息,接着是文档列表,再是从套接字获取的在线状态映射,最后还有某人的乐观更新内容。几个月后,这个存储机制会与服务器端渲染产生冲突,数据失效的问题也会波及到错误的数据,而且每次提交 pull request 都会重新引发关于数据应存储在何处的争论。本文为协作型应用提出了一条简单的所有权规则,展示了在单仓库架构中实现该规则的存储与查询代码,并说明了实时事件、身份验证会话以及性能优化如何融入同一模型之中。

这种场景属于高度依赖协作的产品:使用 Tiptap 和 Hocuspocus 进行富文本编辑,借助 Socket.io 实现轻量级的在线状态检测与通知功能,同时还通过单仓库结构将网页应用、共享的 UI 组件以及 API 类型统一管理。在这种架构下,将服务器缓存混入 Zustand 不仅会破坏代码结构,还会干扰服务端渲染流程、导致错误的数据失效,并使得追踪异常的重新渲染问题成为一项反复出现的麻烦事。

三种状态的一种管理规则

这条规则的设定刻意显得简单直接,正因如此才便于在高压环境下执行。

  • Zustand 负责管理 UI 状态。这类状态从未出现在 HTTP 响应中,包括侧边栏是否打开、当前选中的是哪个标签页、草稿模态框的标记、编辑器周围的边框样式、斜杠命令菜单是否显示,以及那些短暂有效的布局偏好设置。
  • TanStack Query负责管理服务器状态。所有以后端为数据来源的内容都属于这一范畴:文档、用户、权限、计费标志,以及任何在数据变更后需要重新获取的内容。
  • 实时通道用于更新缓存,而非创建第三个存储层。Socket.io和Hocuspocus发送的事件会直接修改Query的缓存或Y.js中的文档。仅仅因为数据会实时变化,就无需在Zustand中为同一列表创建重复副本。
  • 一个简单的测试就能解决大多数疑问:如果某个值在页面刷新时会丢失且没人会在意,那它就是UI状态;而如果丢失了就需要重新获取,那就是服务器状态。

    边界模糊时会出现的两种问题

    SSR之后标签页出现闪烁或不一致现象

    常见的初始处理方式是:先通过getServerSideProps在服务器端获取数据,再结合React Server Components与客户端fetch操作,将文档列表同步到客户端的Zustand中。在强制刷新时,初始显示的是服务器端的数据,随后Zustand会初始化为空状态,之后又重新填充数据。用户会看到画面一闪而过。更糟糕的是,两个打开的标签页可能会显示不同的列表,因为Zustand存储没有缓存标识的概念,它只是单纯的内存状态。

    TanStack Query正是为解决这个问题而设计的。它的initialData选项以及数据脱水和补水功能,能够让客户端获取到服务器端渲染的精确内容。Zustand则无法提供类似功能,因为它从一开始就不是为缓存而设计的。

    永不回滚的乐观更新

    第二种故障模式是手工实现的乐观更新。某个组件调用set()在Zustand中显示新的文档标题,触发状态变更,但如果请求失败却忘了恢复旧值。TanStack Query的onMutate、onError和onSettled回调能为你提供可预测的回滚路径。而在Zustand中自定义的乐观更新方式往往缺乏严谨性且容易出错。

    将状态变更操作移至Query中,仅用Zustand来处理“重命名对话框已打开”这类状态,就能避免大部分此类错误。

    在单仓库中组织状态

    在Turborepo风格的工作空间中,其结构大致如下:

    apps/web → Next.js app
    packages/ui → shared components (ShadCN-ish)
    packages/state → Zustand stores + Query keys/hooks
    packages/api → shared types / Prisma-ish DTOs
    

    最重要的决定是将查询键和Zustand存储都放在packages/state中。如果每个功能文件夹都自行设计键的命名规则,缓存失效处理就会变得难以把握。

    仅限于编辑器界面的Zustand存储

    下面的存储只记录编辑器周围界面所需的信息:侧边栏的显示状态、斜杠菜单以及当前激活的侧面板。每个字段都有一个简单的设置函数,而resetChrome则能在不修改侧边栏设置的情况下关闭临时界面。需要注意的是,示例代码中的字符串字面量使用了排版引号,这样是无法编译的;在自行修改时请将其替换为普通引号。

    // packages/state/src/ui-store.ts
    import { create } from ‘zustand’
    type EditorChromeState = {
     sidebarOpen: boolean
     slashMenuOpen: boolean
     activePanel: ‘comments’ | ‘outline’ | null
     setSidebarOpen: (open: boolean) => void
     setSlashMenuOpen: (open: boolean) => void
     setActivePanel: (panel: EditorChromeState[‘activePanel’]) => void
     resetChrome: () => void
    }
    export const useEditorChrome = create<EditorChromeState>((set) => ({
     sidebarOpen: true,
     slashMenuOpen: false,
     activePanel: null,
     setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }),
     setSlashMenuOpen: (slashMenuOpen) => set({ slashMenuOpen }),
     setActivePanel: (activePanel) => set({ activePanel }),
     resetChrome: () =>
     set({ slashMenuOpen: false, activePanel: null }),
    }))
    

    存储中未包含的内容同样重要。没有文档,没有currentUser,也没有从套接字复制来的连接状态。如果仅仅是为了客户端用户体验而设计,那么一个用于存储连接状态的简单结构是可以接受的,不过通常这类信息可以从服务提供方获取。然而,文档列表绝不能存储在这里。

    查询钩子、键生成器以及安全的重命名机制

    文档模块定义了一个键生成器、列表查询功能以及重命名操作。documentKeys对象会生成分层结构的类型化键:all为根节点,list和detail则在其基础上延伸。列表查询会使用30秒的staleTime参数。重命名操作遵循标准的乐观序列:取消正在进行的文档获取请求,对旧值进行快照保存,将新标题写入缓存,出错时恢复快照状态,而在请求处理完成后则使详细信息及整个文档树失效。与存储模块类似,该代码片段中使用了排版引号和省略号来替代展开运算符,且导入语句缺少结尾引号,使用前需对这些问题进行修正。

    // packages/state/src/documents.ts
    import { useMutation, useQuery, useQueryClient } from ‘@tanstack/react-query
    export const documentKeys = {
     all: [‘documents’] as const,
     list: (workspaceId: string) =>
     […documentKeys.all, ‘list’, workspaceId] as const,
     detail: (id: string) =>
     […documentKeys.all, ‘detail’, id] as const,
    }
    export function useDocuments(workspaceId: string) {
     return useQuery({
    queryKey: documentKeys.list(workspaceId),
     queryFn: () => fetchDocuments(workspaceId),
     staleTime: 30_000,
     })
    }
    export function useRenameDocument() {
     const qc = useQueryClient()
    return useMutation({
     mutationFn: ({ id, title }: { id: string; title: string }) =>
     renameDocument(id, title),
     onMutate: async ({ id, title }) => {
     await qc.cancelQueries({ queryKey: documentKeys.detail(id) })
     const prev = qc.getQueryData(documentKeys.detail(id))
     qc.setQueryData(documentKeys.detail(id), (old: any) =>
     old ? { …old, title } : old,
    )
     return { prev }
     },
     onError: (_err, { id }, ctx) => {
     if (ctx?.prev) qc.setQueryData(documentKeys.detail(id), ctx.prev)
    },
     onSettled: (_data, _err, { id }) => {
     qc.invalidateQueries({ queryKey: documentKeys.detail(id) })
     qc.invalidateQueries({ queryKey: documentKeys.all })
     },
     })
    }
    

    键生成器实际承担的工作比看上去的更多。由于在单仓库中所有地方都导入了相同的键,套接字处理程序和UI钩子都会指向完全一致的缓存条目。绝不可能出现某个应用更新了其数据存储而共享包却毫无察觉的情况。使documentKeys.all失效也能达到相同效果,因为Query是按键的前缀进行匹配的,这样只需一次调用就能将所有列表及其下的详细条目标记为过期。如需更深入地了解如何以这种方式组织Query层,请参阅从queryOptions到rollbacks:构建TanStack Query数据层。

    将实时更新集成到模型中

    在当前架构中,Hocuspocus负责处理协作编辑文档,而Socket.io则用于传输较轻量的数据,如用户在线状态和通知。一种初步的可行设计是使用usePresenceStore来同步来自Socket的完整成员列表,同时通过REST接口加载相同成员的查询缓存。将同一列表保留两份并保持同步,正是容易引发严重故障的重复处理方式。

    更合理的职责划分如下:

    1. 初始成员列表来自诸如useWorkspaceMembers这样的查询钩子。
    2. 在线状态事件仅更新临时性字段,如在线状态或光标颜色,这些数据可存储在紧凑的Zustand切片中,或按用户ID索引存储在各个组件的状态中。
  • 结构变化,例如有新成员加入工作空间,会像 REST 变更操作一样更新或使查询缓存失效。
  • 协作内容始终保存在由 Y.js 和 Tiptap 管理的 Y.Doc 中。为方便起见而将完整的 ProseMirror JSON 复制到 Zustand 中,会导致文档出现两种不同的表示形式,这些形式最终会不一致,而且通常会在最不合适的时刻出现差异。

    来自协作编辑器的性能经验

    选择特定数据片段而非读取整个存储

    只有当 useEditorChrome((s) => s.sidebarOpen) 的参数值发生变化时,才会重新渲染对应组件。若不指定选择器直接调用该钩子,则会监听存储中的所有字段,因此只要在模态框中输入内容并更新了任意字段,都可能重新渲染与之无关的界面面板。这个建议看似显而易见,但人们仍容易无意中使用全存储监听机制。

    避免将高频光标操作交由 React 处理

    实时光标是协作编辑器中常见的陷阱。如果每次光标移动都要通过 Zustand 传递到 React 并触发布局更新,编辑器就会变得迟缓。Y.js 正是为处理这类快速变化且短暂存在的数据而设计的,因此应让它负责光标操作,将 React 状态保留给变化较慢的界面元素。

    将 staleTime 视为产品决策

    staleTime 应体现特定界面中过期数据的危害程度。在医疗类 SaaS 界面这类情况下,过时的权限可能带来危险,此时应设置较低的值,并在界面重新获得焦点时立即使数据失效。而对于很少变化的文档列表,较高的值则能节省网络流量。统一的 staleTime: 0 设置会让某个 Next.js 应用产生不必要的频繁请求,却没有任何好处。

    更新服务器状态,而非 UI 装饰层

    使用 SSR 或 RSC 时,应在服务器端处理 Query 数据,以确保首次渲染结果正确;除非有特殊原因,否则应始终将 Zustand 保留在客户端。在服务器端渲染 UI 装饰层状态往往不值得增加如此多的复杂性。

    保持分离结构的约定

    一些团队约定的作用,远超过额外的抽象层所能实现的效果:

    • 维护一个统一的 documentKeys 模块,并在钩子、套接字处理程序以及测试中均导入该模块。
    • 绝不在 Zustand action 中进行数据获取操作。需要访问网络的 action 实际上应使用 mutation 钩子,而非 set() 方法。
    • 功能模块可以自行维护 UI 存储。在编辑器旁使用 useSlashCommandStore 没有问题;但跨应用共享的服务器实体则不应如此处理。
    • 从 packages/api 中导入共享类型。每当重命名 DTO 发生变化时,Query 钩子与 Prisma 请求数据也会同步更新,同时缓存修复将不再依赖类型不明确的字符串。

    其目标并非复杂的状态管理策略,而是一条让疲惫的工程师在深夜仍能正确应用的规则。

    认证会话与功能标志也是服务器状态

    在 Next.js SaaS 应用中,会话处理往往是导致状态重复的常见原因。无论是 Cookie、JWT 还是 NextAuth 风格的对象,会话本身都属于服务器状态,应用界面中显示的用户资料同样属于服务器状态。为了加快导航栏的渲染速度而使用 Zustand 对用户信息进行缓存,会形成第二个数据源,而一旦权限发生变化——比如收到邀请、角色升级或用户更换所属组织——这个数据源就会立即过时。

    更稳妥的解决方案是:

    • 在服务器端,尽可能通过认证辅助函数来读取会话信息。
    • 在客户端,通过如 useMe 这样的 Query 钩子来暴露用户信息,并使用 ['me'] 这类稳定的键值。
  • Zustand可以存储activeWorkspaceId,因为选择工作区属于用户界面层面的操作。既然成员关系数组已经存在于Query中,Zustand就不应再存储该数组。
  • 当套接字通知角色变更时,需使['me']及相关工作区键失效。通过手动修改Zustand中的多个字段并希望没有遗漏,正是权限漏洞产生的原因。

    功能标志也遵循相同逻辑。API提供的标志属于Query范畴,而用于调试的本地覆盖值可以存放在Zustand中,前提是该存储的名称必须明确,例如useDevFlagOverrides,这样就不会有人将其与生产环境的数据混淆。

    权衡取舍及何时无需采用此方法

    将 Zustand 与 TanStack Query 同时使用意味着需要学习两个库,这对刚加入项目的工程师来说确实是个负担。在拉取请求模板中附上一段简短的 STATE.md 文件来解释这种分离方式,很快就能带来好处。

    有些状态确实同时涉及这两个系统,比如选中的文档。解决方法是仅在 Zustand 中存储 id,再通过 useQuery 或 getQueryData 来读取 entity。若将实体复制到 UI 存储中,又会引发重复问题。

    对于只有单个表单的小型营销网站而言,这种架构显得过于复杂。但对于需要实时编辑功能、受身份验证保护的工作区以及共享单仓库包的产品来说,它很快就会成为必需。

    新应用上线时的检查清单

    1. 从一开始就安装 TanStack Query 并定义一个明确的键生成函数。
    2. 为 shell 和 UI chrome 创建各自的 Zustand 存储。
    3. 将 socket 事件和 Hocuspocus 事件连接到 queryClient.setQueryData 和 invalidateQueries。
    4. 拒绝那些在 Zustand 内部获取数据的 pull request。
    5. 在优化其他内容之前,先分析编辑器路由重新渲染的频率。

    核心要点

    • 通过判断哪些状态在刷新时可以消失、哪些必须在标签页切换后仍然存在来确定各自的所有权;前者属于 UI 状态,后者属于服务器状态。
    • 实时事件只是输入到 Query 缓存或 Y.Doc 中,绝不能成为创建并行存储的理由。
    • 统一的键生成函数是确保在应用程序、包及 socket 处理程序之间实现可靠失效机制的关键。
    • 当 UI 选择关联到服务器数据时,应存储标识符而非实体本身。
  • 将光标等快速变化的界面数据完全置于 React 之外。