
本文详解如何使用 `rowspan` 属性让表格左侧的分类标签(如 label 1、label 2)垂直跨多行,避免重复渲染,匹配真实分组数据结构。核心在于预统计每组标签的行数,并在首次出现时设置 `rowspan`,后续同组行跳过该单元格渲染。
在构建分组型表格(如带左侧标签栏的配置表、指标报表或表单摘要)时,常见需求是:同一逻辑分组下的多行数据共享一个左侧标签,而非每行重复显示。HTML 原生支持通过 <td rowSpan="N"> 实现单元格纵向合并,但 React 中需结合数据结构动态计算与条件渲染,否则易出现错位、重复或 rowSpan 值错误等问题。
✅ 正确实现步骤
-
预统计每组标签的行数
使用 reduce 遍历原始数据,按 rowLabel 聚合计数,生成映射对象 numRowsByLabel:const numRowsByLabel = data.reduce((acc, row) => { acc[row.rowLabel] = (acc[row.rowLabel] || 0) + 1; return acc; }, {}); 逐标签遍历,内部按数据行渲染
外层 labels.map() 确保标签顺序可控;内层 data.map() 筛选归属当前标签的行,并仅在该组第一行渲染带 rowSpan 的 <td>,其余行跳过该列。-
关键逻辑:用状态标记“是否首行”
注意:React 渲染函数需纯函数式,不可依赖闭包外变量。因此应在每次 label 循环内重置 isFirstRow = true,并在处理每行时更新:{labels.map((label) => { let isFirstRow = true; // ✅ 每个 label 独立作用域 return data .filter(row => row.rowLabel === label) .map((row, idx) => { const numRows = numRowsByLabel[label]; const shouldRenderLabelCell = isFirstRow; isFirstRow = false; // ✅ 标记已处理首行 return ( <tr key={`${label}-${idx}`}> {shouldRenderLabelCell && ( <td rowSpan={numRows} style={{ width: '33%', textAlign: 'center', borderRight: '1px solid #E1D9D6', fontSize: '9px' }}> {label} </td> )} <td style={{ width: '50%', paddingLeft: '5px', fontSize: '9px' }}>{row.key}</td> <td style={{ width: '50%', textAlign: 'right', paddingRight: '5px', fontSize: '9px' }}>{row.value}</td> </tr> ); }); })}
⚠️ 注意事项
- key 必须唯一且稳定:推荐组合 label 与 idx(如 ${label}-${idx}),避免仅用数组索引(ndx),防止重排时 React Diff 异常。
- rowSpan 值必须准确:若某标签实际对应 3 行,但 rowSpan="3" 却只渲染了 2 行 <tr>,末尾将留空导致布局错乱。务必确保 filter 后的行数与 numRowsByLabel[label] 严格一致。
- 样式兼容性:避免在 <td> 内嵌套 div 或使用 display: flex(会破坏表格盒模型)。所有样式应直接作用于 <td>/<tr>,保持语义化表格结构。
- 空标签防护:若 labels 数组包含无对应数据的标签,filter(...).length === 0 时需跳过整个 map,避免渲染空 <tr>。
✅ 最终效果对比
| 问题代码(错误) | 正确实现(rowSpan) |
|---|---|
| 每行重复渲染 LABEL 1 → 布局冗余、语义混乱 | LABEL 1 单元格 rowSpan="2",覆盖两行高度,视觉与语义统一 |
通过以上方法,你不仅能解决当前的渲染问题,还能构建可扩展的分组表格组件——未来支持排序、折叠、动态加载时,只需复用 numRowsByLabel 逻辑即可。记住:rowSpan 不是魔法,而是对数据分组关系的显式声明。











