React 表格组件设计——基础布局

我心飞翔 分类:javascript

table1.gif

前言

在前端中后台项目中,表格是最常见也是最基础的数据组件。表格主要以二维栅格的形式呈现,能够清晰明了地展示数据。

在基于表格的项目开发中,我们经常遇到的一个问题,就是如何平衡功能的完备性与可拓展性。功能完备的表格,比如常用的 Antd Table ,能够满足我们业务开发的大部分场景,但是当我们实现一些个性化的需求时,往往需要改动到底层的逻辑。

计划通过调研一些常见的 React 表格功能实现方案,帮助大家进行合理的技术选型,也为自己造轮子提供一些可供参考的方案。

本文先从表格组件的最基本的行列布局讲起。

组件 API 设计

目前,市面上常见的表格方案有 Antd Table、Fusion Table、ali-react-table、Material-UI Table 和 React Suite Table 等。一个功能完备的表格组件通常会提供以下的一些功能:

  • 数据展示,包括表格行合并/列合并
  • 固定列
  • 数据排序和筛选
  • 数据分页
  • 行选择
  • 列宽调整/拖拽排序
  • 虚拟渲染
  • 可编辑单元格/行

目前,主流的组件 API 设计大体可以分为两种(大部分设计会同时兼容这两种风格的使用):

方案 优点 缺点
数据源驱动 用户只需要提供 columnsdataSource ,其余渲染的细节完全交给组件内部;便于内部进行一些渲染和性能优化 组件的逻辑更加抽象,隐藏了内部的细节,限制了组件的布局
暴露子组件 简单直观,使用完全的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&nbsp;(g)</TableCell>
<TableCell align="right">Carbs&nbsp;(g)</TableCell>
<TableCell align="right">Protein&nbsp;(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 }

image.png

在 CSS 中,表格的渲染以及样式作用的效果是有优先级的。如上图所示:

  • 最底层是 table 元素,定义生成一个透明的块级框表格
  • 第二层是 column groups , 我们可以指定一系列 colgroup 元素用于定义表格中对应的每一列的表现行为,不渲染实际元素
  • 第三层是 columns,它的作用是定义列中每个单元格的表现,对应的元素是 col
  • 第四层是 row groups ,它定义由一行或多行构成的行组,对应的元素是 tbody
  • 第五层是 rows ,它定义由单元格组成的行的表现,对应的元素是 tr
  • 第六层是 cells ,它定义表格中每个单元格的表现,对应的元素是 thtd

一个完整的表格布局可以声明如下:

<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 ,则该值为整列的宽度
  • 对于未设置宽度的列,表格的剩余空间将会尽可能地平分到各列
  • 表格如果未设置宽度,那么最终的表格宽度为各列之和中最大的那个决定;否则,由设定的表格宽度决定,并影响列的宽度分配

一个宽度分配的例子如下:
image.png

自动宽度布局

在 table 上使用以下属性触发自动宽度布局:

table-layout: auto;

自动布局模型需要考虑每个单元格的内容,用户代理需要读完表格的全部内容才能开始排布,因此自动布局比固定布局要慢。

  • 计算一列中每个单元格的最小宽度和最大宽度
  • 计算各列的最小宽度和最大宽度
  • 表格如果未设置宽度或者宽度小于计算得到的列宽和,那么表格最终的宽度等于列宽度、边框和间距之和;否则,如果表格宽度大于列宽和,则多出的宽度将平分后追加到原本计算的列宽上

一个宽度分配的例子如下:
image.png

表格高度

表格高度在很多地方是由实际的用户代理决定的:

  • 显示指定表格的高度
    • 高度比各行高度之和小,无效
    • 高度比各行高度之和大,由用户代理决定留白或者增加行高
  • 未指定表格的高度,由各行高度计算叠加得到

基于 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) 。容器中的水平区域称为“行”,垂直区域称为“列”。
React 表格组件设计——基础布局

使用 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 是如何优化自适应布局的:

codepen 演示

关键代码是基于 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 采用绝对定位来完全控制表格的布局,这种方案需要计算每个单元格的位置,具体的实现比较复杂,但这种方案有利于虚拟列表的接入,在性能上表现优秀。

列宽调整

功能描述

image.png
表格的列宽调整,其实就是当用户的鼠标移动到两列之间时,会出现一个可拖拽的手柄,用户拖拽这个手柄就可以自行调整指定列的宽度。这是一个增强用户体验的表格功能。

实现列宽调整的时候需要注意的是,当表格内容宽度自适应的时候,列的宽度可能会互相影响。这个时候通常有以下两种解决方案:

  • 调整列宽的时候,根据所有列之和重新计算表格宽度
  • 引入一列空白的占位列,其宽度自适应,用于列伸缩时适应表格宽度

实现

我们可以结合 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

参考

  • Table in CSS2.2 spec
  • CSS Grid 网格布局教程

回复

我来回复
  • 暂无回复内容