前言
在前端中后台项目中,表格是最常见也是最基础的数据组件。表格主要以二维栅格的形式呈现,能够清晰明了地展示数据。
在基于表格的项目开发中,我们经常遇到的一个问题,就是如何平衡功能的完备性与可拓展性。功能完备的表格,比如常用的 Antd Table ,能够满足我们业务开发的大部分场景,但是当我们实现一些个性化的需求时,往往需要改动到底层的逻辑。
计划通过调研一些常见的 React 表格功能实现方案,帮助大家进行合理的技术选型,也为自己造轮子提供一些可供参考的方案。
本文先从表格组件的最基本的行列布局讲起。
组件 API 设计
目前,市面上常见的表格方案有 Antd Table、Fusion Table、ali-react-table、Material-UI Table 和 React Suite Table 等。一个功能完备的表格组件通常会提供以下的一些功能:
- 数据展示,包括表格行合并/列合并
- 固定列
- 数据排序和筛选
- 数据分页
- 行选择
- 列宽调整/拖拽排序
- 虚拟渲染
- 可编辑单元格/行
目前,主流的组件 API 设计大体可以分为两种(大部分设计会同时兼容这两种风格的使用):
| 方案 | 优点 | 缺点 |
|---|---|---|
| 数据源驱动 | 用户只需要提供 columns 和 dataSource ,其余渲染的细节完全交给组件内部;便于内部进行一些渲染和性能优化 | 组件的逻辑更加抽象,隐藏了内部的细节,限制了组件的布局 |
| 暴露子组件 | 简单直观,使用完全的JSX 风格,通过暴露 Table 的子组件 API,由用户自行控制渲染的细节,布局比较灵活 | 用户需要编写更多的代码来控制渲染的细节 |
数据驱动
依赖数据源的 API 设计封装性较高,表格的布局和行为可以完全通过 columns 配置来控制,能快速创建组件。一个使用示例如下:
const dataSource = [
{
key: "1",
name: "胡彦斌",
age: 32,
},
{
key: "2",
name: "胡彦祖",
age: 42,
},
];
const columns = [
{
title: "姓名",
dataIndex: "name",
key: "name",
},
{
title: "年龄",
dataIndex: "age",
key: "age",
},
];
<Table dataSource={dataSource} columns={columns} />;
但是,因为封装隐藏了组件的内部细节,当我们需要拓展功能的时候就会比较困难。比如,当我们需要覆盖默认的 table 元素时, Antd Table 需要通过 component API 来处理( 官网的一个拖拽排序 demo)。
暴露子组件
另一种设计则是通过提供子组件的形式,这种写法灵活度比较高,用户无需通过属性来配置。例如以下的一个 Material-UI Table 使用示例:
<Table className={classes.table} aria-label="simple table">
<TableHead>
<TableRow>
<TableCell>Dessert (100g serving)</TableCell>
<TableCell align="right">Calories</TableCell>
<TableCell align="right">Fat (g)</TableCell>
<TableCell align="right">Carbs (g)</TableCell>
<TableCell align="right">Protein (g)</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map(row => (
<TableRow key={row.name}>
<TableCell component="th" scope="row">
{row.name}
</TableCell>
<TableCell align="right">{row.calories}</TableCell>
<TableCell align="right">{row.fat}</TableCell>
<TableCell align="right">{row.carbs}</TableCell>
<TableCell align="right">{row.protein}</TableCell>
</TableRow>
))}
</TableBody>
</Table>;
子组件的主要可以拆分成以下几个部分:
<TableContainer /><Table /><TableHead /><TableBody /><TableRow /><TableCell /><TableFooter /><TablePagination />
这种暴露子组件接口可以方便我们以 JSX 的风格来编写代码。不过在有些组件实现中,这种写法可能也只是一个语法糖。比如,Antd Table 和 Rsuite Table 的 Column 组件实际上还是通过拦截和收集属性,然后再由内部定义渲染的。
组件布局方案
表格组件和 table 元素之间关联密切。基于原生的 table 元素,我们可以很容易地实现一些复杂的数据展示,比如行合并,列合并等。但是,由于table 元素有内在的一套布局规范,在自适应布局上不够灵活。因此,在设计表格组件的时候,我们需要衡量方案的适应场景:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 基于原生 table 元素实现 | 基于 table 原生,无需实现一套布局规范,实现简单 | 组件的结构和布局效果受到 table 布局规范的限制 |
| 自定义一套布局方案,比如采用绝对定位、Flex 或者 Grid 实现 | 布局灵活,可以根据需要自由组合,在自适应布局上表现良好 | 需要实现行列排布功能,相比 table 实现起来比较复杂 |
基于 table 的布局
在 Antd Table 中,表格的行为和原生 table 的表现基本一致。因此,这一节我们重点讲解原生 table 的布局思想。
表格格式化
表格格式化指的是表格的基本构成以及表格中元素之间的关系。表格格式化是表格布局的基础。在 CSS 中,表格有很多特有的行为和规则,能将不同尺寸的元素整齐的排列在一起。
表格模型是 “行主导” 的,也就是说标记语言会显式声明行,而列则是从行中单元格的布局衍生而来的。除了通过标记语言显示声明表格,我们也可以通过 display 属性来指定元素的表现行为,一些值如下:
table { display: table }
tr { display: table-row }
thead { display: table-header-group }
tbody { display: table-row-group }
tfoot { display: table-footer-group }
col { display: table-column }
colgroup { display: table-column-group }
td, th { display: table-cell }
caption { display: table-caption }
在 CSS 中,表格的渲染以及样式作用的效果是有优先级的。如上图所示:
- 最底层是
table元素,定义生成一个透明的块级框表格 - 第二层是
column groups, 我们可以指定一系列colgroup元素用于定义表格中对应的每一列的表现行为,不渲染实际元素 - 第三层是
columns,它的作用是定义列中每个单元格的表现,对应的元素是col - 第四层是
row groups,它定义由一行或多行构成的行组,对应的元素是tbody - 第五层是
rows,它定义由单元格组成的行的表现,对应的元素是tr - 第六层是
cells,它定义表格中每个单元格的表现,对应的元素是th和td
一个完整的表格布局可以声明如下:
<table>
<!-- 可选的标题 -->
<caption>
标题
</caption>
<!-- 可选的列组 -->
<colgroup>
<col style="width: 100px; min-width: 100px;">
</colgroup>
<!-- 可选的表头 -->
<thead>
<tr>
<th>name</th>
<th>age</th>
<th>year</th>
<th>address</th>
</tr>
</thead>
<!-- 表格数据 -->
<tbody>
<tr>
<td>name-0</td>
<td>20</td>
<td>2020</td>
<td>0.45431075531901444</td>
</tr>
</tbody>
<!-- 可选的表尾 -->
<tfoot>
<td>name-footer</td>
<td>age-footer</td>
<td>year-footer</td>
<td>address-footer</td>
</tfoot>
</table>
表格布局
表格的宽度有两种确定方式:固定宽度布局和自动宽度布局。用户代理计算表格布局时,固定宽度的表格布局要比自动宽度模型快一些。
固定宽度布局
在 table 上使用以下属性触发固定宽度布局:
table-layout: fixed;
固定布局模型无需考虑单元格中的内容,宽度布局由表格的宽度、列的宽度以及单元格的边距和边框决定。
- 整列的宽度可以由
col决定,或者由第一行的cell决定;如果设置的宽度值非auto,则该值为整列的宽度 - 对于未设置宽度的列,表格的剩余空间将会尽可能地平分到各列
- 表格如果未设置宽度,那么最终的表格宽度为各列之和中最大的那个决定;否则,由设定的表格宽度决定,并影响列的宽度分配
一个宽度分配的例子如下:
自动宽度布局
在 table 上使用以下属性触发自动宽度布局:
table-layout: auto;
自动布局模型需要考虑每个单元格的内容,用户代理需要读完表格的全部内容才能开始排布,因此自动布局比固定布局要慢。
- 计算一列中每个单元格的最小宽度和最大宽度
- 计算各列的最小宽度和最大宽度
- 表格如果未设置宽度或者宽度小于计算得到的列宽和,那么表格最终的宽度等于列宽度、边框和间距之和;否则,如果表格宽度大于列宽和,则多出的宽度将平分后追加到原本计算的列宽上
一个宽度分配的例子如下:
表格高度
表格高度在很多地方是由实际的用户代理决定的:
- 显示指定表格的高度
- 高度比各行高度之和小,无效
- 高度比各行高度之和大,由用户代理决定留白或者增加行高
- 未指定表格的高度,由各行高度计算叠加得到
基于 table 的表格组件实现
我们基于 Antd Table 和 ali-react-table 的源码,实现一个最基础的表格组件设计:
- 基于 table 布局,使用
tr和td等元素定义行列布局 - 使用 columns 和 dataSource 的配置渲染各行 & 各列的单元格
- 使用
colgroup控制各列的宽度 codepen 演示
关键代码是基于colgroup 实现宽度配置:
const ColumnGroup: React.FC<{ columns: ColumnType[] }> = ({ columns }) => {
const columnWidths = columns.map((ele) => ele.width).join('-')
const cols = useMemo(() => {
let cols: React.ReactElement[] = []
let mustInsert = false
for (let i = columns.length; i >= 0; i--) {
const width = columns[i] && columns[i].width
if (width || mustInsert) {
cols.unshift(
<col
key={i}
style={{ width, minWidth: width, textAlign: columns[i].align }}
/>
)
mustInsert = true
}
}
return cols
// eslint-disable-next-line
}, [columnWidths])
return <colgroup>{cols}</colgroup>
}
基于 Grid 的布局
在表格组件设计中,使用栅格(grid)布局也是一个不错的方案,因为 grid 本身就是网格(二维)布局,也有行和列的概念。但是,grid 布局比 table 布局更加强大,它无需考虑元素在文档中的顺序和布局,同时,在自适应布局上,grid 布局的表现也更加优秀。
栅格布局的基础概念
创建栅格的第一步是定义栅格容器 (grid container) ,栅格容器的子元素是栅格元素 (grid item) 。容器中的水平区域称为“行”,垂直区域称为“列”。
使用 display 属性可以创建栅格格式化上下文:
.grid {
display: grid;//块级框
/* or */
display: inline-grid;//行内框
}
栅格布局中的一切都依赖于栅格线,栅格线的布局方式非常多,最基本的栅格模板是由 grid-template-rows 和 grid-template-columns 这两个属性确定的。
栅格布局能够使用很多功能强大的关键字或者工具函数来定义行、列以及单元格的布局规范,在自适应布局上表现优秀。比如:
fr: 份数单位,可以将容器宽度均分成几等份
grid-template-columns: 1fr 1fr;
minmax():定义一个长度范围
grid-template-columns: 1fr 1fr minmax(100px, 1fr);
repeat(): 简化重复的布局定义
grid-template-columns: repeat(2, 100px 20px 80px);
基于 Grid 的表格组件实现
同样地,我们基于 Grid 布局实现一个基础的表格功能:
- 基于 grid 布局,无需显示声明行和列
- 结合 columns 的配置来定义
grid-template-columns以控制列的宽度 - 可以使用
flexGrow配置来定义各列宽度的自适应规范
可以看到 Grid 是如何优化自适应布局的:
关键代码是基于 grid-template-columns 配置宽度,并优化自适应:
const styles = useMemo(() => {
let cols: string[] = []
columns.forEach((col) => {
if (col.width && !col.flexGrow) {
cols.push(`${col.width}px`)
} else {
let flexGrow = col.flexGrow ?? 1
let minWidth = col.minWidth ?? COLUMN_MIN_WIDTH
cols.push(`minmax(${minWidth}px, ${flexGrow}fr)`)
}
})
return { columns: cols.join(' ') }
}, [columns])
其他
除了表格布局和栅格布局,Rsuite Table 采用绝对定位来完全控制表格的布局,这种方案需要计算每个单元格的位置,具体的实现比较复杂,但这种方案有利于虚拟列表的接入,在性能上表现优秀。
列宽调整
功能描述
表格的列宽调整,其实就是当用户的鼠标移动到两列之间时,会出现一个可拖拽的手柄,用户拖拽这个手柄就可以自行调整指定列的宽度。这是一个增强用户体验的表格功能。
实现列宽调整的时候需要注意的是,当表格内容宽度自适应的时候,列的宽度可能会互相影响。这个时候通常有以下两种解决方案:
- 调整列宽的时候,根据所有列之和重新计算表格宽度
- 引入一列空白的占位列,其宽度自适应,用于列伸缩时适应表格宽度
实现
我们可以结合 react-resizable 来实现拖拽列宽调整功能。基本的实现思路是:
- 为表头的每一列(
th)增加一个可拖拽的手柄 - 拖拽手柄,使用
Resizable库提供的接口,获取列伸缩后的宽度 - 重设列宽度,如果表格是固定的滚动宽度,调整列宽的同时也要重新计算表格宽度
我们来看下 Antd 官网的一个封装示例:
import { Table } from 'antd';
import { Resizable } from 'react-resizable';
const ResizableTitle = props => {
const { onResize, width, ...restProps } = props;
if (!width) {
return <th {...restProps} />;
}
return (
<Resizable
width={width}
height={0}
handle={
<span
className="react-resizable-handle"
onClick={e => {
e.stopPropagation();
}}
/>
}
onResize={onResize}
draggableOpts={{ enableUserSelectHack: false }}
>
<th {...restProps} />
</Resizable>
);
};
class Demo extends React.Component {
//
components = {
header: {
cell: ResizableTitle,
},
};
handleResize = index => (e, { size }) => {
this.setState(({ columns }) => {
const nextColumns = [...columns];
nextColumns[index] = {
...nextColumns[index],
width: size.width,
};
return { columns: nextColumns };
});
};
render() {
const columns = this.state.columns.map((col, index) => ({
...col,
onHeaderCell: column => ({
width: column.width,
onResize: this.handleResize(index),
}),
}));
return <Table bordered components={this.components} columns={columns} dataSource={this.data} />;
}
}
总结
示例 demo 放在 github