后台报表不踩坑:el-table 多表头+动态列+合并单元格+导出Excel

发布时间:2026/10/17 2:18:15
后台报表不踩坑:el-table 多表头+动态列+合并单元格+导出Excel 做后台管理系统三年以上的前端基本都会碰到一个绕不开的需求一张表格表头要分组好几层列数是后端动态返回的数据里还要合并相同项的单元格最后还得一键导出成Excel。这四个需求拆开看都不难但组合到一起尤其是数据一变就要重新计算合并、导出还要还原多级表头和合并单元格就会有一堆细节问题冒出来。这篇文章把我最近一个报表模块里完整实现多表头 动态列 合并行/列 导出Excel的过程和踩坑记录下来给正准备做同样功能的朋友一个可以直接抄的解决方案。1. 多表头不只是嵌套el-table-column 的分层渲染与表头分组逻辑1.1 从嵌套写死到多级表头的最小实现Element 的表格组件做多表头最基础的写法就是嵌套。父级el-table-column不设置prop只作为分组容器子级设置prop对应数据字段。页面效果就是父级表头跨列子级表头按字段分布。template el-table :datatableData border el-table-column label基本信息 aligncenter el-table-column propname label姓名 width120 / el-table-column propage label年龄 width80 / /el-table-column el-table-column label业绩信息 aligncenter el-table-column proporderCount label订单数 width100 / el-table-column proptotalAmount label总金额 width120 / /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 :datatableData border template v-forcol in columns el-table-column v-if!col.children || !col.children.length :keycol.prop :propcol.prop :labelcol.label :widthcol.width aligncenter / el-table-column v-else :keycol.label :labelcol.label aligncenter el-table-column v-forchild in col.children :keychild.prop :propchild.prop :labelchild.label :widthchild.width aligncenter / /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。第二注意typeselection和typeindex这类特殊列。当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要和表格实际列索引对应。如果表格有typeselection固定列那姓名列的索引可能不是 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为什么我最终选了前端生成 xlsx4.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 填充数据与合并单元格拿到表头矩阵后把数据填充进 Excelimport 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的错位表格列多了以后为了横向滚动方便通常会把前几列设置fixedleft。但固定列和合并单元格同时存在时会出现一个诡异现象滚动时固定列覆盖了被合并的相邻列或者合并区域的边框断裂。Element 内部对固定列的处理是额外渲染一份浮层表格合并逻辑和浮层的计算机制不完全一致。我的解决思路很简单能不用 fixed 就不用必须用时优先保证合并区域内没有 fixed 列。如果业务上非要固定把固定列放在合并区域之外或者用summary-method做合计行来代替部分固定需求。这个问题没有完美的通用方案本质是组件内部渲染机制的边界实用主义一点反而更高效。6.2 导出后 Excel 提示文件损坏/格式错误ExcelJS 导出最常见的报错有两种。一种是mergeCells区域重叠比如先合并了A1:B1又合并了B1:C1Excel 打开就会提示修复。解决方案是构建合并区间时做一次去重和重叠检测确保每个单元格只属于一个合并区域。另一种是数据里出现了非法的特殊字符或者日期值没格式化。Excel 对以开头的内容会当成公式执行如果用户数据里恰好有12这种字符串导出后打开就会变成计算结果甚至还可能触发公式注入。我踩过这个坑后给所有单元格赋值前统一做了类型处理文本字段一律在前面加一个不可见前缀或设置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而是列配置树 合并映射表 导出矩阵这三个数据模型能否统一。一旦三者打通不管是多表头还是动态列都只是同一份配置的不同渲染形式。后面如果再遇到类似需求先把数据结构理清楚组件层面反而花不了太多时间。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询