做后台管理系统三年以上的前端,基本都会碰到一个绕不开的需求:一张表格,表头要分组好几层,列数是后端动态返回的,数据里还要合并相同项的单元格,最后还得一键导出成Excel。这四个需求拆开看都不难,但组合到一起,尤其是数据一变就要重新计算合并、导出还要还原多级表头和合并单元格,就会有一堆细节问题冒出来。这篇文章把我最近一个报表模块里完整实现"多表头 + 动态列 + 合并行/列 + 导出Excel"的过程和踩坑记录下来,给正准备做同样功能的朋友一个可以直接抄的解决方案。
1. 多表头不只是嵌套:el-table-column 的分层渲染与表头分组逻辑
1.1 从"嵌套写死"到"多级表头"的最小实现
Element 的表格组件做多表头,最基础的写法就是嵌套。父级el-table-column不设置prop,只作为分组容器,子级设置prop对应数据字段。页面效果就是父级表头跨列,子级表头按字段分布。
<template> <el-table :data="tableData" border> <el-table-column label="基本信息" align="center"> <el-table-column prop="name" label="姓名" width="120" /> <el-table-column prop="age" label="年龄" width="80" /> </el-table-column> <el-table-column label="业绩信息" align="center"> <el-table-column prop="orderCount" label="订单数" width="100" /> <el-table-column prop="totalAmount" label="总金额" width="120" /> </el-table-column> </el-table> </template>这种写法本身不复杂,但一旦列数变多、层级变深,模板会非常臃肿,而且后端如果返回动态列,这种静态嵌套就没法直接用了。所以理解多表头的底层实现机制是第一步。
1.2 Element 内部是怎么渲染多表头的
el-table在初始化时会把所有el-table-column收集起来,递归成一颗列配置树。表头渲染的时候,组件会根据这棵树,对每一层的叶子节点做colspan和rowspan计算,生成一个二维的表头矩阵。比如"基本信息"跨2列、"业绩信息"跨2列,第一层实际只渲染两格,第二层渲染四格。
这个机制意味着几个关键结论:
- 数据始终是平铺的。不管表头分几层,
tableData里的每条数据都是一个扁平对象,列与数据通过prop关联。 - 表头分组只影响表头展示,不影响数据对齐。每一列最终都会对应到一个字段,字段是唯一的锚点。
- 复杂的多表头,本质是列的树形结构。只要把"列配置"变成树形 JSON,就能用
v-for动态渲染任意层级,这就为动态列打下了基础。
多级表头还有一个容易忽略的点:中间层级如果出现没有子级的空列,或者某个分组下只有一列,渲染时colspan计算会不同。常见做法是给中间分组也设置prop,但这样数据会对齐到分组本身,语义上容易混乱。我的习惯是中间层只写label,强制所有数据列落在叶子节点上。
1.3 用列配置树驱动模板
既然底层是树形结构,模板就可以用递归渲染。这里比较实用的写法是定义一个columns数组,每个元素包含prop、label、width、children等字段,模板里用v-for循环,发现children就继续嵌套。
<template> <el-table :data="tableData" border> <template v-for="col in columns"> <el-table-column v-if="!col.children || !col.children.length" :key="col.prop" :prop="col.prop" :label="col.label" :width="col.width" align="center" /> <el-table-column v-else :key="col.label" :label="col.label" align="center"> <el-table-column v-for="child in col.children" :key="child.prop" :prop="child.prop" :label="child.label" :width="child.width" align="center" /> </el-table-column> </template> </el-table> </template>到这里,多表头已经从"死模板"变成了"配置驱动"。这也是后面动态列和导出功能的基础,因为不管是渲染表格还是生成 Excel 表头,我们操作的其实就是同一份columns配置。
2. 动态数据列:让表格结构跟着接口响应走
2.1 后端返列配置,表格怎么消化
报表类系统的动态列通常有两种来源:一种是前端根据用户勾选动态生成列,另一种是后端根据权限、日期范围、统计维度返回列配置。两者本质相同,都是拿到一份columns数组,再拿到对应的tableData。
我经常用的接口结构长这样:
{ "columns": [ { "label": "基本信息", "children": [ { "prop": "name", "label": "姓名" }, { "prop": "dept", "label": "部门" } ] }, { "label": "1月", "children": [ { "prop": "m1_sales", "label": "销售额" }, { "prop": "m1_count", "label": "订单数" } ] }, { "label": "2月", "children": [ { "prop": "m2_sales", "label": "销售额" }, { "prop": "m2_count", "label": "订单数" } ] } ], "data": [ { "name": "A组", "dept": "华东", "m1_sales": 1000, "m1_count": 8, "m2_sales": 1200, "m2_count": 10 } ] }拿到这份配置后,直接把tableData赋值给el-table,再根据columns循环生成列结构。因为el-table-column本身支持循环渲染,所以前端代码不需要针对具体业务写死任何字段名。
2.2 动态列渲染最容易踩的三个点
第一,key必须稳定且唯一。如果列是动态增删的,key用prop最合适。有些同学会用循环下标当key,结果列顺序一变,表格内部的排序、筛选、固定列状态全部错乱。层级相同的列之间key不能重复,不同层级的label可以重复,但key一定别用label。
第二,注意type="selection"和type="index"这类特殊列。当columns里混入这种列,后面的列索引计算就要特别小心。建议在动态列配置里显式保留这个字段的位置,或者在生成columns时手动unshift一列。
const allColumns = [ { type: 'selection', width: 50, fixed: 'left' }, ...columns ];第三,v-if和v-for不能同时用在同一个el-table-column上。这是 Vue 的老规矩了,循环里需要判断就用template包裹,或者像我上面的写法,用template v-for+ 内部两个el-table-column分支。
2.3 动态合并信息也要从接口来
动态列往往伴随着动态合并。最理想的情况是后端算好合并规则,返回一个mergeInfo数组。每个合并项包含起始行、起始列、跨行数、跨列数:
[ { "rowIndex": 0, "columnIndex": 0, "rowspan": 3, "colspan": 1 }, { "rowIndex": 3, "columnIndex": 0, "rowspan": 2, "colspan": 1 } ]前端收到这份数据后,把它转换成一个以"行-列"为 key 的映射表,供span-method查询。不要直接在span-method里遍历数组,因为span-method会被调用很多次,遍历数组在数据量大的时候性能很差。关于这一点,后面合并单元格章节会给出完整代码。
3. 合并行与合并列:span-method 的坐标算法
3.1 span-method 的返回值到底怎么理解
el-table的合并能力集中在一个方法上:span-method。它接收一个包含row、column、rowIndex、columnIndex的对象,返回两种格式:
- 数组:
[rowspan, colspan] - 对象:
{ rowspan: 1, colspan: 1 }
返回值的规则是:某个单元格返回rowspan: 2,表示这个单元格要往下占两行;返回colspan: 2,表示往右占两列;返回rowspan: 0或colspan: 0时,这个单元格会被合并掉,不显示。这里有个新手经常绕晕的地方:合并方向是"当前单元格往右、往下延伸",而不是"把后面的单元格拉过来"。所以渲染时计算逻辑要在第一格设置跨数,被跨越的格子返回 0。
spanMethod({ rowIndex, columnIndex }) { if (rowIndex === 1 && columnIndex === 0) { return { rowspan: 2, colspan: 1 }; } if (rowIndex === 2 && columnIndex === 0) { return { rowspan: 0, colspan: 0 }; } return { rowspan: 1, colspan: 1 }; }3.2 合并行:先算好"合并映射表",再查表
最常见的合并需求是"某列相同值的行合并成一行"。我推荐的做法是:根据数据源计算出一个合并映射表,span-method里只做查询。
function buildRowMergeMap(data, mergeField, columnIndex) { const map = {}; let row = 0; while (row < data.length) { let end = row + 1; while (end < data.length && data[end][mergeField] === data[row][mergeField]) { end++; } const count = end - row; if (count > 1) { map[`${row}-${columnIndex}`] = { rowspan: count, colspan: 1 }; for (let i = row + 1; i < end; i++) { map[`${i}-${columnIndex}`] = { rowspan: 0, colspan: 0 }; } } row = end; } return map; }这里有两个细节:
- 合并字段必须是连续相同的值。如果数据是"A、A、B、A",然后你想把所有 A 合并,这是不行的,表格合并只会处理相邻行。需要先对数据排序,让相同值相邻。
columnIndex要和表格实际列索引对应。如果表格有type="selection"固定列,那"姓名"列的索引可能不是 0 而是 1,映射表构建时要把这个偏移量算进去。
span-method里查表:
spanMethod({ rowIndex, columnIndex }) { return mergeMap[`${rowIndex}-${columnIndex}`] || { rowspan: 1, colspan: 1 }; }3.3 合并列:跨列合并和跨行合并的配合
横向合并相对少见,但一旦出现,比如"详细信息"列需要跨两个字段合并展示,算法是类似的。跨列合并时要把每一行的起始列建映射,被跨过的列返回colspan: 0。
最麻烦的是行列同时合并,比如一个单元格跨 2 行 2 列。此时起始格的映射是{ rowspan: 2, colspan: 2 },同一合并区域内其余三个格子分别返回0。构建映射表的时候,不要只标记起始格,要主动把被吞掉的格子也写进映射,否则span-method查不到对应项,它们会按照默认1:1渲染,表格就会多出边界线。
3.4 动态列下,"列索引"是最容易错的地方
动态列 + 多表头 + 合并单元格,三者叠加时,span-method里的columnIndex指的是"整张表所有叶子列的序号",而不是某个分组内部的序号。假设第一层有selection列占 1 列,然后"基本信息"占 2 列,"业绩信息"占 2 列,那么"业绩信息"的第一列索引是 3,而不是 0。
这个索引关系在后端返回mergeInfo时尤其容易对不上。我踩过一次很深的坑:后端返回的columnIndex是从 0 开始计数的真实列序号,但前端某个分组内部自己也用了从 0 开始的索引,两边一减就错位了。后来统一约定:接口返回的columnIndex永远是整表叶子列的真实索引,由前端负责转换成分组内逻辑,不在接口层做任何处理。
4. 导出Excel:为什么我最终选了前端生成 xlsx
4.1 三种导出方案的取舍
导出 Excel 不是只有前端一条路,实际项目里通常有三种选择,我列个对比表:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 后端接口导出 | 可以复用后端报表模板,数据量上限高,样式稳定 | 多级表头还原要后端写 POI 模板,开发成本高;动态列时参数传递复杂 | 数据量几十万行、对权限有强要求 |
| 前端把表格转为 Excel | 用现成库直接 dump,实现快 | 多表头、合并单元格还原效果不稳定,样式粗糙 | 纯前端小表格 |
| 前端用 ExcelJS 生成 | 完全可控表头层级、合并区域、样式;和后端解耦 | 需自己构建表头矩阵和合并区间,代码量稍大 | 多表头 + 合并 + 动态列的复杂表格 |
我最终选择了 ExcelJS。原因是动态列场景下,后端模板很难跟着"今天 3 列、明天 5 列"的配置走,每次改列都要改后端代码,太僵硬。前端拿到columns和tableData,完全有能力把结构还原成 Excel,还能在导出前让用户选择导出哪些列。
4.2 把多级表头还原成 Excel 表头矩阵
ExcelJS 里合并单元格是一个个手动指定的,比如mergeCells('A1:B1')。所以核心任务是把columns树转换成一个二维表头矩阵,同时记录哪些区域需要合并。
我写了一个递归函数:遍历columns树,记录每一层的叶子列数,得到总列数;再用一个headerMatrix[row][col]二维数组填充 label。第一层放分组名,第二层放子列名,跨层的空格子设置为null,之后根据矩阵中相同内容的连续区域生成合并区间。
function buildHeaderMatrix(columns) { const matrix = []; const leafCount = countLeafColumns(columns); const firstRow = columns.map(col => col.label); matrix.push(firstRow); if (columns.some(col => col.children && col.children.length)) { const subMatrix = buildHeaderMatrix(columns.flatMap(col => col.children || [])); matrix.push(subMatrix); } return matrix; }实际代码会用列偏移量记录每个分组占的宽度,避免一行一个递归导致列对应错位。这里不展开全部代码,但思路很关键:表头矩阵是导出功能的主心骨,Excel 的表头样式和后续数据填充都依赖它。
4.3 用 ExcelJS 填充数据与合并单元格
拿到表头矩阵后,把数据填充进 Excel:
import ExcelJS from 'exceljs'; async function exportExcel({ columns, data, mergeMap, fileName }) { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('报表'); const headerMatrix = buildHeaderMatrix(columns); const leafColumns = flattenColumns(columns); // 写入表头 headerMatrix.forEach((row, rowIndex) => { row.forEach((cell, colIndex) => { if (cell) { const excelCell = sheet.getCell(rowIndex + 1, colIndex + 1); excelCell.value = cell; excelCell.alignment = { horizontal: 'center', vertical: 'middle' }; } }); }); // 表头合并 // 需要根据 headerMatrix 中连续相同的 label 计算合并区间并调用 sheet.mergeCells() // 写入数据 data.forEach((item, rowIndex) => { leafColumns.forEach((col, colIndex) => { const excelCell = sheet.getCell(rowIndex + 1 + headerMatrix.length, colIndex + 1); excelCell.value = item[col.prop]; }); }); // 数据区合并 Object.keys(mergeMap).forEach(key => { const [rowIdx, colIdx] = key.split('-').map(Number); const span = mergeMap[key]; if (span.rowspan > 1 || span.colspan > 1) { const startRow = rowIdx + 1 + headerMatrix.length; const startCol = colIdx + 1; sheet.mergeCells( startRow, startCol, startRow + span.rowspan - 1, startCol + span.colspan - 1 ); } }); const buffer = await workbook.xlsx.writeBuffer(); // 触发下载 const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${fileName}.xlsx`; a.click(); URL.revokeObjectURL(url); }这里最需要注意的是:数据区合并之前,一定要先填充完所有数据,再执行 mergeCells。如果先合并,再往被合并的区域外写字,ExcelJS 会报错,或者打开 Excel 后提示文件有问题。
5. 完整 Demo:多表头 + 动态列 + 合并 + 导出一次跑通
5.1 一份数据同时驱动四件事
我把整个流程抽象成一个数据模型,前端所有功能都围绕这个模型运转:
const reportData = { columns: [ { label: '团队', children: [ { prop: 'team', label: '团队名称' } ]}, { label: 'Q1', children: [ { prop: 'q1_sales', label: '销售额' }, { prop: 'q1_count', label: '订单数' } ]}, { label: 'Q2', children: [ { prop: 'q2_sales', label: '销售额' }, { prop: 'q2_count', label: '订单数' } ]} ], data: [ { id: 1, team: '华东组', q1_sales: 100, q1_count: 8, q2_sales: 110, q2_count: 9 }, { id: 2, team: '华东组', q1_sales: 120, q1_count: 9, q2_sales: 130, q2_count: 10 }, { id: 3, team: '华南组', q1_sales: 200, q1_count: 15, q2_sales: 210, q2_count: 16 } ], mergeRules: [ { field: 'team', columnIndex: 0, content: 'team' } ] };mergeRules表示"第 0 列按照 team 字段连续相同值合并"。前端在拿到数据后,先调用buildRowMergeMap生成mergeMap,再交给表格和导出函数用。整个过程只需要这一处计算,避免表格和导出各算一遍,两边结果不一致。
5.2 表格渲染和导出的核心代码串联
表格渲染部分就是前文的多表头循环模板,加上spanMethod查询mergeMap。导出部分把同一个mergeMap传给 ExcelJS。串联的关键是一个工具函数:
function prepareTableModel(reportData) { const mergeMap = {}; reportData.mergeRules.forEach(rule => { const colIndex = rule.columnIndex; const field = rule.field; const subMap = buildRowMergeMap(reportData.data, field, colIndex); Object.assign(mergeMap, subMap); }); return { columns: reportData.columns, data: reportData.data, mergeMap }; }这样页面里所有逻辑都收敛了。用户看到表格是一个样子,导出的 Excel 打开也是一个样子,不会有"页面合并了但 Excel 没合并"的割裂感。跑通 Demo 时我特地对比过:页面里第 0 列有三行"华东组"合并成一行,导出的 Excel 也是同样的合并效果,只是 Excel 里多了表头两层,数据区起始行号要平移。
6. 我在实际项目中踩过的几个坑
6.1 合并单元格和固定列(fixed)的错位
表格列多了以后,为了横向滚动方便,通常会把前几列设置fixed="left"。但固定列和合并单元格同时存在时,会出现一个诡异现象:滚动时固定列覆盖了被合并的相邻列,或者合并区域的边框断裂。Element 内部对固定列的处理是额外渲染一份浮层表格,合并逻辑和浮层的计算机制不完全一致。
我的解决思路很简单:能不用 fixed 就不用,必须用时优先保证合并区域内没有 fixed 列。如果业务上非要固定,把固定列放在合并区域之外,或者用summary-method做合计行来代替部分固定需求。这个问题没有完美的通用方案,本质是组件内部渲染机制的边界,实用主义一点反而更高效。
6.2 导出后 Excel 提示"文件损坏/格式错误"
ExcelJS 导出最常见的报错有两种。一种是mergeCells区域重叠,比如先合并了A1:B1,又合并了B1:C1,Excel 打开就会提示修复。解决方案是构建合并区间时做一次去重和重叠检测,确保每个单元格只属于一个合并区域。
另一种是数据里出现了非法的特殊字符,或者日期值没格式化。Excel 对以=开头的内容会当成公式执行,如果用户数据里恰好有=1+2这种字符串,导出后打开就会变成计算结果,甚至还可能触发公式注入。我踩过这个坑后,给所有单元格赋值前统一做了类型处理:文本字段一律在前面加一个不可见前缀或设置cell.type,数字就明确用cell.value = Number(value)。
6.3 大数据量下的性能取舍
动态列的表格,数据超过 1000 行时,span-method的调用次数会非常高。如果把合并映射表构建放在span-method内部一次又一次执行,页面会明显卡顿。正确做法是把映射表放在computed里,表格数据变化时才重新计算。
导出场景也一样,ExcelJS 逐行逐列写数据,几千行没问题,几万行就会明显变慢。我最后的处理策略是:页面表格用 Element 渲染(数据量控制在几千行内),导出功能在前端做,但导出前增加一个"数据量过大时导出原始明细"的降级选项。如果单表超过 5 万行,还是建议走后端导出,前端导出在这个量级下意义不大。
6.4 一个老生常谈但必须说的事:接口协议要提前定死
多表头、动态列、合并规则,这三件事如果每个接口字段都是各写各的,前后端联调时一定会炸。我的经验是在项目启动阶段就把columns、data、mergeRules这三个字段的 JSON Schema 定义好,前端可以先用 mock 数据把整个模型跑通,后端再按协议输出。合并规则里的columnIndex到底指什么、rowspan最大到多少、空值怎么处理,全部写进接口文档,后面能少很多扯皮。
这次做完这套功能,我最深的感受是:复杂表格功能的核心不是某个组件 API,而是"列配置树 + 合并映射表 + 导出矩阵"这三个数据模型能否统一。一旦三者打通,不管是多表头还是动态列,都只是同一份配置的不同渲染形式。后面如果再遇到类似需求,先把数据结构理清楚,组件层面反而花不了太多时间。