团队弹窗组件规范

基于 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') 唤起
  • 不用管 loadingonSubmit 期间 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-formController 做统一封装,自动注入 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.jstsconfig.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            # 本文件

八、注意事项

  1. Modal 必须包裹在 ModalProvider 内
    否则 useModal() 会抛出错误:useModal must be used within a ModalProvider

  2. 每个 Modal 必须有唯一 id
    同一页面内 id 冲突会导致状态互相覆盖。

  3. 弹窗组件必须声明在 JSX 树中
    不能只在 open 时才动态创建组件,否则 Hooks 调用顺序会乱。

  4. onSubmit 必须是异步函数或返回 Promise
    这样 Modal 才能自动管理 confirmLoading 状态。

  5. 不要在外部控制 visible
    弹窗显隐完全由 ModalProvider 内部状态驱动,外部只需调用 open/close

  6. 样式覆盖
    如需调整错误色或表单项间距,修改 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>
posted @ 2026-06-03 02:51  HuangBingQuan  阅读(29)  评论(0)    收藏  举报