Skip to content

035. 详情保存、弹窗服务与受控状态

学习目标

这一节继续完善 AI 简历前端封装,重点是详情页保存、弹窗服务和组件状态。

学完后,你应该能理解:

  • useDetail 为什么要封装 reloadsave
  • 选择模板为什么可以做成一个异步服务;
  • Ant Design 静态 Modal 为什么会丢上下文;
  • 弹窗服务如何保留当前页面的 HTTP、用户和 token;
  • 为什么服务型弹窗可以用 await 获取结果;
  • Modal 确认按钮为什么要支持异步 loading;
  • 受控组件、非受控组件和默认值有什么区别。

useDetail 的核心能力

详情页最核心的动作通常只有两个:

  • 加载当前记录;
  • 保存当前记录。

reload 负责重新请求当前记录数据。

save 负责调用新建或更新接口,把当前表单数据保存到后端。

保存成功后,还要把后端返回的数据同步回页面状态和表单对象。

这样可以保证:

  • 页面显示的是后端确认后的数据;
  • 表单值和状态值一致;
  • 保存后生成的 ID、时间等字段能回填回来。

为什么要封装详情页逻辑

很多详情页都长得很像:

  • 根据 ID 判断新建或编辑;
  • 初始化数据;
  • 加载详情;
  • 绑定表单;
  • 保存;
  • 处理错误;
  • 控制 loading。

如果每个页面都手写,会有大量重复代码。

封装 useDetail 后,写详情页会更快,也更规范。

熟悉前端的人可以直接理解这套封装;如果前端基础弱,建议自己手写一遍,理解路由、表单和请求如何串起来。

列表页和详情页各有用途

有些业务更适合在表格里直接增删改查。

例如项目管理,可以在表格里新建、行内编辑、批量编辑。

但有些业务字段多、交互复杂,就更适合详情页。

AI 简历属于后者。

它不仅有表单字段,还会有源码、预览、AI 对话、图片和导出操作,所以详情页封装很重要。

选择模板是一个服务

选择模板可以设计成一个异步函数。

调用方式像这样:

ts
const template = await selectTemplate();

这个函数内部打开弹窗,让用户选择模板。

如果用户选择了模板,就返回模板数据。

如果用户取消,就抛出一个取消异常或返回特定状态。

这样业务代码会很清爽:它不需要关心弹窗怎么渲染,只需要等待选择结果。

Ant Design 静态 Modal 的问题

Ant Design 有静态 Modal 方法,例如 Modal.infoModal.confirm

这些方法用起来方便,但有一个问题:它们可能不在当前 React 树里渲染。

结果就是弹窗内容拿不到当前页面的 context。

例如页面里通过 context 注入了:

  • HTTP 客户端;
  • 当前用户;
  • token;
  • layout 类型;
  • 全局服务。

静态弹窗里可能拿不到这些。

为什么 context 很重要

不同 layout 下的请求行为不同。

例如:

  • pages 页面请求要带 token;
  • private 页面请求也要带对应 token;
  • public 页面请求不应该带登录 token。

如果弹窗丢了 context,它内部请求数据时就可能用错 HTTP 客户端。

选择模板弹窗需要请求模板列表,所以它必须继承当前页面的请求上下文。

Root Render Service

为了解决弹窗 context 问题,可以封装一个 root render service。

它的思路是:

  1. 在当前应用上下文里维护一组动态渲染内容;
  2. 打开弹窗时往这组内容里添加一个弹窗组件;
  3. 这个弹窗仍然被当前 React Provider 包裹;
  4. 弹窗关闭时从渲染列表里移除。

这样弹窗内容就不会丢失当前页面的 context。

弹窗服务

弹窗服务会基于 root render service 封装更好用的方法。

例如:

  • 打开确认框;
  • 打开选择模板弹窗;
  • 打开 AI 对话弹窗;
  • 打开抽屉;
  • 返回用户选择结果。

它的目标是把“弹窗交互”变成一个可以 await 的服务。

后面很多功能都会用这种方式:

  • 选择简历模板;
  • AI 返回字段配置;
  • AI 返回排序结果;
  • 表格 Vibe 配置。

异步确认和 loading

确认按钮经常需要执行异步逻辑。

例如:

  • 保存数据;
  • 请求接口;
  • 校验表单;
  • 等 AI 返回。

弹窗服务需要支持:

  • 点击确定后进入 loading;
  • 等异步函数完成;
  • 成功后关闭弹窗;
  • 失败或返回 false 时保持弹窗打开。

这样用户不会在请求未完成时重复点击,也不会因为校验失败直接关闭弹窗。

取消关闭的场景

例如选择模板时,如果用户没有选择任何模板就点确定,弹窗不应该关闭。

这时确认函数可以返回 false

弹窗服务看到返回 false,就保持弹窗打开,并展示提示。

这种行为对表单弹窗非常常见。

为什么不用全局 HTTP 单例

可以把 HTTP 客户端做成全局对象,但这里不推荐。

原因是不同页面区域可能需要不同请求上下文。

例如:

  • public 请求不带 token;
  • pages 请求带 pages token;
  • private 请求带 private token。

如果所有弹窗都直接引入同一个全局 HTTP,很容易在不同 layout 下混用登录态。

通过 context 注入,弹窗和页面能使用同一套上下文。

受控组件

受控组件的值由外部状态控制。

典型写法是:

tsx
<Input value={value} onChange={handleChange} />

组件本身不决定值是什么,它只通过 onChange 把变化告诉外部。

这种方式最适合表单统一管理。

非受控组件和默认值

有时只想给组件一个初始默认值,而不想外部持续控制它。

这时可以使用非受控方式,例如 defaultValue

如果只传 value,但没有正确处理 onChange,输入框可能无法编辑。

所以封装组件时,要明确支持哪种模式:

  • 完全受控;
  • 非受控;
  • 既支持 value,也支持 defaultValue

ModelState 的意义

项目会封装类似 ModelState 的 Hook,用来处理受控和非受控混合场景。

它的目标是让组件既可以被外部控制,也可以自己维护内部状态。

这类封装在复杂组件里很常见。

例如聊天输入框、弹窗字段、编辑器内容,都可能需要同时支持默认值和受控值。

这一节的重点

这一节有三条主线。

详情页:

  • reload 加载;
  • save 保存;
  • 保存后同步表单和状态。

弹窗服务:

  • 保留 React context;
  • 支持 await
  • 支持异步确认和 loading。

组件状态:

  • 理解受控;
  • 理解非受控;
  • 用 Hook 统一管理复杂状态。

这些封装会让后面的 AI 简历页面和表格 Vibe 功能更容易组合。

AI Agent 课程学习文档。