团队弹窗组件规范
基于 React Context + react-hook-form 的三层架构弹窗表单规范,根治团队内弹窗表单写法不一致的问题。
一、背景与痛点
项目使用 Vite + React 18 + TypeScript + antd,react-hook-form。团队内弹窗表单存在以下乱象:
| 乱象 | 后果 |
|---|---|
有人用受控组件 useState,有人用非受控 ref |
同项目内多种数据流并存,维护困难 |
| 校验规则各自写正则 | 规则不统一,手机号/邮箱校验到处复制粘贴 |
| 错误提示样式百花齐放 | 有的用 antd Form.Item help,有的手写 div,视觉不统一 |
| 提交 loading 处理不统一 | 有的忘记加 loading,有的异常时弹窗意外关闭 |
| 弹窗关闭后数据残留 | 再次打开时还能看到上次填写的内容 |
本规范通过 ModalProvider / Modal / useModal 三层架构 + Controller / RULE / ErrorMessage 三个统一原子,将弹窗表单彻底标准化。
二、Before vs After(同一需求对比)
以下以分享标签管理弹窗为例,展示规范前团队内的典型写法与规范后的统一写法。
2.1 规范前(典型乱象写法)
// ❌ 规范前:useState 管 visible,手动管 form,各自为战
const ShareModal = ({ visible, onCancel }: { visible: boolean; onCancel: () => void }) => {
const [users, setUsers] = useState<string[]>([]);
const [permission, setPermission] = useState<string>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async () => {
if (!users.length) {
setError('请选择至少一名成员'); // 错误提示各自写
return;
}
if (!permission) {
message.error('请选择权限'); // 有的用 message,有的用 div
return;
}
setLoading(true);
try {
await api.submit({ users, permission });
onCancel(); // 异常时弹窗也关了,数据丢失
} catch (e: any) {
message.error(e.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (visible) {
setUsers([]); // 每次打开手动 reset,容易漏
setPermission(undefined);
setError('');
}
}, [visible]);
return (
<Modal visible={visible} onCancel={onCancel} confirmLoading={loading} onOk={handleSubmit}>
<div style={{ marginBottom: 8 }}>选择成员</div>
<Select mode="multiple" value={users} onChange={setUsers} />
{error && <div style={{ color: 'red', fontSize: 12 }}>{error}</div>}
<div style={{ marginBottom: 8, marginTop: 16 }}>权限设置</div>
<Select value={permission} onChange={setPermission}>
<Select.Option value="view">仅查看</Select.Option>
<Select.Option value="edit">可编辑</Select.Option>
</Select>
</Modal>
);
};
上面代码的问题对应表:
| 乱象 | 具体表现 |
|---|---|
| 受控/非受控混用 | 全用 useState 管字段,代码膨胀,同项目内有人写 ref 有人写 state |
| 校验规则各自写正则 | if (!users.length) 硬编码在组件里,换个人写又是一种判断方式 |
| 错误提示样式百花齐放 | 有的用 message.error,有的手写 <div style={{ color: 'red' }}> |
| 提交 loading 处理不统一 | 手动 setLoading(true/false),异常时弹窗直接关闭,用户填的数据没了 |
| 弹窗关闭后数据残留 | 靠 useEffect 手动 setUsers([]),忘加一项就残留上次内容 |
2.2 规范后(统一写法)
// ✅ 规范后:只声明 UI 和规则,显隐/loading/reset/校验/错误样式全自动
import { Modal, FormController, RULE } from '@/components/modal';
import { Select } from 'antd';
export default function ShareModal() {
return (
<Modal
id="share"
title="分享标签管理"
width={600}
onSubmit={async (values) => {
await api.submit(values);
}}
defaultValues={{ users: [], permission: undefined }}
>
<FormController
name="users"
label="选择成员"
rules={RULE.arrayNotEmpty('请选择至少一名成员')}
>
<Select mode="multiple" showSearch placeholder="请输入搜索">
{/* 选项 */}
</Select>
</FormController>
<FormController
name="permission"
label="权限设置"
rules={RULE.required('请选择权限')}
>
<Select placeholder="请选择权限">
<Select.Option value="view">仅查看</Select.Option>
<Select.Option value="edit">可编辑</Select.Option>
</Select>
</FormController>
</Modal>
);
}
改进点:
- 不用管
visible,页面用open('share')唤起 - 不用管
loading,onSubmit期间confirmLoading自动管理 - 不用管
reset,每次打开自动执行form.reset({ ...defaultValues, ...params }) - 不用管错误样式,
FormController自动渲染统一错误提示 - 校验规则用
RULE.xxx,不再硬编码,团队内完全一致
三、三层架构
2.1 架构总览
┌─────────────────────────────────────────┐
│ ModalProvider (Context 层) │ ← 维护全局弹窗状态栈
│ ┌───────────────────┐ │
│ │ useModal (Hook 层) │ ← 提供 open/close API
│ └───────────────────┘ │
│ ┌───────────────────┐ │
│ │ Modal (组件层) │ ← 封装 antd Modal + useForm
│ └───────────────────┘ │
└─────────────────────────────────────────┘
2.2 各层职责
| 层级 | 文件 | 职责 |
|---|---|---|
| Context 层 | ModalProvider.tsx |
维护全局弹窗状态数组 [{ id, open, params }],提供 open/close/closeAll 方法 |
| Hook 层 | useModal.ts |
供业务代码调用以打开/关闭弹窗;供 Modal 组件查询自身状态 |
| 组件层 | Modal.tsx |
基于 antd Modal 封装,内部自动创建 useForm 实例,统一处理校验、loading、reset、关闭 |
三、三个统一原子
3.1 FormController — 统一表单字段绑定
对 react-hook-form 的 Controller 做统一封装,自动注入 field,向下兼容所有 antd 表单组件。
<FormController name="email" label="邮箱" rules={RULE.requiredEmail()}>
<Input placeholder="请输入邮箱" />
</FormController>
特性:
- 自动处理
value/onChange绑定 - 空值安全处理:
mode="multiple"的 Select 默认回退到[] - 自动透传
status="error"到 antd 组件 - 默认展示
<ErrorMessage />,可通过showError={false}关闭
3.2 RULE — 统一校验规则
集中定义常用校验规则,团队内不再各自写正则。
RULE.required('自定义提示')
RULE.email()
RULE.phone()
RULE.maxLength(20)
RULE.rangeLength(6, 20)
RULE.arrayNotEmpty('请至少选择一项')
RULE.custom((value) => value > 0 || '必须大于 0')
已内置规则:
| 规则 | 说明 |
|---|---|
required |
必填 |
email |
邮箱格式 |
requiredEmail |
必填 + 邮箱 |
phone |
中国大陆手机号 |
minLength |
最小长度 |
maxLength |
最大长度 |
rangeLength |
长度范围 |
number |
数字(含负数/小数) |
positiveNumber |
正数 |
integer |
整数 |
positiveInteger |
正整数 |
url |
URL 格式 |
arrayNotEmpty |
数组非空 |
custom |
自定义校验函数 |
3.3 ErrorMessage — 统一错误展示
自动从 formState.errors 读取错误并渲染统一样式。
<ErrorMessage name="email" />
样式已固化在 style.less 中:
- 颜色:
#ff4d4f - 字号:
14px - 上边距:
4px
四、使用规范
4.1 声明弹窗(组件文件)
弹窗组件只负责 UI + 校验规则声明,不控制显隐。
// src/pages/user/components/UserCreateModal.tsx
import { Modal, FormController, RULE } from '@/components/modal';
import { Input, Select } from 'antd';
export default function UserCreateModal() {
return (
<Modal
id="user-create"
title="新建用户"
width={560}
defaultValues={{ status: 'active' }}
onSubmit={async (values) => {
await api.createUser(values);
}}
>
<FormController name="name" label="用户名" rules={RULE.required()}>
<Input placeholder="请输入用户名" />
</FormController>
<FormController name="email" label="邮箱" rules={RULE.requiredEmail()}>
<Input placeholder="请输入邮箱" />
</FormController>
<FormController name="phone" label="手机号" rules={RULE.phone()}>
<Input placeholder="请输入手机号" />
</FormController>
<FormController name="status" label="状态" rules={RULE.required()}>
<Select
placeholder="请选择状态"
options={[
{ label: '启用', value: 'active' },
{ label: '禁用', value: 'inactive' },
]}
/>
</FormController>
</Modal>
);
}
4.2 触发弹窗(页面文件)
页面通过 useModal().open(id, params) 命令式唤起。
// src/pages/user/index.tsx
import { Button } from 'antd';
import { useModal } from '@/components/modal';
import UserCreateModal from './components/UserCreateModal';
export default function UserPage() {
const { open } = useModal();
return (
<div>
<Button onClick={() => open('user-create')}>
新建用户
</Button>
{/* 声明在 JSX 树中,生命周期由 React 正常管理 */}
<UserCreateModal />
</div>
);
}
4.3 传递初始参数
open('user-edit', { userId: '123', name: '张三' });
Modal 内部会在打开时自动执行:
form.reset({ ...defaultValues, ...params });
五、核心 API 参考
5.1 ModalProvider
<ModalProvider>{children}</ModalProvider>
放置位置: 应用根节点(已在 App.tsx 中包裹)。
5.2 Modal
<Modal
id="unique-id" // 必填,弹窗唯一标识
title="弹窗标题" // 必填
width={560} // 可选,默认 520
defaultValues={{}} // 可选,表单默认值
formMode={true} // 可选,默认 true,是否启用表单模式
okText="确定" // 可选
cancelText="取消" // 可选
onSubmit={async (values) => {}} // 可选,表单提交回调
onCancel={() => {}} // 可选,取消回调
>
{children}
</Modal>
5.3 useModal
const { open, close, closeAll, isOpen, getParams, states } = useModal();
open('id', params); // 打开弹窗,可携带参数
close('id'); // 关闭弹窗
closeAll(); // 关闭所有弹窗
isOpen('id'); // 查询弹窗是否打开
getParams('id'); // 获取弹窗参数
六、关键设计决策
6.1 声明式 + 命令式混合
- 声明式:
<Modal>组件声明在 JSX 树中,保证组件生命周期正常,Hooks 使用不受限制。 - 命令式:
open('id', params)唤起弹窗,符合业务直觉,避免层层传递visible状态。
6.2 弹窗内部自动 reset
每次 open 时,Modal 内部调用 form.reset({ ...defaultValues, ...params }),彻底避免关闭后再打开残留上次数据的问题。
6.3 提交统一处理
- 点击确定 → 先
trigger()全量校验 - 校验不通过 → 展示错误,不进入提交
- 校验通过 → 进入
onSubmit,期间confirmLoading自动管理 - 业务抛异常 → 弹窗不关闭,保留用户已填数据,便于修改后再次提交
6.4 多弹窗并发
Provider 维护的是状态数组,不同 id 的弹窗可同时存在,互不影响。
6.5 不使用 antd Form
本规范基于 react-hook-form 做数据管理,不依赖 antd Form。原因:
react-hook-form性能更优(非受控,减少重渲染)- 校验逻辑可完全复用到非 antd 场景
- 团队内只学一套 API
6.6 路径别名
已在 vite.config.js 和 tsconfig.json 中配置 @/ 指向 src/,规范组件统一通过 @/components/modal 引入。
七、文件结构
src/components/modal/
├── ModalProvider.tsx # Context + 状态管理
├── Modal.tsx # antd Modal 封装 + useForm
├── useModal.ts # Hook
├── FormController.tsx # Controller 封装
├── ErrorMessage.tsx # 错误展示
├── formRules.ts # 统一校验规则
├── style.less # 基础样式
├── index.ts # 统一导出
└── README.md # 本文件
八、注意事项
-
Modal 必须包裹在 ModalProvider 内
否则useModal()会抛出错误:useModal must be used within a ModalProvider。 -
每个 Modal 必须有唯一
id
同一页面内id冲突会导致状态互相覆盖。 -
弹窗组件必须声明在 JSX 树中
不能只在open时才动态创建组件,否则 Hooks 调用顺序会乱。 -
onSubmit必须是异步函数或返回 Promise
这样 Modal 才能自动管理confirmLoading状态。 -
不要在外部控制
visible
弹窗显隐完全由ModalProvider内部状态驱动,外部只需调用open/close。 -
样式覆盖
如需调整错误色或表单项间距,修改style.less中的 Less 变量即可。
九、典型场景示例
场景 1:纯展示弹窗(无表单)
<Modal id="notice" title="系统公告" formMode={false}>
<p>系统将于今晚 22:00 进行维护。</p>
</Modal>
场景 2:编辑弹窗(带初始值)
// 页面中
open('user-edit', { name: '张三', email: 'zs@example.com' });
// 弹窗中
<Modal id="user-edit" title="编辑用户" onSubmit={handleUpdate}>
<FormController name="name" rules={RULE.required()}>
<Input />
</FormController>
</Modal>
场景 3:自定义校验
<FormController
name="confirmPassword"
label="确认密码"
rules={RULE.custom((value, formValues) =>
value === formValues.password || '两次输入的密码不一致'
)}
>
<Input.Password />
</FormController>

浙公网安备 33010602011771号