TTeamDesk 学习手册
本章目录 展开导航 ↓

16 · Zustand 草稿:未提交的内容应该放在哪里#

客户写了一半补充说明,切去看另一张工单,再回来发现内容没了,会很沮丧。草稿用于保留这些尚未提交的编辑内容。本章解释当前 store 怎样创建、按什么范围保存、何时恢复,以及哪些内容仍然不该放进它。

1. 先建立四种状态的分工#

状态 当前负责者 事实来源
工单、负责人、已发送回复 Prisma + TanStack Query 数据库,Query 是浏览器缓存
未发送回复、未创建工单内容 Zustand 草稿 当前浏览器会话中的用户编辑
创建表单字段与错误 Ant Design Form 表单实例
列表筛选、页码 nuqs URL 中的浏览条件

不要再把已发送回复复制一份到 Zustand 作为第二套事实来源。草稿与服务器数据是不同生命周期的对象,发送成功才跨过这条边界。

创建表单中,Form 负责实时字段与校验,onValuesChange 将当前字段保存成草稿快照;回复输入框则直接以草稿文本作为受控值。使用同一个 store,不等于所有表单都要改用同一种绑定方式。

2. 两层 key 怎样避免草稿串用#

stores/drafts.ts 的两个函数:

ts
// 源码节选
export const draftStorageKey = (userId: string) => `teamdesk-drafts:${userId}`;
export const draftKey = (teamId: string, ticketId: string) =>
  `${teamId}:${ticketId}`;

外层存储名区分账号,内部条目区分团队与工单:

text
teamdesk-drafts:用户A
  ├─ 甲团队:工单1  → 回复文本 + 请求编号
  ├─ 甲团队:工单2  → 另一份回复
  └─ 乙团队:new    → 新建工单的表单字段

new 是创建表单用的特殊条目,不是数据库工单 ID。一个团队当前只有这一份新建工单草稿,不是同时保存任意多张待创建工单。

账号、团队、工单任何一层丢掉,都可能误展示另一处的编辑内容。加 key 是组织客户端数据,不是授权;能访问某份本地草稿不能让服务端接受越权请求。

3. 为什么 store 要用工厂创建#

createDraftStore(userId, storage?) 使用 zustand/vanilla 的 createStore 创建独立实例,而不是在模块顶层建立一个所有账号共享的可变全局对象。

DraftProvider 用:

tsx
// 源码节选:独立 store 实例在该 Provider 生命周期内保持稳定
const [store] = useState(() => createDraftStore(userId));

Context 传递 store 实例,useDraftStore(selector) 再通过 Zustand 的 useStore 订阅所需部分。比如只读 state.drafts[key],能让组件围绕自己的草稿变化更新;这不等于永远不会因其他 props 或父组件重新渲染。

TeamWorkspace 按用户、团队和角色给外层 Provider 设置 key,上下文变化会重新挂载这组子树。重新挂载不是删除持久草稿,新 store 还可以从当前账号存储恢复相关条目。

拓展阅读为什么不直接把 store 放模块全局?

服务端渲染进程会处理多个请求,模块级可变状态的生命周期可能长于一个用户页面;浏览器切账号时也容易残留上一个人的实例。这里用工厂加 Provider 约束实例范围,另外用账号 key 管持久存储。两层边界共同工作,不能仅靠给组件加 'use client' 就忽略首次渲染和实例归属。

4. persist 为什么使用 sessionStorage#

关键配置是:

ts
// 源码节选:src/stores/drafts.ts
name: draftStorageKey(userId),
storage: createJSONStorage(() => storage ?? sessionStorage),
partialize: (state) => ({ drafts: state.drafts }),
skipHydration: true,

persist 将选中的 state 序列化到存储。partialize 只保存 drafts,不会把 hydrated 标记和 save / remove 方法当作持久内容。

当前是 sessionStorage:通常可在同一标签页刷新后恢复,生命周期属于浏览器页面会话;它不是跨设备同步,也不是长期云草稿。新窗口、复制标签页、恢复会话等行为受浏览器机制影响,不要把它描述成“跨标签实时共享”或“永远关闭即绝对清除”。

教程阅读站自己的学习进度使用 localStorage,那是另一项产品功能,和业务草稿不共用存储。

5. 什么是这里的 hydration,为什么延迟恢复#

这个词在这里表示“把持久存储中的草稿合并回 store”。它与 React 首屏 HTML 对齐有关,但不是两者完全等同。

首屏先用空 state,挂载后再执行:

tsx
// 源码节选:src/features/tickets/draft-provider.tsx
useEffect(() => {
  void Promise.resolve(store.persist.rehydrate()).finally(() =>
    store.setState({ hydrated: true }),
  );
}, [store]);

skipHydration: true 把恢复时机交给 Provider。这样服务端首次 HTML 与浏览器首帧都不依赖 sessionStorage 中的私有内容,降低首屏不一致的风险。

恢复完成前,创建按钮和回复输入被禁用,避免用户刚输入就被稍后恢复的旧草稿覆盖。hydrated: true 表示恢复流程已结束,不能理解为“存储一定可用、所有内容一定成功保存”。

当前实现没有完整的存储配额错误提示或草稿加密机制。浏览器禁用存储、存储写入失败等情况,不在“已验证损坏数据可丢弃”的保证范围内。

6. 为什么浏览器存储也要校验#

merge 用 Zod 检查恢复对象:文本长度、请求 UUID、表单字段形状都有限制。校验失败则恢复为空草稿集合,避免把坏数据或意外结构当可用草稿。

这项校验并不是对每个坏条目都单独修复:当前如果整份 drafts 对象不满足 schema,结果会丢弃该次恢复的整份草稿集合。JSON 解析失败或底层存储异常也不能和“JSON 可解析但结构错误”混为同一测试场景。

store 同时限制最多 100 项:保存时把刚编辑条目移到对象末尾,超量删除最前项;恢复时也只取最多 100 项。这是按最近编辑顺序控制条目数,不是每项带时间戳的过期策略,也不是严格的字节配额。

7. 创建与回复各在什么时候保存、清除#

动作 新建工单草稿 回复草稿
编辑输入 onValuesChange 保存表单字段 保存文本并生成请求编号
关闭弹窗 / 切去别处 保留 按原 key 保留
成功提交 resetFields 并移除条目 移除条目,回复页回 1
提交失败 保留以便修正 保留文本和编号
点击清空草稿 删除条目,并重置表单值 删除当前回复条目
工单关闭 不是同一编辑入口 隐藏发送输入,未发送草稿仍保留
主动退出登录 删除当前标签页该账号的草稿存储 同左

新建表单打开时先填默认字段,再覆盖 draft?.ticket。单纯改 initialValues 不足以在每次重开弹窗时恢复动态内容,当前实现使用 form.setFieldsValue。

save(key, draft) 会替换这个 key 的草稿对象,而非自动深合并。调用时要传本次需要保留的整份条目;不要只保存 { requestId } 却期待旧文本自动留下。

8. 测试与练习#

tests/drafts.test.ts 注入内存 storage,验证同账号重建 store 后恢复、不同账号 / 团队 / 工单不串用、clearStorage 后重建不恢复,以及 JSON 形状错误时丢弃数据。它不依赖数据库,也不模拟实际退出按钮、100 项淘汰边界、持久化 requestId 的重试链路或真实浏览器存储异常;这些不能由“草稿测试通过”一并推出。

练习:为什么不能只持久化回复 text,不保存 requestId?

动手练习查看参考答案

参考答案:刷新后虽然文字还在,原发送意图的编号却丢了;如果之前已经写库但响应丢失,再发时产生新编号就可能重复。当前回复草稿把文本和编号一起保存,以衔接上一章的幂等合同。

这条链路是:用户编辑 → 按账号 / 团队 / 工单保存草稿 → sessionStorage 持久化 → 挂载后校验恢复 → 成功提交才清除;真实业务结果仍由服务端与数据库确认。

把知识变成自己的理解

完成本章练习,再继续下一站。

TEAMDESK HANDBOOK · 从一张工单,走进全栈开发

搜索教程

输入关键词,搜索全部章节。

阅读设置

外观
正文字号

从一张工单出发,读懂数据如何流转。

设置与进度仅保存在当前浏览器。