切换日光/暗黑模式
035. 详情保存、弹窗服务与受控状态
学习目标
这一节继续完善 AI 简历前端封装,重点是详情页保存、弹窗服务和组件状态。
学完后,你应该能理解:
useDetail为什么要封装reload和save;- 选择模板为什么可以做成一个异步服务;
- 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.info、Modal.confirm。
这些方法用起来方便,但有一个问题:它们可能不在当前 React 树里渲染。
结果就是弹窗内容拿不到当前页面的 context。
例如页面里通过 context 注入了:
- HTTP 客户端;
- 当前用户;
- token;
- layout 类型;
- 全局服务。
静态弹窗里可能拿不到这些。
为什么 context 很重要
不同 layout 下的请求行为不同。
例如:
pages页面请求要带 token;private页面请求也要带对应 token;public页面请求不应该带登录 token。
如果弹窗丢了 context,它内部请求数据时就可能用错 HTTP 客户端。
选择模板弹窗需要请求模板列表,所以它必须继承当前页面的请求上下文。
Root Render Service
为了解决弹窗 context 问题,可以封装一个 root render service。
它的思路是:
- 在当前应用上下文里维护一组动态渲染内容;
- 打开弹窗时往这组内容里添加一个弹窗组件;
- 这个弹窗仍然被当前 React Provider 包裹;
- 弹窗关闭时从渲染列表里移除。
这样弹窗内容就不会丢失当前页面的 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 功能更容易组合。