业务里的表格从来不是「行 + 列」那么简单:要分页(前端分页 / 服务端分页)、多选、分组合并、固定列、列宽拖拽、斑马纹、空状态......现成的 Table 要么过重,要么缺能力。所以这篇文章想和大家聊一聊怎么手撸一个配置驱动、能打业务的 React Table 组件。
一、先想清楚:我们要解决什么问题?
一个「能打」的 Table 至少要覆盖这些场景:
- 配置驱动 :用一份
options描述表头、列、分页、选择行为,而不是在 JSX 里写死一堆<th>/<td> - 真假分页:前端切片分页(假分页)和服务端请求分页(真分页)共用一套表格逻辑
- 行选择:单选/多选、全选/当页全选、禁用某些行、分组下勾选联动
- 合并与分组 :按某一维度的
groupKey做行分组,并支持rowSpan/colSpan - 固定列:左右固定列 + 横向滚动时表头与 body 对齐
- 列宽拖拽:表头边缘拖拽改变列宽(固定列不参与)
- 体验细节:斑马纹、空数据提示、最大高度滚动、底部合计等
二、核心设计:用 options 驱动整张表
表头、列、分页、选择、是否可拖拽等,全部收口到 options,组件内部通过 setOptions 统一解析并挂到实例上,方便在 render 和生命周期里复用。
javascript
setOptions(options) {
const {
th = [],
tbody,
trAttr,
type = '',
key = 'sbTable',
rowSelection = {},
operations = {},
scrollable = false,
groupKey = '',
emptyText,
// ....
} = options;
this.th = th;
this.tbody = tbody;
this.type = type; // 'checkbox' | 'normal'
this.ref = key;
this.rowSelection = rowSelection;
this.scrollable = scrollable;
this.groupKey = groupKey;
this.emptyText = emptyText;
// ...
}
这样,使用方只需要传 options + dataSource,表格长什么样、怎么分页、选不选,都由配置决定。
三、表头与列
表头支持「多行」,所以用二维数组 th:每一行是一个 tr,每个元素是 th 的配置(title、rowSpan、colSpan、width、align、fixed 等)。body 列用一维数组 tbody,每项描述一列:key 直接取数据字段,或不用 key 而用 render(data, index, rowNum, groupIndex) 自定义渲染。
表头渲染时顺带把「固定列」的 class 打好(sticky-left / sticky-right),为后面的固定列布局做准备:
javascript
// 表头:支持多行 th,每行一个 tr
this.th.map((ths, index) => (
<tr key={`th-tr-${index}`}>
{this.type === 'checkbox' && index === 0 && (
<th className={`${this.ref}thh`} name="checkallbox" rowSpan={this.th.length}>
{!!this.state.dataSource.length && this.renderCheckAllBox()}
</th>
)}
{ths && ths.length && ths.map((th, ins) =>
(!th.skip || !th.skip()) && (
<th
key={`th-${index}-${ins}`}
rowSpan={th.rowSpan}
width={th.width || 'unset'}
colSpan={th.colSpan}
style={{ textAlign: th.align || 'center' }}
className={`${this.ref}thh ${
th.fixed && !!this.state.dataSource.length && th.fixed === 'right'
? 'sticky-column sticky-right'
: ''
} ${
th.fixed && !!this.state.dataSource.length && th.fixed === 'left'
? 'sticky-column sticky-left'
: ''
}`}
>
{th.title}
</th>
)
)}
</tr>
))
body 的每一列则根据 td 配置决定是走 key 还是 render,并统一处理 rowSpan、对齐、固定列 class:
javascript
this.tbody.map((td, ins) => {
const rowSpan = this.rowSpanRender(td, data);
return (!td.skip || !td.skip()) && rowSpan !== 0 && (
<td
key={`tb-td-${index}-${ins}`}
{...(td.tdAttr && td.tdAttr(data, index))}
rowSpan={rowSpan}
width={td.width || 'unset'}
colSpan={td.colSpan}
style={{ textAlign: td.align || 'center', ...td.style }}
title={this.isShowTitle ? data[td.key] : ''}
className={`${td.fixed === 'right' ? 'sticky-column sticky-right' : ''} ${
td.fixed === 'left' ? 'sticky-column sticky-left' : ''
}`}
>
{td.key
? (data[td.key] ?? td.emptyText)
: td.render.call(this, data, index, rowNum, this.computeGroupKeyIndex(data))}
</td>
);
})
有了「表头二维 + body 列描述」这一层,复杂表头、固定列、自定义单元格就都能在一份配置里表达清楚了。
四、数据与分页:visibleList 是「当前要渲染的那一页」
数据源是 dataSource,但真正参与渲染的是「当前页」的数据。组件里用 setVisibleList 根据是否分组、是否分页、真假分页,算出 visibleList 再 setState,这样 render 里只遍历 state.dataSource 即可。
分组时,先用 groupKey(以及可选的 groupKey2)把数据按维度聚合成 groupDataMap,再按分页截取;非 xhr 时直接对当前页做 slice,xhr 时通常整份 dataSource 就是当前页,只做分组展开即可:
javascript
setVisibleList() {
let visibleList = [];
let _list = this.data;
if (this.groupKey) {
let result = Util.prototype.Array.groupBy(this.data, this.groupKey);
this.state.result = result;
this.groupIndexMap = result.indexMap;
this.groupDataMap = result.dataMap;
this.groupKeyList = result.keyList;
_list = Util.prototype.Array.map2Array(this.groupDataMap);
}
// groupKey2 可再做一层分组...
let totalCount = this.xhr ? this.pagination.totalSize : _list.length;
if (this.pagination) {
let current = this.pagination.current || 1;
let pageSize = this.pagination.pageSize || 10;
if (!this.xhr) {
let start = (current - 1) * pageSize;
let end = current === pageCount ? totalCount : current * pageSize;
visibleList = _list.slice(start, end);
} else {
visibleList = _list;
}
} else {
visibleList = _list;
}
if (this.groupKey) visibleList = [].concat(...visibleList);
this.setState({ dataSource: visibleList, total: totalCount });
}
分页切换时,需要区分「假分页」和「真分页」:
- 假分页只改
pagination.current/pageSize,然后再调一次setVisibleList即可; - 真分页则交给父组件
onPaginationChange拉新数据,再更新pagination和dataSource,最 后同样走setVisibleList。
这样一套表格逻辑同时支持真假分页。
五、行选择与分组下的勾选联动
当 type === 'checkbox' 时,表头有「全选」框,每一行根据 rowSelection.disableCheck(row) 决定是否可勾选;支持「仅当页全选」和「全量全选」。分组时,同一组内勾选要联动(一组算一个「逻辑行」,checkbox 只在该组首行渲染,并设 rowSpan):
javascript
renderCheckBox(row, index) {
let rowSpan = 1;
if (this.groupKey) {
let groupData = this.groupDataMap[this.groupKey(row)];
rowSpan = groupData[0] === row ? groupData.length : 0;
}
if (rowSpan === 0) return null;
const { disableCheck, checkboxToolTip, isShowHj } = this.rowSelection;
const disabled = disableCheck && disableCheck(row);
const checkbox = (
<td name="checkbox" width="32px" rowSpan={rowSpan}>
<Checkbox
checked={row.checked}
disabled={disabled}
onChange={(e) => this.onCheck(e.target.checked, row)}
/>
</td>
);
return !(isShowHj && isShowHj(row))
? (checkboxToolTip ? <Tooltip title={checkboxToolTip(row)}>{checkbox}</Tooltip> : checkbox)
: <td name="checkbox" width="32px">合计</td>;
}
勾选/取消勾选时,若存在 groupKey,需要把同组所有行的 checked 同步,再根据是否 xhr 更新「全选」状态,并回调 rowSelection.onSelect / onSelectPage / onSelectAll。这样分组 + 多选 + 全选/当页全选都在一套逻辑里闭环。
六、rowSpan 与分组合并
除了 checkbox 的 rowSpan,普通列也支持「按分组合并」。rowSpanRender(td, data) 根据 td.rowSpan、td.combine(对应 groupKey)、td.combine2(对应 groupKey2)计算当前单元格应该占几行,同组非首行返回 0 表示不渲染该格(由首行的 rowSpan 占位):
javascript
rowSpanRender(td, data) {
if (td.rowSpan) return td.rowSpan;
let rowSpan = 1;
if (td.combine && this.groupKey) {
let groupData = this.groupDataMap[this.groupKey(data)];
rowSpan = groupData[0] === data ? groupData.length : 0;
}
if (td.combine2 && this.groupKey2) {
let groupData2 = this.groupDataMap2[this.groupKey2(data)];
rowSpan = groupData2[0] === data ? groupData2.length : 0;
}
return rowSpan;
}
这样,表头可以多行多列,body 可以按业务分组做合并,行列跨度都由配置 + 数据推导,无需手写一堆 rowSpan/colSpan。
七、固定列与列宽拖拽
固定列用 CSS position: sticky 实现,表头与 body 的对应列都加上 sticky-left / sticky-right。关键是要在滚动或列宽变化时,把「左侧宽度累加」和「右侧宽度累加」算准,赋给 left / right,这样多列固定时不会错位。在 setColumnStyle 里遍历表头行和 body 每一行的 cells,按索引累加左侧/右侧宽度并写回 style:
javascript
setOffset(elements) {
for (let i = 0; i < elements.length; i++) {
let Right = 0, Left = 0;
for (let r = i + 1; r < elements.length; r++) Right += elements[r].offsetWidth;
for (let l = 0; l < i; l++) Left += elements[l].offsetWidth;
if (elements[i].className.includes('sticky-right')) {
elements[i].setAttribute('style', `${elements[i].getAttribute('style')} right:${Right || -1}px;`);
} else if (elements[i].className.includes('sticky-left')) {
elements[i].setAttribute('style', `${elements[i].getAttribute('style')} left:${Left || -1}px;`);
}
}
}
列宽拖拽:在表头单元格上监听 mousedown / mousemove,靠近边缘(如 4px)时认为进入了「可拖拽」状态,按下后根据 evt.screenX 差值计算新宽度,并限制最小宽度 dragMinWidth;固定列不绑定拖拽。拖拽过程中可再次调用 setColumnStyle 让固定列的 left/right 跟着变,表格就不会「错位」。
八、斑马纹与空状态
斑马纹按「当前页」的行下标或按 stripeRowNum 为步长取奇偶,在 isStripe(index) 里返回不同的 backgroundColor,在 <tr style={this.isStripe(index)}> 上使用即可。无数据时 渲染一行 colSpan 覆盖整表的「空状态」,文案用 emptyText 或默认文案:
javascript
renderEmptydata() {
return (
<tr>
<td
className="empty-panal"
colSpan={this.tbody.length + (this.type === 'checkbox' ? 1 : 0)}
>
{this.emptyText ? this.emptyText.call(this) : '没有数据'}
</td>
</tr>
);
}
九、使用示例:配置即文档
业务侧只要组好 options 和 dataSource,表格就能跑起来。下面是一个「带复选框 + 分页 + 自定义列」的简化示例:
javascript
tableOptions = {
type: 'checkbox',
key: 'sbTable',
maxHeight: 385,
th: [[
{ title: '序号', name: 'xh' },
{ title: '编号', name: 'num' },
{ title: '名称', name: 'name' },
{ title: '处理状态', name: 'status' },
]],
tbody: [
{ align: 'center', key: 'xh' },
{ align: 'center', key: 'num' },
{ key: 'name' },
{
align: 'center',
render(row) {
return <span className={`status-${row.status}`}>{row.status}</span>;
},
},
],
pagination: {
current: 1,
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
},
rowSelection: {
onSelect: (selectedRows) => { /* ... */ },
disableCheck: (row) => row.disabled,
},
};
// 使用
<Table
options={this.tableOptions}
dataSource={state.tableData}
onPaginationChange={({ pageNumber, pageSize }) => this.loadData({ pageNum: pageNumber - 1, pageSize })}
/>
真分页时,父组件在 onPaginationChange 里请求接口,把 pagination.totalSize 和新的 dataSource 更新后再传回 Table,组件内部会据此重新 setVisibleList 并刷新全选状态。
十、小结
这样实现的 Table 不一定「大而全」,但能覆盖业务里最常见的一批需求,且易于在一个文件里维护和扩展。如果你也在为复杂表格发愁,不妨从「配置驱动 + 可见数据单一来源」这两点开始,手撸一版属于自己的 Table,说不定打一打业务需求会更顺手呢!