需求背景
在业务开发过程中,我们经常遇到需要以结构化方式展示表单数据的场景。传统表格组件适合展示行列数据,但对于"属性名-属性值"这种配对形式的数据展示并不直观。我们需要开发一个专门的表格组件,具体要求如下:
- 以表格形式展示属性名和属性值,格式为"属性名:属性值"
- 支持一行显示多对键值对
- 允许灵活控制每个属性显示的位置
- 支持单元格合并功能
- 键和值需要有不同样式区分
设计思路
整体架构
采用元数据驱动设计,通过配置描述数据展示方式,将原始数据转换为表格可渲染的结构。
核心概念
- formItems: 描述表单项的元数据数组,定义如何展示数据
- formData: 原始表单数据对象,包含实际要展示的值
- group: 控制属性在表格中的位置,实现灵活的布局
详细设计
1. 元数据结构设计
定义表单项的元数据结构,用于描述每个表单项的展示方式:
typescript
interface IFormItem {
prop: string; // 对应表单数据的字段名
label: string; // 显示名称
group?: number; // 所在键值对位置(从1开始),默认为1
format?: (data: any) => string; // 数据格式化函数
render?: (h: any, value: any, data: any) => any; // 自定义渲染函数
rowspan?: number; // 行合并数
colspan?: number; // 列合并数
}
2. 数据转换策略
将表单数据和元数据结合,生成表格组件可识别的结构:
typescript
// 转换前
formData: { name: "测试", age: 25 }
formItems: [{ prop: "name", label: "姓名" }, { prop: "age", label: "年龄" }]
// 转换后
tableData: [
{
"group-1-key": "姓名",
"group-1-value": "测试",
"group-2-key": "年龄",
"group-2-value": 25
}
]
3. 列生成算法
通过分析formItems中的group属性,动态生成表格列配置:
typescript
interface IColumn {
prop: string;
type: 'title' | 'content';
}
const getDisplayedColumns = (formItems): IColumn => {
const array = formItems;
const groupMap: Map<number, Array<IFormItem>> = array.reduce((prev, cur) => {
const group = cur.group;
if (prev.has(group)) {
const list = prev.get(group);
list.push(cur);
} else {
prev.set(group, [cur]);
}
return prev;
}, new Map()); // 将formItems按照group来分组
const temp: Array<number> = Array.from(groupMap.entries())
.sort((a, b) => a[0] - b[0]) // 按照group从小到大排序
.map(([key, value]) => key); // 返回所有group编号组成的数组:e.g. [1,2]
const cols = Array.from(temp)
.map((item: number) => {
return [
{
prop: `group-${item}-key`,
type: 'title',
},
{
prop: `group-${item}-value`,
type: 'content',
},
];
})
.flat(); // 将每个group的子数组(含有2项)扁平化,形成一维数组
return cols as Array<IColumn>;
};
4. 行数据生成算法
将表单数据按照元数据配置转换为表格行数据:
typescript
interface ICell {
formItem: IFormItem;
data?: any;
}
const getDisplayedData = (formItems, data) => {
const array = formItems;
const groupMap: Map<number, Array<IFormItem>> = array.reduce((prev, cur) => {
const { group } = cur;
if (prev.has(group)) {
const list = prev.get(group);
list.push({ ...cur, group });
} else {
prev.set(group, [{ ...cur, group }]);
}
return prev;
}, new Map()); // 将formItems按照group来分组
const temp: Array<Array<IFormItem>> = Array.from(groupMap.entries())
.sort((a, b) => a[0] - b[0]) // 按照group从小到大排序
.map(([key, value]) => value); // 返回所有group对应的子数组组成的二维数组
const maxRows = Math.max(...temp.map((item) => item.length)); // 二维数组的行数,就是不同group下的最大子数组长度
const rows: Array<ICell> = [];
for (let j = 0; j < maxRows; j++) {
const cols: Record<string, any> = {}; // 对于每行,创建一个空对象
for (let i = 0; i < temp.length; i++) {
if (temp[i][j]) {
const group = temp[i][j]?.group;
cols[`group-${group}-key`] = temp[i][j]?.label ?? '';
cols[`group-${group}-value`] = getCellMetadata(temp[i][j], data); // 为了渲染表单值,把整个formItem对象传入表单值属性
}
}
rows.push(cols);
}
return rows;
};
const getCellMetadata = (item: IFormItem, data: any): ICell => {
return {
formItem: item,
};
};
组件实现
模板部分
vue
<el-table :data="tableData" :show-header="false" :stripe="false" :border="true" :span-method="spanMethod">
<el-table-column v-for="item of columns" :key="item.prop" :prop="item.prop"
:align="item.type === 'title' ? 'right' : 'left'" :class-name="item.type === 'title' ? 'title' : ''">
<template #default="{ row }">
<template v-if="typeof row[item.prop]?.formItem?.render === 'function'">
<component :is="row[item.prop]?.formItem?.render('', data)"></component>
</template>
<template v-else>
<template v-if="typeof row[item.prop]?.formItem?.format === 'function'">
{{ row[item.prop]?.formItem?.format(data, item.prop) }}
</template>
<template v-else-if="row[item.prop] && item.type === 'title'">{{ `${row[item.prop]}:` }}</template>
<template v-else-if="row[item.prop]">{{ data[row[item.prop]?.formItem.prop] || '' }}</template>
</template>
</template>
</el-table-column>
</el-table>
逻辑部分
vue
<script setup>
import { computed } from 'vue';
const props = defineProps({
formItems: {
type: Array,
default: () => [],
},
data: {
type: Object,
default: () => ({}),
},
});
const columns = computed(() => {
return getDisplayedColumns(props.formItems);
});
const tableData = computed(() => {
return getDisplayedData(props.formItems, props.data);
});
const spanMethod = ({ row, column }) => {
const formItem = row[column.rawColumnKey]?.formItem;
return {
rowspan: formItem?.rowspan ?? 1,
colspan: formItem?.colspan ?? 1,
};
};
</script>
使用示例
基本用法
vue
<KeyValueTable :form-items="formItems" :data="data"></KeyValueTable>
typescript
const formItems = [
{
prop: "name",
label: "名称",
},
{
prop: "sex",
label: "性别",
format: (data) => {
return data.sex === 'man' ? '男' : '女'
}
}, {
prop: "manQuestion",
label: "男性问题",
group: 2,
},
{
prop: "womanQuestion",
label: "女性问题",
group: 2,
},
{
prop: "description",
label: "描述",
colspan: 3,
}
]
const data = {
name: "test",
sex: "man",
manQuestion: "男性问题",
womanQuestion: "女性问题",
age: 18,
date: "2025-07-29",
phone: "13900000000",
address: "广东省",
idCard: "440100000000000000",
}
实现效果
该组件将渲染为如下表格结构:

总结
通过本文介绍的设计与实现,我们创建了一个灵活、可配置的键值对表格组件。该组件的核心优势在于:
- 声明式配置:通过formItems元数据驱动视图生成
- 灵活布局:支持多组键值对和自定义位置
- 扩展性强:支持格式化、自定义渲染和单元格合并
- 易于使用:简单的API设计,降低使用门槛
这种设计模式不仅适用于表格展示,其元数据驱动的思想也可以应用于其他需要动态生成视图的场景,为Vue开发者提供了一种可复用的解决方案。