实现一个「配置驱动」的动态表格组件(Vue + Element UI)

一、背景与痛点

后台系统里有一类很常见的需求:页面的表格需要「可配置」。

  • 业务表字段特别多,动辄几十上百个 col,但每个页面要展示的列不一样;
  • 同一份数据,A 页面只显示 3 列、B 页面要显示 8 列,列标题还可能被业务方改来改去;
  • 每次调整都要改代码、重新发版,运维成本极高。

于是我们希望把「表格长什么样」抽成一份配置:

后端返回「列定义(标题 + 类型 + 宽度 + 选项)」+「行数据」,前端完全按配置动态渲染成一张表格;调整列只需改配置,无需发版。

这就是本文要实现的 动态表格组件 TableDesigner :支持动态增删行列、自定义列标题、多种单元格类型(文本 / 日期 / 下拉 / 图片上传),并能把整张表的配置导出/持久化。


二、最终效果

页面由三部分组成:

  1. 工具栏 :添加行、添加列、导出配置 三个按钮 + 一个「列类型」下拉(新增列时决定用哪种单元格)。
  2. 表格 :
    • 序号列 + 动态列 + 操作列;
    • 每个动态列的表头右侧有 编辑 / 删除 两个操作;
    • 单元格按列类型渲染:文本框、日期选择、下拉选择、图片上传;图片上传列还会显示缩略图;
    • 操作列有 删除 按钮删掉整行。
  3. 编辑列弹窗 :改列标题、列宽度、列类型;当类型是「下拉选择」时,还能在线编辑选项列表。

三、核心设计思路

做动态表格,关键是想清楚「用数据描述界面」:

概念 数据结构 说明
列 columns: Column[] 每一列是一个配置对象,描述标题、字段名、类型、宽度、下拉选项
行 tableData: Row[] 每一行是一条记录,row[col.prop] 就是该列的值

列配置结构:

js 复制代码
{
  label: '新列1',          // 表头标题
  prop: 'col_1730000000000', // 字段名(唯一,用时间戳生成)
  type: 'input',           // 单元格类型:input | date | select | upload
  width: 150,              // 列宽
  options: []              // 仅 select 类型使用
}

三个关键实现点:

  1. 动态渲染列 :v-for 遍历 columns 生成 <el-table-column>;
  2. 自定义表头 :用 #header 插槽在表头里塞「编辑 / 删除」;
  3. 单元格类型分发 :用 <component :is> 根据 col.type 动态挂载不同输入组件。

四、详细实现

4.1 列类型 & 初始化数据

js 复制代码
data() {
  return {
    // 支持的列类型
    columnTypes: [
      { label: '文本框', value: 'input' },
      { label: '日期选择', value: 'date' },
      {
        label: '下拉选择',
        value: 'select',
        defaultOptions: [
          { label: '选项1', value: '1' },
          { label: '选项2', value: '2' },
          { label: '选项3', value: '3' }
        ]
      },
      { label: '图片上传', value: 'upload' }
    ],
    currentColumnType: 'input', // 新增列时选中的类型
    columns: [],
    tableData: [],
    editDialogVisible: false,
    editingColumn: { label: '', prop: '', type: 'input', width: 150, options: [] },
    editingColumnIndex: -1
  }
}

4.2 动态渲染列 + 自定义表头

vue 复制代码
<el-table :data="tableData" border>
  <!-- 序号列 -->
  <el-table-column type="index" width="50" align="center" />

  <!-- 动态列 -->
  <el-table-column
    v-for="(col, index) in columns"
    :key="col.prop"
    :prop="col.prop"
    :width="col.width || 150"
  >
    <!-- 表头:标题 + 编辑/删除 -->
    <template #header>
      <div class="column-header">
        <span>{{ col.label }}</span>
        <div class="header-actions">
          <el-link type="primary" @click="editColumn(index)">编辑</el-link>
          <el-link type="danger" @click="deleteColumn(index)">删除</el-link>
        </div>
      </div>
    </template>

    <!-- 单元格:按类型动态挂载组件 -->
    <template #default="scope">
      <component
        :is="getColumnComponent(col.type)"
        :value="scope.row[col.prop]"
        :column="col"
        @input="(val) => handleCellChange(scope.row, col.prop, val)"
      />
    </template>
  </el-table-column>

  <!-- 操作列 -->
  <el-table-column label="操作" width="120">
    <template #default="scope">
      <el-button type="danger" @click="deleteRow(scope.$index)">删除</el-button>
    </template>
  </el-table-column>
</el-table>

4.3 单元格组件分发

把「类型 → 组件」做成一张映射表,想扩展新类型只要往表里加一行:

js 复制代码
getColumnComponent(type) {
  const componentMap = {
    input: 'el-input',
    date: 'el-date-picker',
    select: 'SelectComponent',
    upload: 'UploadComponent'
  }
  return componentMap[type] || 'el-input'
}

el-input、el-date-picker 本身就支持 v-model,所以我们统一用 value prop + input 事件 的约定去驱动它们,自定义组件(下拉、上传)也照这个约定实现,就能无缝替换。

4.4 添加列 / 添加行

⚠️ 这是动态表格最容易踩坑的地方:给已有对象新增属性必须用 this.$set,否则新加的列不会响应式更新,输入框也绑不上。

js 复制代码
// 添加列
addColumn() {
  const newColumn = {
    label: `新列${this.columns.length + 1}`,
    prop: `col_${Date.now()}`,          // 时间戳保证 prop 唯一
    type: this.currentColumnType,
    width: this.currentColumnType === 'upload' ? 200 : 150
  }
  if (this.currentColumnType === 'select') {
    const t = this.columnTypes.find(t => t.value === 'select')
    newColumn.options = [...t.defaultOptions]
  }

  this.columns.push(newColumn)

  // 关键:给每一行补上新列对应的字段(用 $set 保证响应式)
  this.tableData.forEach(row => {
    this.$set(row, newColumn.prop, '')
  })
},

// 添加行
addRow() {
  const newRow = {}
  this.columns.forEach(col => {
    newRow[col.prop] = ''
  })
  this.tableData.push(newRow)
}

4.5 编辑列弹窗

点击表头「编辑」时,深拷贝 当前列到 editingColumn,改完再写回,避免直接改动原对象产生副作用:

js 复制代码
editColumn(index) {
  this.editingColumnIndex = index
  const column = this.columns[index]
  this.editingColumn = {
    ...JSON.parse(JSON.stringify(column)),
    options: column.options ? [...column.options] : [],
    width: column.width || (column.type === 'upload' ? 200 : 150)
  }
  this.$nextTick(() => { this.editDialogVisible = true })
},

saveColumnEdit() {
  if (this.editingColumnIndex > -1) {
    const oldColumn = this.columns[this.editingColumnIndex]
    const newColumn = JSON.parse(JSON.stringify(this.editingColumn))

    // 切到 select 但没选项时兜底
    if (newColumn.type === 'select' && !newColumn.options) {
      const t = this.columnTypes.find(t => t.value === 'select')
      newColumn.options = [...t.defaultOptions]
    }
    // 上传列保证足够宽度
    if (newColumn.type === 'upload' && newColumn.width < 200) {
      newColumn.width = 200
    }

    this.$set(this.columns, this.editingColumnIndex, newColumn)

    // 类型变了 or select 选项变了 → 该列已有数据重置
    if (
      oldColumn.type !== newColumn.type ||
      (oldColumn.type === 'select' &&
        JSON.stringify(oldColumn.options) !== JSON.stringify(newColumn.options))
    ) {
      this.tableData.forEach(row => this.$set(row, newColumn.prop, ''))
    }

    this.editDialogVisible = false
    this.editingColumnIndex = -1
  }
}

弹窗里对「下拉选择」类型还提供在线编辑选项:

vue 复制代码
<template v-if="editingColumn.type === 'select'">
  <el-divider>选项设置</el-divider>
  <div v-for="(option, idx) in editingColumn.options" :key="idx" class="option-item">
    <el-input v-model="option.label" placeholder="选项文本" />
    <el-input v-model="option.value" placeholder="选项值" />
    <el-button type="danger" @click="editingColumn.options.splice(idx, 1)">删除</el-button>
  </div>
  <el-button type="primary" @click="editingColumn.options.push({ label: '', value: '' })">
    添加选项
  </el-button>
</template>

4.6 导出配置(配置持久化的关键)

一整张表的「结构 + 数据」就是一个 JSON,导出即可存库、即可复用:

js 复制代码
exportJson() {
  const config = {
    columns: this.columns,   // 列定义
    data: this.tableData     // 行数据
  }
  const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' })
  const url = URL.createObjectURL(blob)
  const link = document.createElement('a')
  link.href = url
  link.download = 'table-config.json'
  link.click()
  URL.revokeObjectURL(url)
}

五、子组件抽取

把复杂的单元格拆成独立组件,用 value / input 约定对接 v-model。

5.1 下拉选择 SelectComponent

vue 复制代码
<template>
  <el-select :value="value" @input="handleChange" placeholder="请选择">
    <el-option
      v-for="item in column.options"
      :key="item.value"
      :label="item.label"
      :value="item.value"
    />
  </el-select>
</template>

<script>
export default {
  name: 'SelectComponent',
  props: {
    value: { type: [String, Number], default: '' },
    column: { type: Object, required: true }  // 从列配置里拿 options
  },
  methods: {
    handleChange(val) { this.$emit('input', val) }
  }
}
</script>

注意:选项不是写死的,而是从列配置 column.options 里读------这就是「配置驱动」的体现。

5.2 图片上传 UploadComponent

vue 复制代码
<template>
  <div class="upload-component">
    <el-upload
      class="uploader"
      action="/upload"
      :http-request="customUpload"
      :show-file-list="false"
      :before-upload="beforeUpload"
    >
      <img v-if="imageUrl" :src="imageUrl" class="uploaded-image">
      <el-button v-else type="primary">点击上传</el-button>
    </el-upload>
    <div v-if="value" class="file-info">
      <span class="file-name">{{ value }}</span>
      <el-button type="text" @click="clearFile">删除</el-button>
    </div>
  </div>
</template>

<script>
export default {
  name: 'UploadComponent',
  props: { value: { type: String, default: '' } },
  data() { return { imageUrl: '' } },
  methods: {
    beforeUpload(file) {
      const isImage = file.type.startsWith('image/')
      const isLt2M = file.size / 1024 / 1024 < 2
      if (!isImage) { this.$message.error('只能上传图片文件!'); return false }
      if (!isLt2M) { this.$message.error('图片大小不能超过 2MB!'); return false }
      return true
    },
    customUpload(options) {
      const file = options.file
      this.imageUrl = URL.createObjectURL(file)   // 本地预览
      this.$emit('input', file.name)              // 回传文件名(真实项目回传 URL)
    },
    clearFile() {
      this.imageUrl = ''
      this.$emit('input', '')
    }
  }
}
</script>

六、配置驱动的落地:和后端 / 数据库怎么配合

这套组件的真正价值,是把它做成**「配置存 DB、回显靠配置」**的模式:

后端返回(列很多,按需下发):

json 复制代码
{
  "columns": [
    { "label": "订单号", "prop": "orderNo", "type": "input", "width": 180 },
    { "label": "下单时间", "prop": "createTime", "type": "date", "width": 200 },
    { "label": "状态", "prop": "status", "type": "select", "width": 150,
      "options": [{ "label": "已完成", "value": "1" }] }
  ],
  "data": [
    { "orderNo": "A001", "createTime": "2026-10-20", "status": "1" }
  ]
}

前端初始化 :拿到 columns / tableData 直接赋值,表格立刻按配置渲染,零硬编码:

js 复制代码
async load() {
  const { columns, data } = await api.getTableConfig(pageId)
  this.columns = columns
  this.tableData = data
}

保存时 :把 exportJson 里的 { columns, data } 结构 POST 回后端存表即可。这样「改列」就是改一条配置记录,不发版。

这也是低代码/报表平台的常见做法:元数据(列定义)与数据分离,渲染层完全由元数据驱动。

如果对你有帮助,点个赞 / 收藏走一波~ 完整代码已贴在上面,按需取用。 gitee.com/xcxsj/git_d...

相关推荐
风花一世月2 小时前
🛩️ 我把一段夕阳飞行做成了能按键驾驶的网页(24 秒四段航线,在线直接玩)
前端
数据掘金2 小时前
小程序分享参数在页面栈中的传递
前端
乐享qicheng2 小时前
1,自定义表单通俗理解-PC设计APP渲染
前端
为你学会写情书2 小时前
大屏适配别再只会 transform: scale 了,这套方案更稳
前端
孤独的神2 小时前
《AI 成了我的工位搭子,也成了我的焦虑来源》
前端
hunterandroid2 小时前
[鸿蒙从零到一] relationalStore 高级实践:事务、迁移与敏感数据保护
前端
lcarusZZZ2 小时前
前端转型,自建新闻转播客工作流
前端
vipxieliang2 小时前
JavaScript 事件循环与任务队列:从 setTimeout 到 Promise
前端·javascript
ZoeLandia2 小时前
TypeScript 05:交叉类型、索引签名、元组与 enum
前端·typescript