ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

复杂表格多级表头合并与Excel导出实战解析

复杂表格多级表头合并与Excel导出实战解析

最近在做后台系统里的数据报表模块时,遇到了一个非常典型的组合需求:一张表格既要支持多级表头分组展示,又得处理动态接口数据,还要把相同维度的单元格自动合并起来,最后还要一键导出成 Excel。单独看每一项都是组件的常规能力,但组合在一起踩了不少坑,尤其合并逻辑和导出结果的联调阶段,差点把自己绕进去。这篇文章就把我的完整实现思路、踩坑过程和可直接复用的代码结构整理出来,希望能给正好撞上同类需求的人省点时间。

1. 需求拆解:一张复杂表格到底复杂在哪

1.1 四类诉求背后的真实业务场景

先说业务背景。我接手的模块是一个“区域销售汇总表”,展示逻辑大致是这样:按大区划分,大区下面是城市,城市下面还能细分渠道,表头需要呈现出一种“大区→城市→渠道”的三级分组关系。同时表格数据不是写死的,而是从接口动态拉取,不同时间段的筛选条件不同,返回的列可能也会多一列少一列。表格里相同的大区名称、城市名称不能每个格子都重复一遍,需要做行合并,某些汇总列还需要做列合并。最后用户想把这张表原样导到 Excel 里做线下汇报,所以导出时不仅数据要对,表头分组和单元格合并也要尽量保留。

这个场景在运营后台、财务系统、库存管理平台里非常普遍。很多人一开始会觉得“table 不是自带多表头和合并吗”,但真实项目里往往是这几个需求叠加在一起,难度并不是单个功能的相加,而是数据维度、坐标计算和导出映射这三条线必须同时对齐。

1.2 方案选型:组件能力与自研算法的分界线

我用的组件库是 Element UI(Vue 2 技术栈,Vue 3 对应组件用法差异不大,核心思路一致)。先理清哪些能力是组件白送的,哪些必须自己写:

功能点组件自带能力需要自研的部分
多级表头嵌套el-table-column即可实现表头配置化数据结构、动态列的递归渲染
行合并/列合并提供span-method回调钩子合并区间算法、动态数据变化后的合并重算
动态数据data数组直接驱动渲染接口返回数据到表格展示数据的字段映射
导出 Excel组件本身不提供选择导出库、构建多表头二维矩阵、合并单元格坐标写入

选型时我从没考虑过“自己手写一个表格”这种方案,原因很简单:Element 的表格已经处理好了虚拟滚动、固定列、排序、筛选等大量边界情况,自己写一个兼容这些能力的表格代价极高。我的思路是,把组件当“渲染引擎”用,把复杂度收敛到数据预处理的纯函数里。这样导出和展示可以共用同一套数据结构和合并算法,避免维护两套逻辑。

2. 多表头表格结构设计与动态数据渲染

2.1 嵌套表头的基本写法

Element UI 的多表头实现方式是嵌套el-table-column。父级列负责显示分组标题,子列才是真正绑定字段的叶子列。最简单的手写结构长这样:

<template> <el-table :data="tableData" border> <el-table-column label="大区" align="center"> <el-table-column prop="region" label="区域名称" /> </el-table-column> <el-table-column label="销售数据" align="center"> <el-table-column prop="amount" label="销售额" /> <el-table-column prop="target" label="目标额" /> </el-table-column> </el-table> </template>

这种写法的优点是一目了然,缺点也很明显:列结构完全锁死在模板里,接口一旦动态增减列,模板就没法响应了。所以项目里我不会直接写这种硬编码模板,而是把表头配置抽成数据驱动。

2.2 动态表头配置与递归渲染

动态化第一步,定义表头配置的 JSON 结构。每个列对象必须包含几个关键信息:label(显示名称)、prop(绑定的数据字段路径)、children(子列数组,可选)。有children的就是分组列,没有的就是叶子列:

const headerConfig = [ { label: '大区', children: [ { label: '区域名称', prop: 'region' } ] }, { label: '销售数据', children: [ { label: '销售额', prop: 'amount' }, { label: '目标额', prop: 'target' } ] } ];

模板里用一个递归组件渲染这个配置。核心逻辑是判断当前列对象有没有children,有就继续嵌套下去,没有就渲染成叶子列:

<template> <el-table :data="tableData" border :span-method="mergeCellHandler"> <template v-for="col in headerConfig"> <el-table-column v-if="col.children && col.children.length" :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" align="center" /> </el-table-column> <el-table-column v-else :key="col.prop" :prop="col.prop" :label="col.label" align="center" /> </template> </el-table> </template>

如果你的层级更深(比如四级、五级),把这个模板改造成递归组件即可,核心判断逻辑完全一样。

2.3 数据源设计:为什么需要“叶子字段路径”

动态表头跑起来之后,真正容易出事的是数据和表头的对应关系。叶子列的prop必须能精确定位到数据对象里的某个字段。如果接口返回的是嵌套对象,比如sales: { amount: 100 },那prop就要写sales.amount,Element 内部会按路径去取。但我强烈建议在进入表格之前就把接口数据拍平成一维对象,prop直接用扁平字段名。

我这里的做法是在请求接口后加一层normalizeData函数,专门处理字段映射:

function normalizeData(rows) { return rows.map((row) => { return { regionCode: row.region_code, region: row.region_name, amount: Number(row.sales_amount), target: Number(row.sales_target) }; }); }

拍平的好处有三点:第一,span-method里读取row[column.property]非常稳定;第二,导出 Excel 时构建二维数组不需要再解析嵌套结构;第三,表格排序和筛选组件拿到的是干净字段,不会因为深层路径出幺蛾子。这是整个方案里最不起眼但最重要的一步。

注意:如果后端接口字段名不在前端控制范围内,字段映射尽量收敛在数据处理层,不要散落在组件代码里。否则接口字段一变,排查起来想哭。

3. 合并行与合并列的实现与算法拆解

3.1 span-method 合并钩子的基础用法

Element 的表格合并依赖span-method这个回调。表格渲染过程中,每个单元格都会调用一次这个方法,回调参数里包含当前行的数据row、列配置column、行号rowIndex、列号columnIndex。返回一个数组[rowspan, colspan],第一个值是行跨度,第二个值是列跨度。

mergeCellHandler({ rowIndex, columnIndex }) { // 比如:第一列每两行合并一次 if (columnIndex === 0) { if (rowIndex % 2 === 0) { return [2, 1]; } return [0, 0]; } }

这里的关键点在于:当某一行返回[2, 1]表示本单元格向下合并两行,那么被合并掉的下一行对应单元格必须返回[0, 0],相当于告诉表格“这个位置不画格子了”。[0, 0]很容易被忽略,漏写的效果就是表格出现错位或者多出空白格子。

3.2 自动合并算法:分组统计与位置映射

知道了span-method的规则,接下来要解决的是:怎么计算哪些行需要合并、跨度是多少。合并的本质是“连续相同值合并”。比如大区这一列里,“华东区”连续出现在第 0 行到第 2 行,那就应该合并成一个跨 3 行的单元格。

所以算法思路是先要确定按哪一列执行合并,然后对该列的所有数据做一次扫描,找出连续相同值的分组区间。注意“连续相同”这四个字,如果相同值中间隔了别的值,是不能跨行合并的。实际业务里为了让相同值连续,一般先按合并字段排序,再扫描分组。

我实现了一个calcMergeMap函数,负责生成一个“坐标 → 合并信息”的映射表:

function calcMergeMap(data, fields) { const mergeMap = {}; fields.forEach((field) => { let startIndex = 0; for (let i = 1; i <= data.length; i++) { const isLast = i === data.length; const valueChanged = !isLast && data[i][field] !== data[startIndex][field]; if (isLast || valueChanged) { const rowspan = i - startIndex; if (rowspan > 1) { for (let j = startIndex; j < i; j++) { mergeMap[`${j}_${field}`] = { rowspan: j === startIndex ? rowspan : 0, colspan: 1 }; } } startIndex = i; } } }); return mergeMap; }

这里有一个实用的设计:mergeMap的 key 是行号_字段名,而不是行号_列号。因为字段名不会因为表头顺序变化而变化,但列号会。在多表头背景下,叶子列的排列顺序一旦调整,用列号做 key 的算法就要跟着改,用字段名做 key 就稳多了。在span-method里通过column.property拿到当前列绑定的字段,然后去mergeMap里查:

mergeCellHandler({ row, column, rowIndex }) { if (!column.property) return; const mergeInfo = this.mergeMap[`${rowIndex}_${column.property}`]; if (mergeInfo) { return [mergeInfo.rowspan, mergeInfo.colspan]; } }

如果只按大类列合并,其他列保持普通显示,这个函数就足够了。但实际需求里往往有多列同时合并,比如大区相同合并大区,大区内部城市相同也要合并城市。这种情况calcMergeMap传入多个字段即可。

3.3 多级表头下的合并坐标计算

多级表头给合并带来的最大干扰是坐标混乱。span-method 里的rowIndex是数据行的索引,与表头层级无关,从 0 开始;但columnIndex就有文章了,它不是源配置数组里的索引,而是 Element 内部把所有叶子列“展平”之后的索引,从左到右计数。比如我的表头配置是这样的:

  • 第 0 列:区域名称
  • 第 1 列:销售额
  • 第 2 列:目标额

即使“区域名称”挂在“大区”这个分组列下面,columnIndex依旧是 0。这其实对我们有利,因为导出时我也可以按这个顺序构建数据矩阵,两边坐标对齐就不会错。

但有一种情况要特别小心:表头分组列的label本身不参与数据单元格的坐标计数,span-method中的row是该行的原始数据对象,所以如果分组列下有两个子列,当前行的row会同时包含这两个子列的数据。做合并时不要对分组列本身(没有property的列)做任何处理,因为组件已经帮你把表头分组画好了。

3.4 动态数据变化后的合并重算

接口重新请求、筛选条件变化、表格数据源整体替换,都会导致之前的合并映射失效。我在组件里用一个watch来监听表格数据,数据一变就重新执行calcMergeMap,然后再用nextTick确保渲染完成:

watch: { tableData: { handler(newData) { this.mergeMap = calcMergeMap(newData, ['region', 'city']); this.$nextTick(() => { // 强制表格重绘 }); }, immediate: true } }

这里有个很容易踩的隐形坑:表格渲染本身是异步的,如果watch里算完合并映射后不调用$nextTick,可能会导致表格内部状态没有及时刷新,尤其是在某些浏览器里会出现“滚动后合并状态错乱”的现象。前期我排查了很久,最后发现把重绘动作挂到nextTick里才稳定。

4. 多表头表格导出 Excel 完整实现

4.1 三种导出方案怎么选

Element UI 不自带导出能力,社区里常用的方案有三个:xlsx(SheetJS)、xlsx-js-style、exceljs。我直接说结论:

方案优势劣势适用场景
xlsx体积小、上手快、社区案例多样式支持弱(无边框/宽高设置)纯数据导出
xlsx-js-style支持基础样式(边框、背景、字体)功能相对单一需要简单样式时
exceljs样式丰富、可操作单元格对象体积大、API 较重复杂报表样式要求高

我这个需求既要保留多表头合并,又希望导出的表格有边框、有列宽,最终选了exceljs。如果你是临时用一下、不追求样式,直接用xlsx就够了;如果以后要把报表做得像后台系统里那么精致,exceljs的上限更高。

4.2 构建多表头数据矩阵

导出 Excel 的第一步是把表头配置转换成二维数组,也就是 AOA(Array of Arrays)。这个二维数组的第一行对应 Excel 的第一行,第二对应第二行,以此类推。多表头的转换逻辑也不复杂:递归遍历配置,每个分组列占一层,叶子列的label写到最底层。

我写了一个buildHeaderMatrix函数:

function buildHeaderMatrix(config, depth = 0, matrix = []) { config.forEach((col) => { if (col.children && col.children.length) { matrix[depth] = matrix[depth] || []; // 在鼓层放分组标签 const startCol = getCurrentCol(matrix, depth); matrix[depth].push(col.label); // 递归子列 buildHeaderMatrix(col.children, depth + 1, matrix); // 处理跨列合并信息 const childColCount = getLeafCount(col); if (childColCount > 1) { matrix[depth].push(col.label); // 这里需要按子列数补位 } } else { matrix[depth] = matrix[depth] || []; matrix[depth].push(col.label); // 下层是叶子,需要补位到最深层 } }); return matrix; }

这段代码写出来容易乱,核心原因是“分组列占一行、叶子列占一行(或更多)”的结构需要同时处理水平方向和垂直方向的空间占位。我的建议是不要现写,先在纸上画一个 3 层表头的矩阵草图,把“这个单元格在 Excel 里的第几行第几列”标清楚,再动手编码,思路会清晰很多。

最终生成的 headerMatrix 示例(两级表头):

[ ['大区', '', '销售数据'], ['区域名称', '销售额', '目标额'] ]

大区的格子水平跨 1 列、垂直跨 2 行,销售数据的格子水平跨 2 列、垂直跨 1 行。

4.3 合并单元格导出

有了 headerMatrix,接下来的关键动作是生成!merges合并区域数组。在exceljs(以及xlsx)里,合并单元格的坐标是 0 起算的,行列都有起点和终点,用对象表示就是:

{ start: { row: 1, col: 1 }, end: { row: 2, col: 1 } }

这是一个垂直合并,把第 1 行第 1 列到第 2 行第 1 列的区域合并成一个单元格。多表头导出时有两类合并需要处理:

第一类是表头区域的合并。例如上面例子中,“大区”这个分组列从第 1 行第 1 列到第 2 行第 1 列要合并;“销售数据”从第 1 行第 2 列到第 1 行第 3 列要合并。这些合并信息可以在buildHeaderMatrix递归时同步收集,把每个非叶子节点生成一个 merge 对象。

第二类是数据区域的合并,和页面展示用到的mergeMap一一对应。把之前算好的行合并、列合并信息换算成 Excel 坐标写入工作表即可:

function addDataMerges(worksheet, data, fields) { const mergeMap = calcMergeMap(data, fields); const headerRows = headerMatrix.length; Object.keys(mergeMap).forEach((key) => { const info = mergeMap[key]; if (info.rowspan > 1) { const [rowIndex, field] = key.split('_'); const colIndex = leafFields.indexOf(field); const startRow = headerRows + Number(rowIndex) + 1; worksheet.mergeCells(startRow, colIndex + 1, startRow + info.rowspan - 1, colIndex + 1); } }); }

这段代码里最容易出错的是坐标系换算。页面上 span-method 的行号从 0 开始对应数据第一行,但 Excel 里第 1 行是表头,所以数据区域的起始行要做偏移。我之前在这个换算上至少改了三版,最后一次是用“打印 console 日志 + 打开 Excel 对照”的方式逐步对齐的。

4.4 导出文件名、样式与兼容性处理

导出不是拿到文件就万事大吉了,还有三个细节影响体验:

第一,文件名。建议加上时间戳,避免用户多次导出得到同一个文件名而困惑:

const fileName = `销售汇总表_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;

第二,列宽。exceljs里可以按每列的实际内容宽度估算,也可以按叶子列固定设置。我的做法是遍历数据,取该列字符串最大长度,再乘一个系数作为列宽,这样导出的表格不会出现“数字变成井号”的窘境:

worksheet.columns = leafFields.map((field) => ({ header: field, width: Math.max( ...data.map((row) => String(row[field] || '').length), 10 ) }));

第三,数字格式。如果数据里有金额、百分比,导出时直接写入数字会丢失格式。exceljs里可以设置单元格的numFmt,例如金额用#,##0.00。这不是必须项,但财务场景下格式错了用户会立刻发难。

注意:如果接口返回的是字符串形式的数字,导出前一定要转成 Number,否则 Excel 可能会把它识别成文本,导致求和、排序、图表等功能全部失灵。这也是我前面在 normalizeData 里就做Number()转换的原因之一。

5. 常见问题与排查技巧实录

5.1 合并状态不随数据刷新

这是我在项目中遇到最多的问题。明明watch里重新算了mergeMap,页面表格的合并状态却不更新,甚至出现“合并的格子还保留着上一批数据”的错觉。后来排查发现,核心原因有两个:一是mergeMap是在 data 赋值后立即计算的,但 Element 内部渲染异步,计算出来的映射和实际渲染的数据快照可能不同步;二是mergeMap被直接放在组件的 data 里,虽然引用变了,但表格内部没有触发重绘。

解决办法是在数据变化后手动强制刷新表格。我用的方案很简单,给el-table加一个key,数据更新时递增:

<el-table :key="tableKey" :data="tableData" :span-method="mergeCellHandler">

tableKey每次数据更新加 1,相当于强制重新挂载表格。代价是表格无法保持原来的滚动位置和编辑状态,但在报表展示场景下可以接受。如果你的表格需要保留滚动位置,那就要换更精细的doLayout刷新方案。

5.2 导出 Excel 与页面显示不一致

导出后表头错位,几乎都是构建矩阵或者合并坐标越界导致的。排查方法非常机械但有效:把生成的二维矩阵打印出来,一格一格对照 Excel 里的单元格。

我踩过的典型错误是:构建 headerMatrix 时,分组列的子列数量不一致(有的分组有 3 个叶子,有的只有 1 个叶子),导致第 1 行的分组标签没有填满整行,后面所有列的索引全部前移。这种问题很难通过肉眼检查代码发现,只能靠打印矩阵来定位。另一个常见错误是mergeCells时把起点和终点写反了,Excel 会直接报错或生成奇怪的合并区域。

5.3 表格数据量一大就卡顿

当数据行数超过 1000 行,并且使用span-method做合并时,Element 的性能会明显下降。原因不难理解:每个单元格渲染都要调用一次span-method,而且我最初的实现里还是在回调里现算 mergeInfo,相当于 N 个单元格 × 每次查表,重复劳动很多。

优化思路有两个方向。第一,把 mergeMap 查询优化成 O(1),用前面行号_字段名的 map 结构,不要在span-method里做数组遍历或正则匹配。第二,如果数据量继续往上走,建议开启 Element 的虚拟滚动(或切换到别的支持虚拟表格的组件),合并逻辑保持不变,只是渲染层换掉。我在实际项目中把阈值设成 800 行,超过就走分页,体验最稳定。

5.4 几个容易忽略的隐藏坑

这个环节分享三个我个人的独特经验。

第一个坑是合并单元格区域内的行点击事件错位。表格里如果加了行点击弹窗,用户点击合并格子的下半部分时,事件绑定的row是被合并的起始行,而不是用户视觉上“正在点击”的那一行。做行级操作时需要根据rowIndex+rowspan关系做一次映射校正。

第二个坑是固定列和合并列的冲突。Element 的fixed属性在合并场景下偶尔会出现左边锁定的列和中间合并列错位的问题,视觉上像是一列“滑”过去了。我最终放弃了对合并列使用 fixed,改成固定外层操作列来绕开。

第三个坑是字段名冲突。如果某个叶子列绑定的字段叫children,或者表头配置里label重复,Element 内部渲染会出一些莫名其妙的警告,排查起来很费时间。统一命名约定:label是展示名,prop是数据字段,children只能是子列数组。

最后再分享一个我从这个项目里沉淀出的判断

复杂表格需求真正考验的不是你会不会用某个组件,而是你能不能把“展示层”问题和“数据层”问题分开。表头分组、单元格合并、Excel 导出这几件事看起来是三个独立功能,但它们的共同地基只有一个——结构良好的数据模型和一份统一的配置化列定义。只要你把列配置抽出来,让展示、合并、导出都读同一份配置,后续加需求(比如加打印、加移动端适配)都会轻松很多。我接下来的版本里也打算把这份列配置直接接到后端接口去,让运营同学自己配表头。这个方向如果你也感兴趣,建议先把本文提到的数据归一化和合并算法的基础打牢。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进