ant-design-vue 表格「行、列都动态」的处理方案
场景:行是「行业」,列是「统计时间(月份)」,两者都由接口数据决定,前端需要动态拼装表头并填充数据。
一、问题本质
传统 a-table 写法是列固定、行动态:
<a-table :columns="columns" :data-source="list" />columns是写死的常量,data-source是接口返回的数组。
而本例中:
- 行:行业列表不确定(工业、电力热力……),由接口
xm_gjzbfx_zdgyydl_hy返回; - 列:统计月份数量不确定(1~12 月任意区间),由接口
xm_gjzbfx_zdgyydl返回的数据中出现过哪些tjsj决定。
因此不能写死列,必须根据数据反推表头。
二、核心思路
接口返回的是**长表(纵向)**结构,一行 = 一个「行业 + 月份」的组合:
[{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. 表头第一列固定:行维度
this.zdhyColumns=[{title:this.zdgyydYear,// 标题直接用当前选中年份dataIndex:'mc',// 行维度字段:行业名称key:'mc',width:200,fixed:'left',// 动态列较多时必须固定左侧,否则横向滚动后看不懂行是什么},];注意:每次刷新都要整体重建
zdhyColumns(而不是 push 到旧数组里),否则切换年份后会不断叠加历史列。
2. 先查「行」:确定行业列表及顺序
this.zdhyData=[];awaitFormBoListApi.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){constgy=this.zdhyData.find(item=>item.mc==='工业');this.zdhyData=this.zdhyData.filter(item=>item.mc!=='工业');this.zdhyData.unshift(gy);}}});此时zdhyData只有mc一个字段,其余字段在第 4 步补上。
3. 再查「列」:按统计时间分组并生成列
constlist=this.groupBy(data);// 相同 tjsj 归为一组list.forEach((val,index)=>{constcolumns={title:val.tjsj,// 父表头:2024-01width:260,children:[// 子表头:总量 / 增长率{title:'总量(万千瓦时)',dataIndex:`ydl${index}`,// ← 用分组下标生成唯一 keywidth: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的插入顺序,等价于「数据首次出现的顺序」):
groupBy(resData){constdataInfo={};resData.forEach(item=>{const{tjsj}=item;if(!dataInfo[tjsj])dataInfo[tjsj]={tjsj,child:[]};dataInfo[tjsj].child.push(item);});returnObject.values(dataInfo);}4. 数据回填:把值写进对应行的同名属性
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. 最后一列自适应,消除右侧空白
// 当 columns 为动态时,给最后一个项设置自适应宽度,防止出现空白间隙this.zdhyColumns[this.zdhyColumns.length-1].children[0].width='';this.zdhyColumns[this.zdhyColumns.length-1].children[1].width='';动态列总宽常常凑不满容器,最后一列去掉固定宽度可让表格铺满。
若这里把父列
width也一起置空,记得同步修正scrollX的计算逻辑(见下)。
6. 动态计算 scroll.x,配合固定列
computed:{scrollX(){// 动态获取 scrollX,防止数组固定列的大小变化returnthis.zdhyColumns.reduce((pre,cur)=>pre+cur.width,0);},}模板中:
<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 重新渲染五、可复用模板
剥离业务后的通用写法:
data(){return{dynamicColumns:[],dynamicData:[],dynamicX:0,};},methods:{asyncloadDynamicTable(){constrows=awaitfetchRows();// [{ name, time, value }]constcols=awaitfetchCols();// 备用:也可直接从 rows 推导时间维度constgroups=this.groupBy(rows,'time');// 1) 行维度列constcolumns=[{title:'名称',dataIndex:'name',key:'name',width:160,fixed:'left'},];// 2) 行数据(先去重)constdataMap={};rows.forEach(r=>{if(!dataMap[r.name])dataMap[r.name]={name:r.name};});constdataSource=Object.values(dataMap);// 3) 动态列 + 值回填groups.forEach((group,i)=>{constcolKey=`value_${i}`;columns.push({title:group.key,width:200,children:[{key:colKey,title:'数值',dataIndex:colKey,ellipsis:true,scopedSlots:{customRender:'value'},},],});group.child.forEach(item=>{consttarget=dataMap[item.name];if(target)target[colKey]=item.value??'--';});});// 4) 末列自适应if(columns.length>1){constlast=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){constmap={};list.forEach(item=>{constk=item[key];if(!map[k])map[k]={key:k,child:[]};map[k].child.push(item);});returnObject.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需按列宽实时计算、末列自适应收尾,才能兼顾「列多可横滚」与「铺满容器」。