聊表格单元格合并这个话题之前,我想先问你一个问题:你上一次手写HTML表格,是在什么时候?我在做一个后台数据报表页时被这个需求狠狠教育过——接口返回几十行数据,要把相同部门、相同状态的格子合并成一块,看起来很简单的功能,做起来却会因为一条数据多一个空格、两句代码少一个td,导致整张表错位。这篇文章会把表格单元格合并整个过程中我最常用的思路、最容易踩的坑、以及动态数据和Excel场景的批量处理都拆开讲一遍。不管你是前端开发、数据分析还是办公表格重度用户,只要能对着例子走一遍,后面就能少走很多弯路。
1. 合并单元格前必须建立“网格坐标”概念
1.1 表格本来就是一张二维坐标网
很多人在做表格合并时,第一个反应是去翻属性文档,查rowspan和colspan怎么写。我建议你先别急着写代码,先把表格当成一张二维网格来看:每一行有多个格子,每个格子都有一个“第几行、第几列”的坐标。合并单元格,本质上是让某一个格子通过rowspan或colspan一次性占用多个坐标位置。
举个例子,一个最简单的3行2列表格,在DOM里是3个tr,每个tr里有2个td,一共6个格子。此时如果把第一列的三行合并成一个格子,视觉上它仍然是3行2列,但DOM里需要删掉后面两行各自的第一列td,只剩下4个td。换句话说,不是“每个视觉格子都需要写一个td”,而是“合并占用了几个坐标,这一行就要少写几个td”。
新手最容易犯的错就是:只在前一行的td上加了rowspan="3",后面两行还是按照原来的习惯各写两个td。结果浏览器会把这多出来的两个td硬生生挤到新的一列里去,整张表从2列变成3列,对不齐。
提示:合并占据多少个格子,就删掉对应数量的td/th。判断标准是“视觉列数”,不是“tr里面写了几个td”。
1.2 rowspan表示往下占,colspan表示往右占
rowspan="n"的语义是:当前单元格从当前位置开始,向下跨越n行。也就是它把自己的格子位置占到了第n行,被跨过的后续行里,这一列就不再需要对应的td。
colspan="n"则是向右跨越n列。这两个属性可以同时出现在同一个td上,常见于复杂表头。比如下面这个例子:
html复制<table border="1">
<tr>
<td rowspan="2">分组</td>
<td colspan="2">数值范围</td>
</tr>
<tr>
<td>最小值</td>
<td>最大值</td>
</tr>
</table>
视觉上这是一个2行3列的表格。第一行的“分组”占(第1行, 第1列),同时向下延伸到第2行;“数值范围”占(第1行, 第2列)和(第1行, 第3列)。第二行的“最小值”“最大值”分别落在(第2行, 第2列)和(第2行, 第3列)。
注意第一行只有2个td,第二行也只有2个td,但视觉行数一致。原因就是“分组”横跨了两行,“数值范围”横跨了两列。很多人在第一行写了3个th,第二行写了3个th,最后怎么调都调不齐,就是因为没把网格坐标画出来。
1.3 动手前先画网格草图
我自己做表格合并前,一定会做一步几乎没人做的工作:拿纸笔或者直接在编辑器的注释里画一个几行几列的小网格。先把所有格子按网格位置标好,再把需要合并的区域圈出来,标注“这个格要占r行c列,剩下几行几列的td要删掉”。
比如需求是“第一列按部门合并,第二列按状态合并,第三列保留明细”,那我会先画一个3列的表格,把第一列中相同部门的纵向格子连接起来,再数一数每一行需要写几个td。这一步看起来土,但能解决80%的错位问题。尤其是当合并同时出现在多列、跨越多行时,光靠脑子想很容易漏算,画一遍就清清楚楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 静态表格的手工合并:表头分组与跨行跨列的排列套路
2.1 表头分组是最典型的手工合并场景
实际项目里,真正需要纯手写合并的,大多是静态表头,尤其是那种“季度报表”“学科成绩表”“排课表”之类的结构化表格。拿最常见的季度表来说,表头往往长这样:第一行有地区,然后第一季度、第二季度各占两列;第二行则是计划数、实际数。
对应HTML可以这样写:
html复制<table border="1" style="border-collapse: collapse">
<thead>
<tr>
<th rowspan="2">地区</th>
<th colspan="2">第一季度</th>
<th colspan="2">第二季度</th>
</tr>
<tr>
<th>计划</th>
<th>实际</th>
<th>计划</th>
<th>实际</th>
</tr>
</thead>
<tbody>
<tr>
<td>华东</td>
<td>120</td>
<td>110</td>
<td>140</td>
<td>135</td>
</tr>
<tr>
<td>华北</td>
<td>90</td>
<td>85</td>
<td>100</td>
<td>95</td>
</tr>
</tbody>
</table>
关键在第一行写了5个th,第二行只写了4个th。为什么第二行不写“地区”?因为第一行的“地区”用了rowspan="2",它已经把第二行的第一列位置占掉了。如果你在第二行习惯性地再写一个“地区”,整张表头就会多出一列,和下面的数据对不上。
提醒:合并后的表头,各行的th数量不一定要相等,但“视觉列数”必须一致。写完之后最好打开浏览器开发者工具,检查一下每行的实际网格列数。
2.2 数据区合并同类项:看起来整齐,但数据语义要想清楚
表头之外,第二种高频场景是数据区的“同类项合并”。例如产品A有3行明细,产品B有2行明细,希望把左侧产品名称列合并成一个大格子,右侧仍然按行展示各区域的数据。
html复制<tbody>
<tr>
<td rowspan="3">产品A</td>
<td>华东</td>
<td>100</td>
</tr>
<tr>
<td>华南</td>
<td>80</td>
</tr>
<tr>
<td>华北</td>
<td>60</td>
</tr>
<tr>
<td rowspan="2">产品B</td>
<td>华东</td>
<td>90</td>
</tr>
<tr>
<td>华南</td>
<td>70</td>
</tr>
</tbody>
这个写法里,产品A这部分的第一行有3个td,后两行都只有2个td;产品B同理。因为rowspan已经帮后两行“预留”了第一列的位置。
有一点一定要想清楚:合并只适合用在“左侧分类维度列”。右侧的明细数据通常不要合并,尤其是每一行代表一条独立记录时。比如一张订单表里,同一用户有多条订单,你如果把用户名合并了,视觉上会让人觉得这些订单是同一条记录,业务上很容易造成误读。
2.3 合并后的对齐、边框和行高细节
合并单元格在浏览器里的默认表现,往往和设计稿差一截。最常见的问题是文字不居中、边框线显得杂乱。我的惯例是首先给表格设置border-collapse: collapse,让相邻单元格的边框合并成一条线,否则合并区域周围容易出现双线,看起来像是表格破了个口子。
接着是文字对齐。合并单元格里的内容通常需要居中显示:
css复制td,
th {
text-align: center;
vertical-align: middle;
}
还有一个容易忽略的点:rowspan单元格的高度,不是平均分配,而是由它跨越的所有行里“最高的一行”决定。如果右侧某一行内容特别长,整个合并区域会被撑高,不是把高度按比例分给每一行。做导出、做分页预览时,这个细节尤其重要。
3. 动态数据合并:把相邻相同值自动算成rowspan的完整方案
3.1 动态需求到底在要什么
真实项目里,更多表格数据不是手写的,而是从后端接口拿到的数组。比如返回这样的数据:
javascript复制const rows = [
{ dept: '销售部', region: '华东', amount: 120 },
{ dept: '销售部', region: '华北', amount: 80 },
{ dept: '技术部', region: '华东', amount: 200 },
{ dept: '技术部', region: '华北', amount: 150 }
];
需求通常是这样一句话:第一列的部门,相邻且相同的值要合并;第二列区域要不要合并,看业务要求。这两个条件加在一起,就要求你先“算”出一个合并矩阵,然后根据矩阵决定每个单元格要不要写rowspan、要不要直接跳过不渲染。
这个问题的通用解法我拆成三步:先初始化一个二维矩阵,每一列的值默认都是1;然后逐列扫描,找到“从哪一行开始,连续几行值相同”;最后渲染时,如果某坐标的值是0,就代表这个格子被上方合并吃掉了,不输出td。
3.2 完整计算和渲染代码
下面这段代码是我在项目里反复用过的一套方案,可以直接按自己的字段去调整:
javascript复制/**
* 计算每列相邻相同值的 rowspan
* @param rows 原始数据数组
* @param mergeCols 需要合并的列名数组
* @returns 二维矩阵,spanMatrix[row][col]
* 值为0表示该坐标被上方合并,不渲染td
* 大于1表示该坐标是合并起点,需要写rowspan
*/
function calcRowspans(rows, mergeCols) {
const spans = rows.map(() => mergeCols.map(() => 1));
for (let ci = 0; ci < mergeCols.length; ci++) {
let anchor = 0; // 当前合并段的起始行
for (let ri = 1; ri <= rows.length; ri++) {
const lastVal = rows[ri - 1][mergeCols[ci]];
const currentVal =
ri < rows.length ? rows[ri][mergeCols[ci]] : null;
if (ri < rows.length && currentVal === lastVal) {
// 当前行和上一行同值,说明当前坐标被合并吃掉
spans[ri][ci] = 0;
spans[anchor][ci]++;
} else {
// 值发生变化,开启一个新的合并段
anchor = ri;
}
}
}
return spans;
}
渲染代码可以这样写:
javascript复制function renderMergedTable(container, rows, mergeCols, dataCols) {
const spans = calcRowspans(rows, mergeCols);
const table = document.createElement('table');
const tbody = document.createElement('tbody');
rows.forEach((row, ri) => {
const tr = document.createElement('tr');
mergeCols.forEach((field, ci) => {
if (spans[ri][ci] === 0) return; // 被上方合并吃掉,不输出
const td = document.createElement('td');
td.textContent = row[field];
if (spans[ri][ci] > 1) td.rowSpan = spans[ri][ci];
tr.appendChild(td);
});
dataCols.forEach((field) => {
const td = document.createElement('td');
td.textContent = row[field];
tr.appendChild(td);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
}
核心逻辑在anchor这个变量上,它记录当前合并段的起始行号。每次发现“当前行值等于上一行值”,就把当前坐标记为0,同时给起始行累加跨度。等到值变化时,把anchor移动到当前行,开始判断下一段。
这段代码最让我满意的地方是:不管需要合并的列是2列还是3列,都不用改逻辑。每一列独立计算,互不干扰。
3.3 动态合并容易踩的三类边界问题
第一类是数据“长得一样但类型不一样”。比如1和'1',严格比较时并不相等,如果后端数据源本身不稳定,会出现该合并的没合并。我一般会在数据接入后先做一次归一化,把数字转成字符串或把字符串去掉首尾空格。
第二类是null和undefined的处理。如果某一列某些行没有值,直接用===比较,null和undefined会被判定为不相等,导致合并断裂;反过来,如果两个空值连续出现,又可能被意外合并成一个大空格。我的建议是:业务上明确“空值不参与合并”,遇到空值直接强制新开一段。
第三类是“排序之后合并就废了”。用户在页面点一下按金额排序,原本相邻的相同部门就会被打散,旧合并矩阵完全失效。这种场景下,最优解不是“排序后不合并”,而是在排序回调之后重新调用一次calcRowspans,保证矩阵和当前数据顺序一致。
4. 组件化表格里的合并:真合并与伪合并怎么选
4.1 组件表格的合并通常是一层抽象
现在做后台系统,很少有人会完全手写表格渲染,大多数会用现成的组件库。组件库里的表格,数据区是一个数组,渲染由组件内部完成。因此你不能直接往<tr><td>上写rowspan,通常需要提供一个“合并函数”,告诉组件某个位置应该跨几行几列。
这类函数不同组件库叫法不同,有的叫spanMethod,有的叫mergeCell,接收的参数一般是当前行的索引、当前列的索引,以及当前行数据。返回值通常是rowspan和colspan。
这时候最优雅的用法是把上面那份calcRowspans算出来的矩阵直接喂给组件:
javascript复制const spanMap = calcRowspans(rows, mergeCols);
function spanMethod({ rowIndex, columnIndex }) {
const span = spanMap[rowIndex]?.[columnIndex] ?? 1;
return span === 0
? { rowspan: 0, colspan: 0 }
: { rowspan: span, colspan: 1 };
}
不同组件对rowspan: 0的处理不太一样,大多数组件会把它理解为“这个格子被隐藏”,本质上就等于跳过渲染。用之前一定要看文档确认,否则可能出现行高、边框残留的诡异问题。
4.2 排序、筛选之后的合并策略
组件表格最大的麻烦是同时存在合并、排序、筛选、跨页选择。排序会改变行顺序,筛选会减少行数,这两个操作都会让旧spanMap瞬间失效。我自己有一张决策表,分享给大家:
| 使用场景 | 推荐做法 |
|---|---|
| 排序后仍要保持合并效果 | 每次排序后重新计算合并矩阵 |
| 允许用户按被合并列排序 | 建议不合并,否则排序语义会混乱 |
| 需要单元格编辑、勾选行 | 尽量用伪合并,不要用真合并 |
| 数据量特别大、滚动频繁 | 用真合并,但关闭部分列排序能力 |
核心原则是:合并是展示层的处理,不要让它影响数据层的操作。如果用户确实需要频繁排序、筛选,我宁可放弃视觉合并,也不要做一堆重算合并矩阵的兼容逻辑。
4.3 伪合并:一种值得掌握的折衷方案
什么叫伪合并?就是每个单元格依然是独立的一个td,数据也完整地存在于每一行,但我通过“只让分组的第一行显示文字,后续相同行留空”的方式,让视觉上看起来像是合并了。
举个例子,如果当前行和上一行的部门值相同,就把当前行的部门文本设为空字符串:
javascript复制function getDisplayText(rows, rowIndex, colIndex) {
if (
rowIndex > 0 &&
rows[rowIndex][colIndex] === rows[rowIndex - 1][colIndex]
) {
return '';
}
return rows[rowIndex][colIndex];
}
这个方案的好处是:排序、筛选、导出、行选中全部正常,因为数据层始终是全量明细;同时视觉上又确实实现了“分组留白”的效果。
代价也明显:读屏软件会把那些空文本的单元格当成空白格,无障碍体验会比较差;另外空文本区域的边框需要通过CSS补出来,否则看起来只是“缺字”,而不是真正的合并块。
所以我一般会把伪合并用在内部工具、数据筛选频繁的后台页面;对外展示的正式报表,还是老老实实用真合并+重新计算矩阵。
5. 容易翻车的两个大坑:导出Excel与打印分页
5.1 导出Excel时,合并信息要怎么带过去
页面表格做了合并,导出成Excel时,如果只是简单地把每个td的文本写进单元格,导出的文件里就会失去合并效果。Excel支持合并区域,但它遵循一条规则:合并区域中只有左上角那个单元格有值,其他单元格必须为空。
所以导出前的数据准备思路应该是:把页面上的表格先读成二维数组,对每一列扫描相同值,记录连续区间,然后调用电子表格工具的合并区域方法。这个过程和calcRowspans的逻辑完全一样,差异只是落地API不同。
我在做导出时通常会额外注意两点:
- 合并区域不要跨空行,否则导出后出现“幽灵合并”,明明没有内容却占着一大片区域。
- 如果表格本身是伪合并,导出时一定不要靠空文本判断,要回到原始数据数组,从数据层判断哪些值相同,再决定是否合并。
5.2 打印分页:合并单元格跨页断裂基本无法彻底解决
浏览器打印一个长表格时,默认会在每个分页符位置把行切开。如果恰好有rowspan跨过了分页线,合并单元格会被截成上下两半,第二页只剩一个不完整的大半格。这个CSS层面没有完美解法,break-inside: avoid只对单个元素有效,对跨越整列的rowspan效果不稳定。
我的实践是:打印报表前先固定每页行数,或者在数据源层面保证每一组合并数据不超过一页的高度。比如每页最多打印15行明细,那就让合并段最多覆盖15行。听起来不够优雅,但实际最稳。
另外,确保表头在每一页都能重复打印,要给thead设置:
css复制thead {
display: table-header-group;
}
5.3 合并单元格容易忽略的无障碍问题
结构上,rowspan和colspan本身是很好的语义标记,屏幕阅读器能理解“这个格子跨越了3行”,所以HTML合并本身并不是无障碍灾难。真正有问题的是两点:一是表头没有标明分组关系,二是合并后部分单元格内容为空,读屏软件无法判断该给用户读什么。
表头建议这样补上scope:
html复制<th scope="colgroup">第一季度</th>
<th scope="row">产品A</th>
对于动态合并产生的空白格,能补aria-label就补,不能补的话至少保证旁边有单元格描述上下文,不要留一个百分百的空格。
6. 办公场景的同学看这里:Excel同类项合并的批量脚本
6.1 网页合并算法可以直接搬到Excel
很多做报表的同学看到前面HTML的算法,可能会觉得和自己没啥关系。实际上Excel里的“合并同类项”,核心逻辑完全一样:从某一行开始,往下扫描同一列的相邻值,只要连续相同就划入一个合并段,直到值变化或到达最后一行。
唯一不同的是,Excel脚本需要调用merge_cells这类库函数去操作区域。写脚本时最需要注意的是边界:最后一行的合并段怎么收尾、空值怎么处理、表头要不要跳过。
6.2 高效合并一列同类项的Python脚本
下面这段脚本可以合并某一列里所有相邻且相同的单元格,比如把第一列相同的部门名合并成一个大格:
python复制from openpyxl import load_workbook
def merge_same_column(path, sheet_name, col_idx, start_row=2):
wb = load_workbook(path)
ws = wb[sheet_name]
anchor = start_row # 当前合并段的起始行
# 多扫一行,用哨兵值强制处理最后一段
for row in range(start_row + 1, ws.max_row + 2):
if row > ws.max_row:
current_val = object() # 哨兵,和任何值都不相等
is_last_sentinel = True
else:
current_val = ws.cell(row, col_idx).value
is_last_sentinel = False
anchor_val = ws.cell(anchor, col_idx).value
# 空值不参与合并
if anchor_val is None or anchor_val == '':
anchor = row
continue
if current_val != anchor_val or (current_val is None and anchor_val is not None):
if row - 1 > anchor:
ws.merge_cells(
start_row=anchor,
start_column=col_idx,
end_row=row - 1,
end_column=col_idx
)
anchor = row
wb.save(path)
调用方式很简单,假如要合并“部门”列,也就是第1列:
python复制merge_same_column('报表.xlsx', 'Sheet1', col_idx=1)
这里有一个Excel合并的关键规律:合并之后,区域内只有左上角的值会被保留,其余值全部清空。所以执行脚本前一定要先备份,或者保证同一列的重复值本来就是冗余信息。
6.3 Excel合并之后的筛选、排序全是坑
Excel合并单元格最反直觉的地方在于:它破坏了表格的行结构。合并后,筛选某一行时会出现“合并单元格必须大小相同才能操作”的提示;排序时也会因为合并区域大小不一致而拒绝执行;公式引用合并区域时,只会取到左上角的值,跟预期经常对不上。
我现在的习惯是:源数据表永远不合并,所有合并操作只发生在“最终展示层”,也就是导出给人看的那份文件里。数据清洗、透视、分析都用普通明细表,到交付前再跑一遍合并脚本。这个习惯帮我省掉了大量返工,也推荐你试试。
我自己实际用下来的体会是:合并单元格这件事,代码层面并不复杂,复杂的是它跟排序、筛选、导出、打印、无障碍这些需求叠加之后的各种连锁反应。所有方案的选择都应该围绕一个原则——先保住数据结构的准确性,再去追求视觉上的整齐。希望这篇梳理能让你少踩几个我已经踩过的坑。
