ant-design-vue 表格「行、列都动态」的处理方案
场景:行是「行业」,列是「统计时间(月份)」,两者都由接口数据决定,前端需要动态拼装表头并填充数据。

一、问题本质
传统 a-table 写法是列固定、行动态:
vue
<a-table :columns="columns" :data-source="list" />
columns 是写死的常量,data-source 是接口返回的数组。
而本例中:
- 行 :行业列表不确定(工业、电力热力......),由接口
xm_gjzbfx_zdgyydl_hy返回; - 列 :统计月份数量不确定(1~12 月任意区间),由接口
xm_gjzbfx_zdgyydl返回的数据中出现过哪些tjsj决定。
因此不能写死列,必须根据数据反推表头。
二、核心思路
接口返回的是**长表(纵向)**结构,一行 = 一个「行业 + 月份」的组合:
js
[
{ hymc: '工业', tjsj: '2024-01', ydl: 123456, ydl_zzl: 5.21 },
{ hymc: '工业', tjsj: '2024-02', ydl: 234567, ydl_zzl: 6.32 },
{ hymc: '电力...', tjsj: '2024-01', ydl: 345678, ydl_zzl: 1.10 },
]
而表格需要的是宽表(横向):一行 = 一个行业,各月份横向铺开。
整个转换分三步:
接口长表数据
│
├─ ① 按 tjsj 分组(groupBy) → 得到「列」的定义顺序
│
├─ ② 每组生成一个父列,列下挂 2 个子列(总量 / 增长率)
│ dataIndex 用「下标」拼接:ydl0、ydl_zzl0、ydl1、ydl_zzl1 ...
│
└─ ③ 把每个行业的数值写回「对应行的对象」上,属性名与 dataIndex 严格同名
row.ydl1 = 234567 ←→ column.dataIndex = 'ydl1'
关键点:columns[i].dataIndex 与 row[dataIndex] 靠「同名」建立映射关系,ant-table 就是靠这个自动取值。所以动态列的本质是:
用「稳定的花名」(如
ydl0、ydl_zzl0)代替「真实业务字段名」,在生成列的同时,把数据按同样的花名写入行对象。
三、代码逐段拆解
1. 表头第一列固定:行维度
js
this.zdhyColumns = [
{
title: this.zdgyydYear, // 标题直接用当前选中年份
dataIndex: 'mc', // 行维度字段:行业名称
key: 'mc',
width: 200,
fixed: 'left', // 动态列较多时必须固定左侧,否则横向滚动后看不懂行是什么
},
];
注意:每次刷新都要整体重建
zdhyColumns(而不是 push 到旧数组里),否则切换年份后会不断叠加历史列。
2. 先查「行」:确定行业列表及顺序
js
this.zdhyData = [];
await FormBoListApi.queryForJson('xm_gjzbfx_zdgyydl_hy', params).then(res => {
if (res.success) {
res.data.forEach(item => {
if (item.hymc) this.zdhyData.push({ mc: item.hymc });
});
// 把「工业」提到第一行,方便阅读
if (this.zdhyData.length > 0) {
const gy = this.zdhyData.find(item => item.mc === '工业');
this.zdhyData = this.zdhyData.filter(item => item.mc !== '工业');
this.zdhyData.unshift(gy);
}
}
});
此时 zdhyData 只有 mc 一个字段,其余字段在第 4 步补上。
3. 再查「列」:按统计时间分组并生成列
js
const list = this.groupBy(data); // 相同 tjsj 归为一组
list.forEach((val, index) => {
const columns = {
title: val.tjsj, // 父表头:2024-01
width: 260,
children: [ // 子表头:总量 / 增长率
{
title: '总量(万千瓦时)',
dataIndex: `ydl${index}`, // ← 用分组下标生成唯一 key
width: 130,
ellipsis: true,
scopedSlots: { customRender: 'ydl' },
},
{
title: '增长率(%)',
dataIndex: `ydl_zzl${index}`,
width: 130,
ellipsis: true,
scopedSlots: { customRender: 'ydl_zb' },
},
],
};
this.zdhyColumns.push(columns);
});
groupBy 实现(顺序即 Object.values 的插入顺序,等价于「数据首次出现的顺序」):
js
groupBy(resData) {
const dataInfo = {};
resData.forEach(item => {
const { tjsj } = item;
if (!dataInfo[tjsj]) dataInfo[tjsj] = { tjsj, child: [] };
dataInfo[tjsj].child.push(item);
});
return Object.values(dataInfo);
}
4. 数据回填:把值写进对应行的同名属性
js
if (val.child.length > 0) {
val.child.forEach(val1 => {
this.zdhyData.forEach(val2 => {
if (val2.mc === val1.hymc) { // 行、列交叉定位
val2[`ydl${index}`] = val1.ydl ? val1.ydl.toLocaleString() : '--';
val2[`ydl_zzl${index}`] = val1.ydl_zzl ? val1.ydl_zzl.toFixed(2) : '--';
}
});
});
}
这里 index 必须与第 3 步生成 dataIndex 时的 index 完全一致,否则列与值会错位。
5. 最后一列自适应,消除右侧空白
js
// 当 columns 为动态时,给最后一个项设置自适应宽度,防止出现空白间隙
this.zdhyColumns[this.zdhyColumns.length - 1].children[0].width = '';
this.zdhyColumns[this.zdhyColumns.length - 1].children[1].width = '';
动态列总宽常常凑不满容器,最后一列去掉固定宽度可让表格铺满。
若这里把父列
width也一起置空,记得同步修正scrollX的计算逻辑(见下)。
6. 动态计算 scroll.x,配合固定列
js
computed: {
scrollX() {
// 动态获取 scrollX,防止数组固定列的大小变化
return this.zdhyColumns.reduce((pre, cur) => pre + cur.width, 0);
},
}
模板中:
vue
<a-table
bordered
:columns="zdhyColumns"
:data-source="zdhyData"
class="table-b mT10"
:pagination="false"
:scroll="{ x: scrollX, y: 800 }"
>
<template slot="ydl" slot-scope="text">
<span>{{ text || '--' }}</span>
</template>
<template slot="ydl_zb" slot-scope="text">
<span>{{ text || '--' }}%</span>
</template>
</a-table>
注意两点:
- 只要用了
fixed: 'left',:scroll="{ x }"就必须给,否则固定列不生效或与内容列错位。 scrollX只累加顶层列 的width,所以父列width(260)应当等于其子列宽度之和(130 + 130),保持一致才不出误差。
7. 自定义渲染:给不同列挂不同模板
动态列无法为每一列单独写 render,用 scopedSlots.customRender 做同类列的共享模板:
- 总量列 →
customRender: 'ydl',模板输出{``{ text || '--' }} - 增长率列 →
customRender: 'ydl_zb',模板输出{``{ text || '--' }}%(补百分比符号)
这样几十个动态列复用同一个插槽,不必逐列定义。
四、完整数据流
用户切换 年份/始月/终月 (zdgyYdlHandleChange)
│
▼
getGjzbfxZdgyYdl()
│
├─ 重置 zdhyColumns(仅保留 mc 列)、zdhyData = []
│
├─ 请求 xm_gjzbfx_zdgyydl_hy → 得到行业列表 → 造「行」+ 工业置顶
│
└─ 请求 xm_gjzbfx_zdgyydl → groupBy(tjsj)
│
遍历分组 index
│
┌─────────────┴─────────────┐
▼ ▼
push 动态列到 columns 把值写入对应 row
(dataIndex = ydl{index}) (row['ydl'+index] = xx)
│ │
└─────────────┬─────────────┘
▼
最后一列 width 置空 + scrollX 重算
▼
a-table 重新渲染
五、可复用模板
剥离业务后的通用写法:
js
data() {
return {
dynamicColumns: [],
dynamicData: [],
dynamicX: 0,
};
},
methods: {
async loadDynamicTable() {
const rows = await fetchRows(); // [{ name, time, value }]
const cols = await fetchCols(); // 备用:也可直接从 rows 推导时间维度
const groups = this.groupBy(rows, 'time');
// 1) 行维度列
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 160, fixed: 'left' },
];
// 2) 行数据(先去重)
const dataMap = {};
rows.forEach(r => {
if (!dataMap[r.name]) dataMap[r.name] = { name: r.name };
});
const dataSource = Object.values(dataMap);
// 3) 动态列 + 值回填
groups.forEach((group, i) => {
const colKey = `value_${i}`;
columns.push({
title: group.key,
width: 200,
children: [
{
key: colKey,
title: '数值',
dataIndex: colKey,
ellipsis: true,
scopedSlots: { customRender: 'value' },
},
],
});
group.child.forEach(item => {
const target = dataMap[item.name];
if (target) target[colKey] = item.value ?? '--';
});
});
// 4) 末列自适应
if (columns.length > 1) {
const last = columns[columns.length - 1];
(last.children || [last]).forEach(c => { c.width = ''; });
}
this.dynamicColumns = columns;
this.dynamicData = dataSource;
this.dynamicX = columns.reduce((s, c) => s + (Number(c.width) || 0), 0);
},
groupBy(list, key) {
const map = {};
list.forEach(item => {
const k = item[key];
if (!map[k]) map[k] = { key: k, child: [] };
map[k].child.push(item);
});
return Object.values(map);
},
},
六、踩坑清单
| 现象 | 原因 | 处理 |
|---|---|---|
| 切换筛选条件后列越来越多 | 只在旧数组上 push,未重置 |
每次请求前重建 columns 数组 |
| 数值与表头错位 | 生成 dataIndex 的下标与回填时的下标不一致 |
用同一个 index 变量,或改用 tjsj 作为稳定 key(ydl_${tjsj}) |
| 某些单元格空白 | 行的属性名与 dataIndex 不同名 |
保证 row[col.dataIndex] 存在,用 ?? / ` |
| 固定列错位 / 滚动异常 | 用了 fixed 但未传 scroll.x |
补 :scroll="{ x: scrollX }",并随列变化重算 |
| 表格右侧有空白 | 动态列总宽不足 | 末列 width 置为 '' 自适应 |
| 单元格无值也不显示占位 | 未挂 scopedSlots |
为同类列统一挂 customRender 插槽 |
| Vue 控制台 key 警告 | 动态子列未设 key |
每个动态列补上唯一 key |
unshift(undefined) |
find('工业') 未命中时返回 undefined |
先判空再 unshift:if (gy) this.zdhyData.unshift(gy) |
| 数据量大时渲染卡顿 | 列 × 行 组合过多 | 开 ellipsis、限制查询区间、必要时虚拟滚动 |
七、小结
动态行列表格的三条铁律:
- 列与数据同源 :
dataIndex与行对象属性名必须由同一套规则生成,口径一处定义、两处使用。 - 每次全量重建 :
columns和data-source都要整体替换,避免残留脏数据。 - 固定列就必给
scroll.x,且scroll.x需按列宽实时计算、末列自适应收尾,才能兼顾「列多可横滚」与「铺满容器」。