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

4,505 阅读11分钟

table1.gif

前言

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

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

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

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

组件 API 设计

目前,市面上常见的表格方案有 Antd TableFusion Tableali-react-tableMaterial-UI TableReact 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 TableRsuite TableColumn 组件实际上还是通过拦截和收集属性,然后再由内部定义渲染的。

组件布局方案

表格组件和 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 Tableali-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 是如何优化自适应布局的:

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

参考