组件抽象的边界与陷阱:从 DataTable 到 FormModal 的设计权衡

164 阅读2分钟

一、为什么要抽象组件?

中后台系统开发过程中,有两个关键词始终绕不过去:表格表单

广告平台项目上线初期,我们发现大量页面的结构都是“筛选 + 表格 + 操作弹窗”,大多数逻辑重复率极高,于是我主导抽象了两个核心组件:

  • <DataTable />:用于列表展示、分页、筛选等
  • <FormModal />:用于弹窗表单,支持动态字段、回填、提交反馈

这两个组件帮团队节省了大量重复开发时间。但在抽象过程中,也踩了不少坑,本文详讲实战经验。


二、DataTable 的抽象核心逻辑

✅ 目标:支持配置化生成业务列表页面

使用方式:

<DataTable
  :columns="columns"
  :search-schema="searchSchema"
  :request="fetchList"
  row-key="id"
/>

组件内部设计如下:

<template>
  <n-form :model="searchParams">
    <n-grid :cols="3">
      <n-form-item v-for="item in searchSchema" :label="item.label" :key="item.field">
        <component :is="item.component" v-model:value="searchParams[item.field]" v-bind="item.props" />
      </n-form-item>
    </n-grid>
    <n-button @click="loadData">查询</n-button>
  </n-form>

  <n-data-table :columns="columns" :data="data" :loading="loading" :row-key="rowKey" />
  <n-pagination :page="page" :page-size="pageSize" @update:page="onPageChange" />
</template>

逻辑部分封装在 setup:

const searchParams = reactive({})
const data = ref([])
const page = ref(1)
const pageSize = ref(10)
const loading = ref(false)

const loadData = async () => {
  loading.value = true
  const res = await props.request({
    ...toRaw(searchParams),
    page: page.value,
    pageSize: pageSize.value
  })
  data.value = res.list
  loading.value = false
}

三、抽象的陷阱:越通用越不通用?

在实际应用中我们发现:

  • 表头有复杂渲染需求(比如单元格中包含图标、tooltip、按钮)
  • 某些页面需要批量操作 checkbox,但有些不需要
  • 筛选区域有时要嵌套 tabs、折叠面板

于是抽象组件变成了“定制化魔改”:

<DataTable :custom-filter-slot="true">
  <template #filter>
    <!-- 重新写一套复杂筛选 UI -->
  </template>
</DataTable>

🚨教训:不应追求“万能组件”,而是80% 通用 + 20% 灵活扩展

解决方案:引入 slot + 插件式 hooks 扩展机制。


四、FormModal:弹窗表单的典范设计

目标是让使用者这样写:

<FormModal
  :model="formModel"
  :schema="[
    { label: '广告名称', field: 'name', component: 'n-input', rules: [{ required: true }] },
    { label: '投放时间', field: 'time', component: 'n-date-picker', props: { type: 'daterange' } }
  ]"
  :on-submit="handleSubmit"
/>

内部实现核心代码:

<n-modal v-model:show="visible">
  <n-form :model="model" :rules="rules" ref="formRef">
    <n-form-item v-for="item in schema" :label="item.label" :path="item.field">
      <component :is="item.component" v-model:value="model[item.field]" v-bind="item.props" />
    </n-form-item>
  </n-form>
  <n-space>
    <n-button @click="onCancel">取消</n-button>
    <n-button type="primary" @click="onConfirm">提交</n-button>
  </n-space>
</n-modal>

表单校验逻辑:

const onConfirm = async () => {
  const form = formRef.value
  if (!form) return
  try {
    await form.validate()
    emit('submit', model)
    visible.value = false
  } catch (e) {
    // 校验失败
  }
}

五、组件抽象的边界策略

  1. 抽象重复逻辑,保留可插拔能力:比如加载数据、分页、表单校验这类逻辑可以固定,但 UI 应该开放插槽
  2. 保持 schema 简洁但可扩展
// 支持动态渲染组件
{
  field: 'gender',
  label: '性别',
  component: 'n-select',
  props: {
    options: [ { label: '男', value: 1 }, { label: '女', value: 2 } ]
  }
}
  1. 状态外置,逻辑内聚:所有组件状态由外部传入(如 model、visible),组件内部只负责行为逻辑
  2. 插槽优先,配置次之,逻辑最后兜底:slot > schema > props

六、总结:组件抽象的本质是「降低重复成本 + 提高上下文适应力」

真正有价值的组件抽象,不是写个超大配置表,而是:

  • 重复性逻辑统一封装,减少冗余
  • 样式和结构保持一致性,提高用户体验
  • 对不确定性内容保持开放,利于团队协作与业务扩展