React + Ant Design 后台项目:63 个业务组件的分层实践

后台项目做久了,页面通常会不断重复这些内容:搜索表单、分页表格、图片上传、动态表单、多语言配置、权限控制和主题切换。

如果每个页面都从 FormTableUpload 开始拼,短期看起来很快,长期却会出现参数命名不一致、交互细节不统一、同一类问题重复修复等情况。

这篇文章整理一个实际 React 后台项目中 src/components 下的组件。项目基于 React 18、TypeScript、Ant Design 5 和 ahooks,目前共有 63 个组件目录、90 个文件。它不是一套追求大而全的 UI 库,而是一套从业务页面中逐步生长出来的组件体系。

本文主要回答三个问题:

  1. 这些组件如何分层?
  2. 表单、表格、上传等核心能力如何组合?
  3. 业务组件持续增加后,如何避免过度封装?

一、先看整体分层

整个组件目录可以分成六类:

分层 组件 解决的问题
表单字段层 FieldsProFormItemMyInputMyCheckboxMyRadioMultipleSelect 统一字段协议、受控值和表单接入方式
日期与业务值层 MyDatePickerMyRangePickerMyTzDatePickerMyTzRangePickerCashField 处理时间戳、时区、金额和百分比等数据语义
表格与搜索层 ProTableProFormProPaginationTableOptions 把查询、分页、刷新、操作栏组成完整列表页
动态表单层 FormListTableFormTableFormListEditTable 处理可增删、可编辑、可排序的复杂表单
上传与展示层 ImgsUploadFilesUpload、OSS 上传、编辑器、终端、复制和 Tooltip 等 封装文件交互及常用展示能力
应用基础设施层 主题、主色、消息、路由权限、自动刷新等 Provider 提供跨页面状态和统一运行环境

从依赖方向看,业务页面不会直接依赖所有底层组件,而是优先使用几个组合入口:

text 复制代码
业务页面
├── ProTable
│   ├── ProForm
│   │   └── ProFormItem
│   │       └── Fields
│   ├── ProPagination / SimplePagination
│   └── AutoRefresh / TableOptions / EditColumns
├── FormList / TableForm / EditTable
├── ImgsUpload / FilesUpload / LanguageUpload
└── ThemeProvider / MessageProvider / RouteAuthProvider

这种结构的关键不是组件数量,而是让依赖保持从上到下:页面负责业务,组合组件负责流程,基础组件负责单一交互。

二、表单体系:用统一协议连接所有字段

1. Fields 是字段注册中心

Fields 根据 type 选择实际渲染的控件。目前支持 Ant Design 基础控件,也支持项目自己的业务控件,例如:

  • 基础字段:InputTextAreaInputNumberSelectTreeSelectCascader
  • 选择字段:RadioRadioGroupCheckboxCheckboxGroupSwitch
  • 日期字段:DatePickerRangePickerTimePickerMyDatePickerMyRangePickerMyTzRangePicker
  • 业务字段:VipLevelCurrencyCashCoinPercent
  • 复杂字段:ImgsUploadLanguageUploadLanguageInputTagsMultipleSelect

增加一种通用字段时,只需要扩展 FieldType 和字段映射,不必修改每个业务表单。

2. ProFormItem 统一 Form.Item 与字段

ProFormItem 负责把 Ant Design 的 Form.Item、栅格布局和具体字段连接起来:

tsx 复制代码
<ProFormItem<Input>
  type="Input"
  name="keyword"
  label="关键词"
  placeholder="请输入关键词"
  rules={[{ required: true, message: '请输入关键词' }]}
  fieldProps={{ allowClear: true, maxLength: 30 }}
/>

它保留了 Form.Item 原有的校验能力,同时增加了:

  • fieldProps:传递给实际字段
  • definedField:接入暂未注册的自定义字段
  • hide:按业务条件隐藏表单项
  • useColspancolProps:控制栅格布局
  • placeholder:直接向字段透传占位提示

泛型参数也很实用。写 ProFormItem<Input> 后,fieldProps 会获得对应控件的类型提示,减少"属性传错但编译不报错"的情况。

3. ProForm 管理表单区域,而不是只包一层 Form

ProForm 在字段之外统一了后台页面常见的功能区:

  • 搜索和重置按钮
  • 自定义工具按钮
  • 回车搜索和节流
  • 整体禁用
  • 默认值与重置行为
  • 自动刷新入口
  • Row/Col 栅格布局

字段可以通过 fields 配置,也可以直接写在 children 中。简单页面使用配置更紧凑,复杂页面保留 JSX 的表达能力。

三、日期、金额和多语言:组件要表达数据语义

只统一 UI 样式还不够。后台项目更容易出错的是"页面上的值"和"接口里的值"含义不同。

1. 日期与时区

日期组件分成两层:

  • MyDatePickerMyRangePicker:处理项目约定的日期值与禁选范围
  • MyTzDatePickerMyTzRangePicker:在此基础上增加 tzDiff,显示时减去时区差,回传时再加回
tsx 复制代码
<ProFormItem<MyTzRangePickerProps>
  type="MyTzRangePicker"
  name="activeTime"
  label="生效时间"
  fieldProps={{ tzDiff: 8 * 60 * 60 }}
/>

这样业务页面不需要到处手写 dayjs(value).add(...)。时区换算被限制在组件边界内,提交给接口的值仍保持统一语义。

2. 金额与比例

CurrencyFieldCashFieldCoinFieldPercentField 都建立在 InputNumber 之上,但分别表达货币、现金、虚拟币和百分比。它们可以统一小数位、步进、前后缀和格式化规则。

看似只是几个薄封装,实际价值是把业务单位写进类型和组件名,避免页面中出现大量难以理解的 addonAfter 与倍率换算。

3. 多语言输入与上传

LanguageInput 默认支持中文、英文、日语、印尼语、越南语等 13 种语言,也支持自定义语言集合和隐藏部分语言:

tsx 复制代码
<LanguageInput
  langs={{ zh: '中文', en: 'English' }}
  hideLangs={[]}
  placeholder="请输入活动名称:"
  inputProps={{ maxLength: 200, showCount: true }}
/>

它的值不是字符串,而是一个语言键值对象:

ts 复制代码
{
  zh: '夏日活动',
  en: 'Summer Campaign'
}

LanguageUploadLanguageUploadAni 使用同样的思路,分别处理多语言资源和带动画配置的多语言资源。

四、ProTable:把列表页的完整流程封装起来

后台最常见的页面通常由"查询表单 + 表格 + 分页"组成。ProTable 的目标不是替换 Ant Design Table,而是管理三者之间的状态流转。

tsx 复制代码
const actionRef = useRef<IProTableRef>(null)
const [rows, setRows] = useState<User[]>([])

async function request(pageInfo: IPageInfos, formInfo: IProFormParams) {
  const result = await queryUsers({ ...pageInfo, ...formInfo })
  setRows(result.list)

  return {
    ...pageInfo,
    total: result.total,
  }
}

<ProTable<User>
  ref={actionRef}
  rowKey="id"
  columns={columns}
  dataSource={rows}
  request={request}
  pageSize={20}
  params={{ fixedParams: { status: 1 } }}
  proFormProps={{ fields }}
/>

request 同时接收分页信息和表单值,返回新的分页状态。组件内部负责搜索、重置、分页切换和加载状态之间的协调。

通过 ref,页面还能执行明确的命令:

ts 复制代码
actionRef.current?.reload()   // 保留页码和查询条件刷新
actionRef.current?.reSearch() // 保留查询条件,回到第一页
actionRef.current?.reset()    // 重置查询条件并刷新
actionRef.current?.setParams({ keyword: 'React' })

围绕列表页还拆出了几个职责单一的组件:

  • ProPagination:标准分页
  • SimplePagination:上一页/下一页式长列表分页
  • AutoRefresh:按指定秒数自动查询
  • TableOptions:查看、编辑、删除等行操作
  • EditColumns:控制列显示与拖拽排序
  • MyTableMyTables:配合不同表格 Hook 的轻量入口

这里最值得保留的设计是:分页和表单状态由组合组件管理,但请求实现和数据源仍由页面持有。组件没有吞掉业务数据流,因此出现特殊需求时仍有扩展空间。

五、动态表单:不要强迫所有场景使用同一种结构

复杂后台经常出现"可增删列表""表格内编辑""拖拽排序"等需求。项目中没有用一个巨型组件覆盖所有情况,而是拆成几种明确形态:

组件 使用场景
FormList 普通纵向动态表单,可添加、删除和配置行操作
TableForm 以表格形式展示表单字段
TableFormList 结合 Form.List 的动态表格表单
TableFormDraged 支持拖拽排序的表格表单
EditTable 直接编辑数据源中的单元格或行

这类组件最容易过度抽象。一个实际经验是:只有"值结构、增删规则、校验方式"相近时才应该复用;如果只是视觉上都像表格,强行合并反而会产生大量布尔参数和条件分支。

六、上传组件:从交互层到存储层分开处理

上传是这个项目中组件数量最多的模块之一,可以分成三层。

1. 表单友好的通用入口

  • ImgsUpload:单图/多图、预览、数量限制、尺寸与格式校验、压缩、自定义按钮
  • FilesUpload:单文件/多文件、文件大小和格式限制、自定义请求
tsx 复制代码
<ImgsUpload
  multi
  count={6}
  size={5}
  accept=".jpg,.jpeg,.png,.webp"
  validate
  validateSize
  limitWidth={1200}
  limitHeight={800}
  preview
  customRequest={uploadImage}
  tips="最多 6 张,每张不超过 5MB"
/>

value 会根据 multi 保持为 stringstring[],因此可以直接放进 Form.Item

2. 基础上传实现

  • CommonUploadFile:普通单文件上传
  • CommonUploadFiles:由表单接管值的文件上传
  • CommonListsUpload:多图片列表上传

3. OSS 存储适配

  • ImgsOssUpload
  • CommonUploadOssFile
  • CommonListOssUpload

这一层负责对象存储相关流程。把 OSS 适配与图片列表 UI 分开后,未来替换存储服务时,业务表单和展示组件不需要一起重写。

不过,这也是当前最需要继续收敛的区域。多个上传组件之间已经出现一部分重复的校验、预览和状态转换逻辑。更合适的演进方式是先抽取上传 Hook 与值转换函数,再逐步合并组件入口,而不是立刻重写所有调用页面。

七、基础展示和应用级 Provider

除了核心表单和表格,目录里还有一组"小而稳定"的组件:

  • CellTooltips:表格单元格省略与 Tooltip 展示
  • TextClipboard:文本复制
  • Tags:可编辑标签列表
  • RadioSelect:单选与下拉组合交互
  • SpaceItemWhiteSpace:间距与排列
  • SwitchItem:按值进行条件渲染
  • SlideFade:页面切换动画
  • IconifySvgIcon:图标入口
  • CustomEditor:富文本编辑器及粘贴图片处理
  • Terminal:基于 xterm 的终端视图
  • Outlets:路由出口包装

应用级状态则交给 Provider:

  • AntdConfigProvider:Ant Design 全局配置
  • ThemeProvider:明暗主题
  • ColorPrimaryProvider:主题主色
  • MessageProviderMessageContextMessageContent:统一消息提示
  • AutoRefreshProvider:跨组件刷新状态
  • QaProvider:环境或配置状态
  • RouteAuthProvider:路由与菜单权限

Provider 适合保存跨页面、跨层级且确实全局的状态。普通表单值和表格数据仍应留在页面或业务 Hook 中,否则全局上下文会很快变得难以追踪。

八、受控与非受控:统一自定义字段的接入方式

MyInputMyCheckboxMyRadioMyTimeMultipleSelect、日期组件、多语言组件和上传组件都遵循同一个约定:

ts 复制代码
type ControllableProps<T> = {
  value?: T
  defaultValue?: T
  onChange?: (value: T) => void
}

内部通过 ahooks 的 useControllableValue 同时支持受控与非受控模式。这样组件既能独立使用,也能被 Ant Design Form 接管。

这条约定比"组件内部用了哪个 Hook"更重要。只要所有自定义字段都遵守相同的值协议,ProFormItemForm.List 和普通页面状态就可以自由组合。

九、组件完整索引

为了方便查找,按目录列出目前的 63 个组件:

text 复制代码
表单与业务字段
Fields, ProFormItem, ProForm, MyInput, MyCheckbox, MyRadio, MyTime,
RadioSelect, MultipleSelect, Tags, VipLevel, CashField, CoinField,
CurrencyField, PercentField, LanguageInput, LanguageUpload, LanguageUploadAni

日期时间
MyDatePicker, MyRangePicker, MyTzDatePicker, MyTzRangePicker

表格与动态表单
ProTable, MyTable, MyTables, ProPagination, SimplePagination,
TableOptions, EditColumns, AutoRefresh, FormList, TableForm,
TableFormList, TableFormDraged, EditTable

上传
ImgsUpload, FilesUpload, CommonUploadFile, CommonUploadFiles,
CommonListsUpload, ImgsOssUpload, CommonUploadOssFile, CommonListOssUpload

展示与工具
CellTooltips, TextClipboard, Iconify, SvgIcon, SpaceItem, WhiteSpace,
SwitchItem, SlideFade, CustomEditor, Terminal, Outlets

Provider 与基础设施
AntdConfigProvider, ThemeProvider, ColorPrimaryProvider,
AutoRefreshProvider, MessageProvider, MessageContext, MessageContent,
QaProvider, RouteAuthProvider

十、把一次页面开发拆开看

前面的分层解决"有哪些组件"的问题,下面用一个典型的配置列表页,把这些组件在一次真实开发中的协作过程拆开。

1. 页面先定义业务数据,而不是先定义组件

假设页面需要完成以下事情:

  • 按关键词和状态查询
  • 按创建时间范围查询
  • 展示用户、状态和更新时间
  • 支持新增、编辑、删除
  • 删除后保持当前搜索条件重新查询

页面真正需要维护的是查询字段、表格列、请求方法和操作方法:

tsx 复制代码
type UserRecord = {
  id: string
  nickname: string
  phone: string
  status: number
  createdAt: string
}

const columns: ColumnsType<UserRecord> = [
  { title: '用户昵称', dataIndex: 'nickname' },
  { title: '手机号', dataIndex: 'phone' },
  { title: '状态', dataIndex: 'status', render: renderStatus },
  { title: '创建时间', dataIndex: 'createdAt' },
  {
    title: '操作',
    key: 'option',
    render: (_, record) => (
      <TableOptions
        options={[
          {
            key: 'edit',
            text: '编辑',
            record,
            action: openEdit,
          },
          {
            key: 'delete',
            text: '删除',
            type: 'error',
            record,
            popconfirm: true,
            popconfirmTitle: '确定删除这条记录吗?',
            action: (row) => removeUser(row.id),
          },
        ]}
      />
    ),
  },
]

表单字段可以和列定义放在同一个页面文件中:

tsx 复制代码
const fields: FieldItems[] = [
  {
    key: 'keyword',
    span: 6,
    proFormItem: (
      <ProFormItem<Input>
        type="Input"
        name="keyword"
        label="关键词"
        placeholder="昵称 / 手机号"
      />
    ),
  },
  {
    key: 'status',
    span: 6,
    proFormItem: (
      <ProFormItem<Select>
        type="Select"
        name="status"
        label="状态"
        fieldProps={{ options: STATUS_OPTIONS }}
      />
    ),
  },
  {
    key: 'createdAt',
    span: 8,
    proFormItem: (
      <ProFormItem<RangePicker>
        type="RangePicker"
        name="createdAt"
        label="创建时间"
      />
    ),
  },
]

页面不需要手写 RowCol、搜索按钮、重置按钮,也不需要在每个字段里写一遍 Ant Design 控件和 Form.Item 的连接代码。

2. ProFormItem 的值为什么能被 Form 接管

这里有一个容易误解的地方:ProFormItem 并没有手动读取 Form 的值,它只是把 type 转换成一个实际的 React 节点,再把这个节点放进 Form.Item

tsx 复制代码
<Form.Item name="keyword">
  <Fields type="Input" fieldProps={{ allowClear: true }} />
</Form.Item>

Ant Design Form 会向子组件注入 valueonChange。原生输入框可以直接使用这两个属性;自定义字段则必须遵守相同协议。因此 MyCheckboxMyRadioTagsLanguageInput 和上传组件都要把内部变化通过 onChange 传给外部。

definedField 是这个链路的逃生口:

tsx 复制代码
<ProFormItem
  name="avatar"
  label="头像"
  rules={[{ required: true, message: '请上传头像' }]}
  definedField={<CustomAvatarUpload />}
/>

需要注意,自定义节点虽然可以由 Form.Item 包裹,但它仍然必须支持 valueonChange,否则表单只能展示,无法收集值。

3. ProForm 的搜索与重置发生了什么

当传入 fields 时,ProForm 会给每项补一个响应式栅格。默认栅格是:

text 复制代码
xs: 12, sm: 10, md: 8, lg: 8, xl: 6, xxl: 5

如果某一项设置了 span,就用明确的 span 覆盖默认响应式宽度。hide 的判断发生在渲染阶段,被隐藏的字段不会占用栅格空间。

查询按钮和重置按钮也是可配置的:

tsx 复制代码
<ProForm
  form={form}
  fields={fields}
  formSearchs={{
    showBtns: true,
    showSearch: true,
    showReset: true,
    searchText: '查询用户',
    resetText: '清空条件',
    onSearch: () => tableRef.current?.reSearch(),
    onReset: () => tableRef.current?.reset(),
    gap: 12,
  }}
  enterSearch
  searchThrottle={1000}
  resetThrottle={1000}
/>

enterSearch 会监听键盘回车,并调用搜索回调;searchThrottleresetThrottle 用于避免连续点击导致重复请求。工具按钮则适合放"新增""批量导入""导出"等页面动作:

tsx 复制代码
<ProForm
  tools={[
    { key: 'create', name: '新增用户', onClick: openCreate },
    {
      key: 'import',
      name: '导入',
      hide: !canImport,
      onClick: openImport,
    },
    {
      key: 'delete',
      name: '批量删除',
      danger: true,
      btnProps: { disabled: selectedRowKeys.length === 0 },
      onClick: batchRemove,
    },
  ]}
/>

这里的 hidedisabled 语义不同:hide 用于权限或场景不允许时不渲染可见操作,disabled 用于操作存在但当前状态不可执行。

4. ProTable 的请求生命周期

使用 ProTable 时,可以把一次搜索理解成下面的状态流转:

text 复制代码
点击查询
  -> 读取 Form 值
  -> 清理空值 / 执行 replaces
  -> 根据当前模式确定页码
  -> 设置 loading
  -> 调用 request(pageInfo, formInfo)
  -> 接收 total / nextPage / prevPage
  -> 更新 Table 和 Pagination

request 返回 falseundefined 时,可以用来表示请求被业务逻辑短路,组件不会按普通分页结果继续处理。正常请求一般返回:

ts 复制代码
return {
  page: pageInfo.page,
  pageSize: pageInfo.pageSize,
  total: result.total,
}

常用参数的边界如下:

参数 作用 适合什么时候使用
mountRequest 是否在页面挂载时立即请求 查询条件必须先补齐时设为 false
pageSize 默认每页条数 列表数量和接口默认值不一致时设置
mode normallong 普通页码或只有上一页/下一页的长列表
fixedParams 重置后仍保留的参数 租户、产品、业务类型等固定条件
onceParams 可被重置的初始参数 从路由或上一个页面带来的临时查询值
validate 查询前是否校验表单 查询条件存在日期或数值规则时开启
clearEmptyValue 是否清理空字符串、nullundefined 后端不接受空值参数时开启
replaces 对字段值做提交前转换 去空格、单位转换、手机号格式化

IProTableRef 里的方法也有明确区别:

ts 复制代码
tableRef.current?.reload()   // 保留当前页和搜索条件
tableRef.current?.reset()    // 清掉临时搜索条件并回到第一页
tableRef.current?.reSearch() // 保留搜索条件,但回到第一页
tableRef.current?.setParams({ status: 1 })
tableRef.current?.getParams()
tableRef.current?.getAllParams()
tableRef.current?.validateValues()

例如新增成功后通常用 reload,因为用户仍处在原来的页码和筛选上下文;点击"查询"通常用 reSearch,因为新条件应该从第一页开始看。

十一、表格体系的两条路线

项目里同时存在 ProTableMyTables,它们的定位不完全一样。

ProTable:从页面出发

ProTable 接收 requestproFormPropsparamsref,适合一个页面从零配置查询和表格:

tsx 复制代码
<ProTable
  columns={columns}
  dataSource={dataSource}
  proFormProps={{ fields }}
  request={handleRequest}
  ref={tableRef}
/>

MyTables:从 Hook 结果出发

MyTables 更像 useTables 的视图层。它接收 actionssearchstableResultspagination,适合已经把请求、分页、数据状态抽到 Hook 里的页面:

tsx 复制代码
const tableState = useTables({
  request: queryUsers,
  searchs: { fields },
})

<MyTables
  {...tableState}
  editColumns
  useStoreColumns
  autoRefreshs={{ autoRefresh: true, api: tableState.actions.reload }}
/>

MyTables 还有一个很实用的能力:按当前路由把用户选择的列保存到 localStorage。保存的 key 形如 columnKeys-${pathname},因此不同页面不会互相覆盖。

使用时要注意列的 keydataIndex 必须稳定,否则列配置恢复时找不到对应列。动态生成随机 key 会让列显示设置失效。

TableOptions:让行操作不再散落

行操作的权限和禁用条件最好集中在操作配置中:

tsx 复制代码
<TableOptions
  options={[
    {
      key: 'detail',
      text: '详情',
      record,
      action: openDetail,
    },
    {
      key: 'edit',
      text: '编辑',
      record,
      hide: record.status === 0,
      action: openEdit,
    },
    {
      key: 'remove',
      text: '删除',
      type: 'error',
      record,
      disabled: record.status === 1,
      action: removeUser,
    },
  ]}
/>

这样列的 render 只负责传递当前行数据,操作的显示规则不会和表格 JSX 混成一大段条件判断。

十二、动态表单的字段路径

动态表单最容易出错的不是按钮,而是字段 name。普通表单字段是:

text 复制代码
name = "title"

Form.List name="items" 中第 2 行的 title 则是:

text 复制代码
name = [1, "title"]

TableForm 会根据 fieldData.name 自动拼出这个路径:

tsx 复制代码
<TableForm
  form={form}
  formList
  formListName="items"
  initialValue={[{ title: '', amount: 0 }]}
  fields={[
    {
      key: 'title',
      title: '名称',
      span: 8,
      type: 'Input',
      itemProps: {
        name: 'title',
        rules: [{ required: true, message: '请输入名称' }],
      },
    },
    {
      key: 'amount',
      title: '金额',
      span: 8,
      type: 'Cash',
      itemProps: { name: 'amount' },
    },
    {
      key: 'option',
      title: '操作',
      span: 4,
      isOption: true,
      addDisabled: (_, index) => index !== 0,
      removeDisabled: (_, index) => index === 0,
    },
  ]}
/>

addHideaddDisabledremoveHideremoveDisableddisabled 都支持布尔值或函数。函数能拿到当前行数据和索引,因此可以实现:

  • 第一行不能删除
  • 只有最后一行显示新增按钮
  • 当当前行状态为已发布时禁止编辑
  • 某个字段根据另一列的值动态禁用

render 适合纯展示列。它收到当前行数据、全部数据和索引,可以在不接入 Form.Item 的情况下渲染计算值:

tsx 复制代码
{
  key: 'total',
  title: '小计',
  span: 4,
  render: (row) => `${row?.amount || 0} 元`,
}

如果一列需要参与提交和校验,就用 type / definedField;如果只是展示,优先用 render,不要为了显示值创建一个不可编辑的表单字段。

十三、上传组件的完整状态机

上传组件表面上只有一个按钮,实际包含四组状态:

text 复制代码
选择文件
  -> beforeUpload 校验
  -> uploading
  -> done / error
  -> 写入 value
  -> onUploaded 通知业务

ImgsUpload 为例,beforeUpload 的处理顺序是:

  1. 如果开启 validateSize,先读取图片宽高。
  2. 校验扩展名是否在 accept 中。
  3. 校验文件大小是否小于 size MB。
  4. 执行 fileValidate 自定义校验。
  5. 如果 compress 开启,调用压缩函数并把压缩后的文件交给 Upload。
  6. 返回 true 继续上传,返回 false 中断上传。
tsx 复制代码
<ImgsUpload
  multi
  count={3}
  accept=".png,.jpg,.webp"
  validate
  validateSize
  limitWidth={800}
  limitHeight={600}
  size={2}
  compress
  quality={0.7}
  fileValidate={async (file) => {
    const isSquareName = file.name.startsWith('banner-')
    return isSquareName
  }}
  fileValidateTip="文件名必须以 banner- 开头"
  onUploaded={(url, file) => {
    console.log('上传成功', url, file.name)
  }}
/>

value、multi 和 count 的关系

组件允许单图和多图两种值形态:

ts 复制代码
// multi=false
value?: string

// multi=true
value?: string[]

count 是总数量限制,不是单次选择数量。multi=true 时,组件会在已有列表长度小于 count 时追加成功结果,并在达到数量后隐藏上传入口。

因此表单初始化值必须和 multi 对应:

tsx 复制代码
<Form initialValues={{ cover: '', banners: [] }}>
  <Form.Item name="cover">
    <ImgsUpload />
  </Form.Item>
  <Form.Item name="banners">
    <ImgsUpload multi count={5} />
  </Form.Item>
</Form>

FilesUpload 与 ImgsUpload 的区别

两者的值协议相似,展示策略不同:

  • ImgsUpload 使用图片预览,支持尺寸校验和压缩
  • FilesUpload 使用文本文件名列表,适合文档、音频、表格等资源
  • FilesUploadreturnFileCb 可以在校验通过后直接把原始 File 交给业务而不上传
  • 两者都支持 fileValidateonUploadedremove 和自定义上传按钮

例如只读取文件、不走默认上传:

tsx 复制代码
<FilesUpload
  validate
  returnFileCb={(file) => {
    parseExcel(file)
  }}
  uploadText="选择 Excel 文件"
  accept=".xlsx,.xls"
/>

普通上传与 OSS 上传的区别

普通上传组件把文件交给后端 action,由后端完成存储后返回资源路径。OSS 组件则使用 customRequest,先获取临时 OSS 配置,再由浏览器直接调用对象存储客户端完成上传。

text 复制代码
普通上传:浏览器 -> 后端上传接口 -> 文件服务 -> 返回 URL
OSS 上传:浏览器 -> 获取临时配置 -> OSS -> 返回 URL

OSS 上传要额外考虑:

  • 临时凭证的有效期
  • 初始化失败重试
  • 上传中的禁用状态
  • 删除后如何同步表单值
  • 是否允许把原始 File 返回给业务

普通上传适合由后端统一鉴权和处理的场景;OSS 上传适合大文件、图片较多或希望减少后端转发压力的场景。两者不应该只靠一个布尔参数硬塞进同一个组件。

十四、日期、时区和接口值的边界

MyTzDatePickerMyTzRangePicker 的核心不是换一个日期控件,而是明确"页面显示时区"和"接口保存时区"的边界。

以范围选择为例:

text 复制代码
接口值:UTC 或统一基准时间
  -> 组件内部按 tzDiff 加法处理默认值
  -> 页面显示用户时区
用户选择页面时间
  -> 组件内部按 tzDiff 减法转换
  -> onChange 回传统一基准时间

如果 tzDiff = 8 * 60 * 60,表示接口值与页面显示值相差 8 小时。这个参数的单位是秒,不能把小时数 8 直接传进去。

tsx 复制代码
<MyTzDatePicker tzDiff={8 * 60 * 60} />
<MyTzRangePicker tzDiff={8 * 60 * 60} />

使用时最好在接口层也写清楚单位和时区约定,避免同一个字段在不同页面分别加减时区。组件只能保证自己的转换对称,不能替业务系统修复不一致的后端约定。

十五、Provider 为什么单独放一层

组件库里有些能力不属于某个页面,而是整个应用都需要:主题、主色、消息、路由权限和自动刷新。

ThemeProvider 与 ColorPrimaryProvider

这两个 Provider 都使用 Context + Reducer:

tsx 复制代码
const { theme, dispatch } = useThemeContext()

dispatch({ type: 'set', payload: ThemeMode.Dark })

状态修改后还会同步到设置 Store,因此刷新页面可以恢复用户选择。这样的状态适合放在 Provider,因为 Header、侧边栏、表格和表单都可能读取它。

MessageProvider

项目没有让每个组件都直接依赖 Ant Design message,而是通过 MessageContext 提供统一方法:

tsx 复制代码
const message = useMessage()

message.success('保存成功')
message.error('保存失败', { duration: 4000 })
message.loading('正在提交')

Provider 内部使用 Portal 把消息渲染到 document.body,再用定时器控制显示时长。这样调用方式稳定,未来切换消息视觉样式时不需要修改所有业务页面。

RouteAuthProvider

路由权限组件从路由模块中收集配置,根据当前用户角色生成菜单,同时监听当前路径:

text 复制代码
路由模块
  -> import.meta.glob 收集路由
  -> 根据 route.handle.role 过滤
  -> 生成菜单树
  -> 根据 auth 和 token 判断是否需要登录
  -> 设置 document.title

这里有一个重要边界:前端路由权限只能控制导航和页面进入体验,真正的数据权限仍然必须由后端校验。不能因为菜单里隐藏了按钮,就认为接口安全了。

十六、组件参数很多时,如何写示例

上传组件是最典型的例子。只写一个最简单的按钮示例是不够的,至少需要覆盖以下场景:

最简单的单图

tsx 复制代码
<ImgsUpload />

受控单图

tsx 复制代码
const [cover, setCover] = useState('banner.png')

<ImgsUpload value={cover} onChange={setCover} />

多图、数量和预览

tsx 复制代码
<ImgsUpload
  multi
  count={6}
  value={banners}
  onChange={setBanners}
  width={120}
  height={80}
  preview
  showImgs
/>

自定义按钮和提示

tsx 复制代码
<ImgsUpload
  uploadBtn={<Button icon={<UploadOutlined />}>上传封面</Button>}
  tips="推荐尺寸 1200 × 800"
  tipStyle={{ color: '#888' }}
  uploadStyles={{ marginTop: 8 }}
/>

文件大小和格式校验

tsx 复制代码
<FilesUpload
  size={20}
  accept=".pdf,.doc,.docx"
  plusSizeTip="文档不能超过 20MB"
  errorAcceptTip="只支持 PDF 和 Word 文档"
/>

接入 Form.Item

tsx 复制代码
<Form.Item
  name="attachments"
  label="附件"
  rules={[{ required: true, message: '请上传附件' }]}
>
  <FilesUpload multi count={5} />
</Form.Item>

参数示例的价值在于把组件的真实契约暴露出来:哪些参数控制展示,哪些参数控制校验,哪些回调在上传完成后触发,哪些值由 Form 接管。组件越复杂,示例越应该覆盖这些组合,而不是只展示默认状态。

十七、回头看:业务组件库最重要的四条原则

1. 先统一协议,再统一外观

value / defaultValue / onChange、分页参数、请求返回值这些协议,比统一圆角和颜色更有长期价值。

2. 组合组件要管理流程,不要吞掉业务

ProTable 可以管理查询、重置和分页,但数据请求、列定义和数据源仍交给页面。这样既减少重复代码,也保留特殊业务的控制权。

3. 不要因为名字相似就立刻合并

上传组件和动态表单组件看起来相似,但值结构、校验和交互可能不同。先抽公共 Hook、工具函数和类型,再决定是否合并 UI 入口。

4. 示例页和类型定义也是组件的一部分

组件参数越多,越需要可运行的示例。当前项目已经为 ImgsUploadFilesUploadLanguageInputTagsProTable 等通用组件补充了集中示例页。相比只写一份属性表,示例更能暴露受控值、表单接入和边界参数是否真的好用。

总结

这套组件体系并不完美:上传模块仍有重复实现,部分历史组件还可以统一命名和类型,一些复杂表单也需要继续补充测试。但它已经把后台项目中最常见的重复劳动沉淀成了稳定边界。

组件化的目标不应该是让目录里的组件越来越多,而是让业务页面越来越接近业务本身。当新增一个列表页时,开发者只需要关心查询字段、列定义、请求和操作;当新增一个配置表单时,只需要关心值结构和校验规则,这套抽象才真正产生了价值。

相关推荐
大家的林语冰2 小时前
👍 超越 ESLint,Oxc 优先采用 TypeScript 7,Rust 和 Go 梦幻联动!
前端·javascript·typescript
不好听6133 小时前
React 记忆化三兄弟:memo、useMemo、useCallback 到底在缓存什么
react.js
想要成为糕糕手4 小时前
🚀 在浏览器里跑 DeepSeek-R1?WebGPU 端侧推理实战(五)—— 中断、重置、缓存与流式生成
前端·react.js·llm
embedded大铭4 小时前
ai时代上站记录
typescript
AI砖家5 小时前
React Native 开发规范与完整流程指南
javascript·react native·react.js
小林ixn5 小时前
全栈项目实战:前端独立开发,不再傻等后端接口
前端·javascript·react.js
张元清5 小时前
React useElementSize Hook:用 ResizeObserver 实时追踪元素宽高 (2026)
javascript·react.js
breeze jiang5 小时前
React Todos 前端独立开发全解:用 vite-plugin-mock + axios 封装,再也不等后端接口
前端·react.js·状态模式