一、背景与痛点
后台系统里有一类很常见的需求:页面的表格需要「可配置」。
- 业务表字段特别多,动辄几十上百个
col,但每个页面要展示的列不一样; - 同一份数据,A 页面只显示 3 列、B 页面要显示 8 列,列标题还可能被业务方改来改去;
- 每次调整都要改代码、重新发版,运维成本极高。
于是我们希望把「表格长什么样」抽成一份配置:
后端返回「列定义(标题 + 类型 + 宽度 + 选项)」+「行数据」,前端完全按配置动态渲染成一张表格;调整列只需改配置,无需发版。
这就是本文要实现的 动态表格组件 TableDesigner :支持动态增删行列、自定义列标题、多种单元格类型(文本 / 日期 / 下拉 / 图片上传),并能把整张表的配置导出/持久化。
二、最终效果
页面由三部分组成:
- 工具栏 :
添加行、添加列、导出配置三个按钮 + 一个「列类型」下拉(新增列时决定用哪种单元格)。 - 表格 :
- 序号列 + 动态列 + 操作列;
- 每个动态列的表头右侧有
编辑/删除两个操作; - 单元格按列类型渲染:文本框、日期选择、下拉选择、图片上传;图片上传列还会显示缩略图;
- 操作列有
删除按钮删掉整行。
- 编辑列弹窗 :改列标题、列宽度、列类型;当类型是「下拉选择」时,还能在线编辑选项列表。

三、核心设计思路
做动态表格,关键是想清楚「用数据描述界面」:
| 概念 | 数据结构 | 说明 |
|---|---|---|
| 列 | columns: Column[] |
每一列是一个配置对象,描述标题、字段名、类型、宽度、下拉选项 |
| 行 | tableData: Row[] |
每一行是一条记录,row[col.prop] 就是该列的值 |
列配置结构:
js
{
label: '新列1', // 表头标题
prop: 'col_1730000000000', // 字段名(唯一,用时间戳生成)
type: 'input', // 单元格类型:input | date | select | upload
width: 150, // 列宽
options: [] // 仅 select 类型使用
}
三个关键实现点:
- 动态渲染列 :
v-for遍历columns生成<el-table-column>; - 自定义表头 :用
#header插槽在表头里塞「编辑 / 删除」; - 单元格类型分发 :用
<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...