实现一个Table泛型组件

Table 组件

可以先看代码, 代码查看

表格组件在我们开发工作中是最常用的整理数据的一种组件, 会在很多页面用到, 我们不可能每个页面都用 table tr td 这些属性写一个表格, 那我们改如何写一个通用的表格组件呢? 我们从一个最基本的表格慢慢完善一个功能强大的表格

我们思考一下如何让用户更好的使用, 我们可以做哪些简单的工作?

  1. 用户可以使用 基本表格
  2. 用户可以设置 边框/紧凑/条纹表格
  3. 用户可以设置序号列, 不需要自己在 columns 里面设置
  4. 用户可以自定义设置单元格需要展示的数据
  5. 用户可以设置选择框, 获取表格数据
  6. 用户可以根据列排序 (后端排序, 前端获取数据渲染)
  7. 排序的过程需要时间, 加一个 loading 效果
  8. 空数据效果
  9. 固定表头
  10. etc....

1. 基本表格

  • 最基本的表格需要表头,和每一行的数据, 那我们可不可以只让用户传递 表头 props数据 data, 我们就可以绘制出一个好看的表格, 我们可以参考优秀的社区组件别人是怎么实现的, 向优秀的人学习
    • 我们可以先定义下面的 columnsdata 的类型以及数据结构
type DataPropa = {
  age: number;
  name: string;
  gender: string;
};

type ColProps = {
  title: string;
  key: keyof DataPropa;
};

const columns: ColProps[] = [
  {
    title: "年龄",
    key: "age",
  },
  {
    title: "姓名",
    key: "name",
  },
  {
    title: "性别",
    key: "gender",
  },
];

const data: DataPropa[] = [
  {
    age: 15,
    name: "yym",
    gender: "男",
  },
];

<Table columns={columns} data={data} />
  • 我们在组件实现我们应该把这些数据渲染到 html 元素上呢? 先完成下面基础的结构
    • 可以循环 columnsth 元素上, 渲染出我们的表头
    • 循环 datatbody > tr 有几个数据, 就有几行, 在每个行表格里匹配 columns 里面 key 对应的值
import { ReactElement, ReactNode } from "react";
import classnames from "classnames";

import "./table.scss";

interface TableProps<T> {
  columns: {
    title: string;
    key: keyof T;
  }[];
  data: T[];
}

const Table: <T>(props: TableProps<T>) => ReactElement = (props) => {
  const { columns, data } = props;

  return (
    <div className="g-table-wrap">
      <table className={classnames("g-table")}>
        <thead className="g-table-head">
          <tr>
            {/* 循环表头 */}
            {columns.map((col) => {
              return <th key={col.key as string}>{col.title}</th>;
            })}
          </tr>
        </thead>

        <tbody className="g-table-body">
          {data.map((item) => {
            return (
              <tr>
                {columns.map((col) => {
                  return (
                    <td key={col.key as string}>
                      <span>{item[col.key] as unknown as string}</span>
                    </td>
                  );
                })}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
};

export default Table;
  • 我们给 上面 table 的 class 加上 scss, 美化成我们希望的样式
.g-table-wrap {
  background-color: #fff;
  border-radius: 4px;
  box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.06), 0 4px 4px 0 rgba(0, 0, 0, 0.12);

  .g-table {
    width: 100%;
    border-spacing: 0;
    border-collapse: collapse;

    &-head {
      line-height: 20px;
      tr {
        th {
          padding: 10px;
          font-size: 12px;
          font-weight: 400;
          color: #8e8e93;
        }
      }
    }

    &-body {
      line-height: 20px;
      tr {
        td {
          padding: 13px 10px;
          font-size: 14px;
          color: #575757;
        }
      }
    }

    tr {
      text-align: left;
      border-bottom: 1px solid #f2f2f5;
      &:hover {
        background: #f2faff;
      }
    }
  }
}
普通表格

2. 边框/紧凑/条纹表格

这些都是样式的变化,我们来给这些添加对应的 class 修改样式

  • 给每个表格行添加边框 bordered: boolean
  • 给一个紧凑的表格 compact: boolean
  • 给一个条纹相间的表格 striped: boolean
// 使用
<Table columns={columns} data={data} />
<Table columns={columns} data={data} bordered compact />
// 如何实现
import { ReactElement, ReactNode } from "react";
import classnames from "classnames";

import "./table.scss";

interface TableProps<T> {
  columns: {
    title: string;
    key: keyof T;
  }[];
  data: T[];
  bordered?: boolean;
  compact?: boolean;
  striped?: boolean;
}

const Table: <T>(props: TableProps<T>) => ReactElement = (props) => {
  const {
    columns,
    data,
    bordered = false,
    compact = false,
    striped = true,
  } = props;

  const tableClasses = {
    "g-table-bordered": bordered,
    "g-table-compact": compact,
    "g-table-striped": striped,
  };

  return (
    <div className="g-table-wrap">
      <table className={classnames("g-table", tableClasses)}>
        <thead className="g-table-head">
          <tr>
            {/* 循环表头 */}
            {columns.map((col) => {
              return <th key={col.key as string}>{col.title}</th>;
            })}
          </tr>
        </thead>

        <tbody className="g-table-body">
          {data.map((item) => {
            return (
              <tr>
                {columns.map((col) => {
                  return (
                    <td key={col.key as string}>
                      {item[col.key] as unknown as string}
                    </td>
                  );
                })}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
};

export default Table;
// 样式
.g-table-wrap {
  background-color: #fff;
  border-radius: 4px;
  box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.06), 0 4px 4px 0 rgba(0, 0, 0, 0.12);
  margin-top: 20px;

  .g-table {
    width: 100%;
    border-spacing: 0;
    border-collapse: collapse;

    &-head {
      line-height: 20px;
      tr {
        th {
          padding: 10px;
          font-size: 12px;
          font-weight: 400;
          color: #8e8e93;
        }
      }
    }

    &-body {
      line-height: 20px;
      tr {
        td {
          padding: 13px 10px;
          font-size: 14px;
          color: #575757;
        }
      }
    }

    tr {
      text-align: left;
      border-bottom: 1px solid #f2f2f5;
      &:hover {
        background: #f2faff;
      }
    }

    &.g-table-bordered {
      border: 1px solid #f2f2f5;
      border-radius: 6px;
      th,
      td {
        border: 1px solid #f2f2f5;
      }
    }
    &.g-table-compact {
      td,
      th {
        padding: 5px;
      }
    }
    &.g-table-striped {
      .g-table-body {
        tr {
          &:nth-child(even) {
            background: #f7f7fa;
          }
          &:hover {
            background: #f2faff;
          }
        }
      }
    }
  }
}
边框表格

3. 序号列

让用户通过配置自动添加序号列, 我们可以设置一个开关, 来自己添加这一列 numberVisible: boolean

// 如何使用 props numberVisible
<Table columns={columns} data={data} bordered compact numberVisible />
// 实现
import { ReactElement, ReactNode } from "react";
import classnames from "classnames";

import "./table.scss";

interface TableProps<T> {
  columns: {
    title: string;
    key: keyof T;
  }[];
  data: T[];
  bordered?: boolean;
  compact?: boolean;
  striped?: boolean;
  numberVisible?: boolean;
}

const Table: <T>(props: TableProps<T>) => ReactElement = (props) => {
  const {
    columns,
    data,
    bordered = false,
    compact = false,
    striped = true,
    numberVisible = false,
  } = props;

  const tableClasses = {
    "g-table-bordered": bordered,
    "g-table-compact": compact,
    "g-table-striped": striped,
  };

  return (
    <div className="g-table-wrap">
      <table className={classnames("g-table", tableClasses)}>
        <thead className="g-table-head">
          <tr>
            {/* 是否显示序号 */}
            {numberVisible && <th>序号</th>}
            {columns.map((col) => {
              return <th key={col.key as string}>{col.title}</th>;
            })}
          </tr>
        </thead>

        <tbody className="g-table-body">
          {data.map((item, index) => {
            return (
              <tr key={index}>
                {/* 显示序号的字段 */}
                {numberVisible && <td>{index + 1}</td>}
                {columns.map((col) => {
                  return (
                    <td key={col.key as string}>
                      {item[col.key] as unknown as string}
                    </td>
                  );
                })}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
};

export default Table;
image.png

4. 自定义设置单元格

用户如果不设置希望在里面展示的数据, 我们就自动匹配对应 columns key 的值, 如果用户希望展示一个 按钮 输入框等, 那我们改怎么弄呢?

  • columns 里面设置一个回调函数 render , 让用户自定义该列怎么渲染数据, 我们返回(该单元格内容, 整行数据, 下标)
  • render 就使用 render 返回渲染, 没有就使用默认的值
// 设置类型
type columns<T> = {
  title: string;
  key: keyof T;
  render?: (text: string, record: T, index: number) => void;
};

interface TableProps<T> {
  columns: columns<T>[];
  data: T[];
  bordered?: boolean;
  compact?: boolean;
  striped?: boolean;
  numberVisible?: boolean;
}

看一下代码如何实现, 需要改变 columns 数据, 多了一个参数 render, 正好可以使用到我们之前写好的 Button 组件

const columns: ColProps[] = [
  {
    title: "年龄",
    key: "age",
  },
  {
    title: "姓名",
    key: "name",
  },
  {
    title: "性别",
    key: "gender",
  },
  {
    title: "地址",
    key: "address",
  },
  {
    title: "操作",
    key: "action",
    render: (text: string, record: DataProps, index: number) => {
      console.log(text, record, index, "data...");
      return <Button type="danger">删除</Button>;
    },
  },
];

用户 columns 传递了 render, 代码里我们接收一下

import { ReactElement, ReactNode } from "react";
import classnames from "classnames";

import "./table.scss";

type columns<T> = {
  title: string;
  key: keyof T;
  render?: (text: string, record: T, index: number) => ReactNode;
};

interface TableProps<T> {
  columns: columns<T>[];
  data: T[];
  bordered?: boolean;
  compact?: boolean;
  striped?: boolean;
  numberVisible?: boolean;
}

const Table: <T>(props: TableProps<T>) => ReactElement = (props) => {
  const {
    columns,
    data,
    bordered = false,
    compact = false,
    striped = true,
    numberVisible = false,
  } = props;

  const tableClasses = {
    "g-table-bordered": bordered,
    "g-table-compact": compact,
    "g-table-striped": striped,
  };

  return (
    <div className="g-table-wrap">
      <table className={classnames("g-table", tableClasses)}>
        <thead className="g-table-head">
          <tr>
            {/* 是否显示序号 */}
            {numberVisible && <th>序号</th>}
            {columns.map((col) => {
              return <th key={col.key as string}>{col.title}</th>;
            })}
          </tr>
        </thead>

        <tbody className="g-table-body">
          {data.map((item, index) => {
            return (
              <tr key={index}>
                {/* 显示序号的字段 */}
                {numberVisible && <td>{index + 1}</td>}
                {columns.map((col) => {
                  return (
                    <td key={col.key as string}>
                      {/* 渲染的数据 */}
                      {col.render
                        ? col.render(
                            item[col.key] as unknown as string,
                            item,
                            index
                          )
                        : (item[col.key] as unknown as string)}
                    </td>
                  );
                })}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
};

export default Table;
image.png

5. 设置选择框 全选/全不选

一般我们在使用表格组件时都会有选择数据的需求, 所以我们把这个功能集成在 Table 组件里

  • 用户可以设置开关打开选择框, 可以选择一个/多个
  • 表头行可以控制所有行 全选/全不选
  • 选择时触发 change 事件把数据给吐出去
  • Checkbox 使用我们之前开发的组件

前面我们设置了序号列, 我们通过控制 numberVisible 来控制显隐, 这次我们设置一个 checkable 来控制选择框列是否显隐

// 使用
<Table
  columns={columns}
  data={data}
  bordered
  compact
  numberVisible
  checkable // 控制弹框是否显示
/>
// 实现
import { ReactElement, ReactNode } from "react";
import { Checkbox } from "../index";

import classnames from "classnames";

import "./table.scss";

type columns<T> = {
  title: string;
  key: keyof T;
  render?: (text: string, record: T, index: number) => ReactNode;
};

interface TableProps<T> {
  columns: columns<T>[];
  data: T[];
  bordered?: boolean;
  compact?: boolean;
  striped?: boolean;
  numberVisible?: boolean;
  // 选择框
  checkable?: boolean;
}

const Table: <T>(props: TableProps<T>) => ReactElement = (props) => {
  const {
    columns,
    data,
    bordered = false,
    compact = false,
    striped = true,
    numberVisible = false,
    checkable = false,
  } = props;

  const tableClasses = {
    "g-table-bordered": bordered,
    "g-table-compact": compact,
    "g-table-striped": striped,
  };

  return (
    <div className="g-table-wrap">
      <table className={classnames("g-table", tableClasses)}>
        <thead className="g-table-head">
          <tr>
            {checkable && (
              <th>
                <Checkbox />
              </th>
            )}
            {/* 是否显示序号 */}
            {numberVisible && <th>序号</th>}
            {columns.map((col) => {
              return <th key={col.key as string}>{col.title}</th>;
            })}
          </tr>
        </thead>

        <tbody className="g-table-body">
          {data.map((item, index) => {
            return (
              <tr key={index}>
                {checkable && (
                  <td>
                    <Checkbox />
                  </td>
                )}
                {/* 显示序号的字段 */}
                {numberVisible && <td>{index + 1}</td>}
                {columns.map((col) => {
                  return (
                    <td key={col.key as string}>
                      {/* 渲染的数据 */}
                      {col.render
                        ? col.render(
                            item[col.key] as unknown as string,
                            item,
                            index
                          )
                        : (item[col.key] as unknown as string)}
                    </td>
                  );
                })}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
};

export default Table;
选择框

上面的图片我们可以看到选择框已经出现在表格列中了, 但是现在有两个问题没有解决?

  1. 我们无法从表头行控制表格数据的 全选/全不选
  2. 我们无法得知我们选择了哪些数据

解决1的逻辑就是: 我们可以在组件内部当表格头的选择框被选中时, 给每一行的选择框 checked = true, 反之 checked = false, 而当我们触发表格行的选择框时, 判断 checked = true 的数量 等于 data.length 则表头为选中状态, 否则就是不完全选择, 当为 0 时, 表头选择框为不选, 我们在组件内部维护一个 [selected] = useState([]), 选择框 change 事件触发, 根据 selected 的值来判断 checked

  • 给每个选择框添加 change 事件
// 实现方案
import { ChangeEvent, ReactElement, ReactNode, useMemo, useState } from "react";
import { Checkbox } from "../index";

import classnames from "classnames";

import "./table.scss";

type columns<T> = {
  title: string;
  key: keyof T;
  render?: (text: string, record: T, index: number) => ReactNode;
};

interface TableProps<T> {
  columns: columns<T>[];
  data: T[];
  bordered?: boolean;
  compact?: boolean;
  striped?: boolean;
  numberVisible?: boolean;
  // 选择框
  checkable?: boolean;
}

const Table: <T>(props: TableProps<T>) => ReactElement = (props) => {
  const {
    columns,
    data,
    bordered = false,
    compact = false,
    striped = true,
    numberVisible = false,
    checkable = false,
  } = props;

  const [selected, setSelected] = useState<any[]>([]);

  const tableClasses = {
    "g-table-bordered": bordered,
    "g-table-compact": compact,
    "g-table-striped": striped,
  };

  const handleSelectItem = (e: ChangeEvent<HTMLInputElement>, item: any) => {
    const { checked } = e.target;
    // 改变 checked 的值
    checked
      ? setSelected([...selected, item])
      : setSelected(selected.filter((i) => i.key !== item.key));
  };

  const handleSelectAllItem = (e: ChangeEvent<HTMLInputElement>) => {
    const { checked } = e.target;
    setSelected(checked ? data : []);
  };

  // 判断表格行是否被选中
  const areItemSelected = (item: any) =>
    useMemo(
      () => selected.filter((i) => i.key === item.key).length > 0,
      [selected]
    );

  // 表格头的状态
  const areAllItemsSelected: boolean = useMemo(
    () => data.length === selected.length,
    [selected]
  );

  // 不完全选择
  const areNotAllItemsSelected: boolean = useMemo(
    () => data.length !== selected.length && selected.length !== 0,
    [selected]
  );

  return (
    <div className="g-table-wrap">
      <table className={classnames("g-table", tableClasses)}>
        <thead className="g-table-head">
          <tr>
            {checkable && (
              <th>
                <Checkbox
                  checked={areAllItemsSelected}
                  indeterminate={areNotAllItemsSelected}
                  onChange={(e) => handleSelectAllItem(e)}
                />
              </th>
            )}
            {/* 是否显示序号 */}
            {numberVisible && <th>序号</th>}
            {columns.map((col) => {
              return <th key={col.key as string}>{col.title}</th>;
            })}
          </tr>
        </thead>

        <tbody className="g-table-body">
          {data.map((item, index) => {
            console.log(areItemSelected(item), "丁东坑");

            return (
              <tr key={index}>
                {checkable && (
                  <td>
                    <Checkbox
                      checked={areItemSelected(item)}
                      onChange={(e) => handleSelectItem(e, item)}
                    />
                  </td>
                )}
                {/* 显示序号的字段 */}
                {numberVisible && <td>{index + 1}</td>}
                {columns.map((col) => {
                  return (
                    <td key={col.key as string}>
                      {/* 渲染的数据 */}
                      {col.render
                        ? col.render(
                            item[col.key] as unknown as string,
                            item,
                            index
                          )
                        : (item[col.key] as unknown as string)}
                    </td>
                  );
                })}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
};

export default Table;
状态变化

解决2的逻辑就是: 我们可以通过 传递一个回调函数, 把选择的数据给暴露出去, changeSeletedItems props, 在 1 中, 我们创建了 state selected , 我们每次 选择框改变都会 setSelected 修改值的变化, 我们可以把值传递给外部

// 使用
const handleChangeSelected = (val: DataProps[]) => {
  console.log(val, "选中的值");
};

<Table
  columns={columns}
  data={data}
  bordered
  compact
  numberVisible
  checkable
  // 新增 change 事件
  changeSeletedItems={handleChangeSelected}
/>
// 实现方案, 每次 selected 变化时, 把 selected 暴露出去

useEffect(() => {
  changeSeletedItems && changeSeletedItems(selected);
}, [selected]);
选中的值

6. 数据排序 sorter

数据排序我们可以方法给用户, 让用户来改变 data 的顺序, 重新渲染表格来达到排序的目的, 我们做成下面图片的样式, 我们可以在 column 上做文章, 开始是没有排序, 点击后会排序 data, 重新渲染

  1. column 存在排序属性, 我们就显示改排序图标, 图标变化从 无状态 => 升序 => 降序
    • 我们可以在内部维护一个 useRef order => "asc" | "desc" | "unsc", 每次点击规律变化, 把当前状态告诉用户
  2. 用户使用时在 columns 添加 sorter 函数, 里面调用后端接口, 改变 data
排序
// 使用
import { Button, Checkbox, CheckboxGroup, Table } from "./lib/index";
import "./App.scss";
import { ChangeEvent, ReactNode, useState } from "react";

type DataProps = {
  age: number;
  name: string;
  gender: string;
  address: string;
  action?: any;
  key?: string;
};
type orderType = "asc" | "desc" | "unsc";

type ColProps<T> = {
  title: string;
  key: keyof DataProps;
  render?: (text: string, record: T, index: number) => ReactNode;
  sorter?: (val: orderType) => void;
};

const App = () => {
  const columns: ColProps<DataProps>[] = [
    {
      title: "年龄",
      key: "age",
      sorter: (val) => {
        // TODO: 开始排序 
        console.log(val, "我是怎么排序规则?");
        if (val === "asc") {
          data.sort((a, b) => Number(a.age) - Number(b.age));
        } else if (val === "desc") {
          data.sort((a, b) => Number(b.age) - Number(a.age));
        } else {
          // ajax
        }
      },
    },
    {
      title: "姓名",
      key: "name",
    },
    {
      title: "性别",
      key: "gender",
    },
    {
      title: "地址",
      key: "address",
    },
    {
      title: "操作",
      key: "action",
      render: (text: string, record: DataProps, index: number) => {
        return (
          <>
            <Button type="danger" style={{ marginRight: "8px" }}>
              删除
            </Button>
            <Button type="primary">编辑</Button>
          </>
        );
      },
    },
  ];

  const data: DataProps[] = [
    {
      key: "1",
      age: 15,
      name: "yym",
      gender: "男",
      address: "深圳市",
    },
    {
      key: "2",
      age: 18,
      name: "张三",
      gender: "女",
      address: "安徽省",
    },
    {
      key: "3",
      age: 35,
      name: "李四",
      gender: "女",
      address: "张家界",
    },
    {
      key: "4",
      age: 6,
      name: "小黑",
      gender: "男",
      address: "蚌埠",
    },
  ];

  const handleChangeSelected = (val: DataProps[]) => {
    console.log(val, "选中的值");
  };

  return (
    <div className="App">
      <Table
        columns={columns}
        data={data}
        bordered
        compact
        numberVisible
        checkable
        changeSeletedItems={handleChangeSelected}
      />
    </div>
  );
};

export default App;
// 代码实现排序
import {
  ChangeEvent,
  ReactElement,
  ReactNode,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { Checkbox } from "../index";

import classnames from "classnames";

import "./table.scss";

type orderType = "asc" | "desc" | "unsc";

type columns<T> = {
  title: string;
  key: keyof T;
  render?: (text: string, record: T, index: number) => ReactNode;
  sorter?: (val: orderType) => void;
};

interface TableProps<T> {
  columns: columns<T>[];
  data: T[];
  bordered?: boolean;
  compact?: boolean;
  striped?: boolean;
  numberVisible?: boolean;
  // 选择框
  checkable?: boolean;
  changeSeletedItems?: (selected: T[]) => void;
}

const Table: <T>(props: TableProps<T>) => ReactElement = (props) => {
  const {
    columns,
    data,
    bordered = false,
    compact = false,
    striped = true,
    numberVisible = false,

    checkable = false,
    changeSeletedItems,
  } = props;

  const [update, setUpdate] = useState(0); // 更新页面
  const [selected, setSelected] = useState<any[]>([]);
  const order = useRef<"asc" | "desc" | "unsc">("unsc");

  const tableClasses = {
    "g-table-bordered": bordered,
    "g-table-compact": compact,
    "g-table-striped": striped,
  };

  useEffect(() => {
    changeSeletedItems && changeSeletedItems(selected);
  }, [selected]);

  const handleSelectItem = (e: ChangeEvent<HTMLInputElement>, item: any) => {
    const { checked } = e.target;
    // 改变 checked 的值
    checked
      ? setSelected([...selected, item])
      : setSelected(selected.filter((i) => i.key !== item.key));
  };

  const handleSelectAllItem = (e: ChangeEvent<HTMLInputElement>) => {
    const { checked } = e.target;
    setSelected(checked ? data : []);
  };

  // 判断表格行是否被选中
  const areItemSelected = (item: any) =>
    useMemo(
      () => selected.filter((i) => i.key === item.key).length > 0,
      [selected]
    );

  // 表格头的状态
  const areAllItemsSelected: boolean = useMemo(
    () => data.length === selected.length,
    [selected]
  );

  // 不完全选择
  const areNotAllItemsSelected: boolean = useMemo(
    () => data.length !== selected.length && selected.length !== 0,
    [selected]
  );

  const handleOrderBy = (col: columns<any>) => {
    if (order.current === "unsc") {
      order.current = "asc";
    } else if (order.current === "asc") {
      order.current = "desc";
    } else if (order.current === "desc") {
      order.current = "unsc";
    }

    setUpdate(Math.random());
    col.sorter && col.sorter(order.current);
  };

  return (
    <div className="g-table-wrap">
      <table className={classnames("g-table", tableClasses)}>
        <thead className="g-table-head">
          <tr>
            {checkable && (
              <th>
                <Checkbox
                  checked={areAllItemsSelected}
                  indeterminate={areNotAllItemsSelected}
                  onChange={(e) => handleSelectAllItem(e)}
                />
              </th>
            )}
            {/* 是否显示序号 */}
            {numberVisible && <th>序号</th>}
            {columns.map((col) => {
              return (
                <th key={col.key as string}>
                  {/* 排序按钮 */}
                  {col.sorter ? (
                    <span
                      className="g-table-sort-wrap"
                      onClick={() => handleOrderBy(col)}
                    >
                      {col.title}
                      <span className="g-table-sort">
                        <i
                          className={classnames("g-table-up", {
                            "g-table-active": order.current === "asc",
                          })}
                        ></i>
                        <i
                          className={classnames("g-table-down", {
                            "g-table-active": order.current === "desc",
                          })}
                        ></i>
                      </span>
                    </span>
                  ) : (
                    <>{col.title}</>
                  )}
                </th>
              );
            })}
          </tr>
        </thead>

        <tbody className="g-table-body">
          {data.map((item, index) => {
            return (
              <tr key={index}>
                {checkable && (
                  <td>
                    <Checkbox
                      checked={areItemSelected(item)}
                      onChange={(e) => handleSelectItem(e, item)}
                    />
                  </td>
                )}
                {/* 显示序号的字段 */}
                {numberVisible && <td>{index + 1}</td>}
                {columns.map((col) => {
                  return (
                    <td key={col.key as string}>
                      {/* 渲染的数据 */}
                      {col.render
                        ? col.render(
                            item[col.key] as unknown as string,
                            item,
                            index
                          )
                        : (item[col.key] as unknown as string)}
                    </td>
                  );
                })}
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
};

export default Table;
原始

asc

desc

7. 添加loading

添加 loading 效果, 就是我们设置一个 loading 动画, 用户传递参数 true/false, 展示/隐藏

// 使用
<Table columns={columns} data={data} loading />
// 实现
// html 结构 wrap 下和 table 平级
{loading && <div className="g-table-loading">加载中...</div>}
.g-table-loading {
  display: flex;
  justify-content: center;
  align-items: center;
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  opacity: 0.7;
  background: #fff;
}

8. 空数据

当用户没有数据时, 我们不能只展示 表头, 而是展示一个默认的样式, 可以通过 data.length > 0 ? 空数据样式 : tbody, 也可以让用户传进来一个 empty 的内容, 我们接受放入空数组页面里

// 实现方案
{data.length === 0 && (
  <tr>
    <td
      className="g-table-empty"
      colSpan={columns.length + colSpan()}
    >
      <span className="default">暂无数据</span>
    </td>
  </tr>
)}
.g-table-empty {
  text-align: center;
  .default {
    display: flex;
    justify-content: center;
    padding: 20px 0;
  }
}

9. 固定表头

很多情况下当我们希望给 table 一个固定高度, 超出滚动时, 我们希望用户能够一直看到表头, 那这个我们应该怎么做呢?

  1. 使用 position: sticky 粘性布局, 优点是比较简单, 缺点是兼容性不是很好, can i use position sticky
    • 用户设置 height={固定高度} 超过出现滚动条 overflow: auto, 给 thead设置 position: sticky
// 使用
<Table columns={columns} data={data} height={400} />
// 实现

<div
  className="g-table-wrap"
  style={{ height: height, overflow: height ? "auto" : "unset" }}
>
  <thead
    className={classnames("g-table-head", {
      "g-table-sticky": !!height,
    })}
  >
</div>

&.g-table-sticky {
    position: -webkit-sticky;
    position: sticky;
    top: 0px; /* 列首永远固定在头部  */
    background: #fff;
    z-index: 10;
}

// 固定第一列
&:first-child {
  position: -webkit-sticky;
  position: sticky;
  left: 0;
  background-color: #fff;
}
// 固定第二列
&:nth-child(2) {
  position: -webkit-sticky;
  position: sticky;
  left: 93px; // 让用户指定固定列的宽度, 获取设置
  background: #fff;
}
固定两列
  • 一些主流 UI 组件库因为有很多用户使用, 做到兼容性比较好, 所以通过 操作 DOM 来把 thead clone 一份, 通过定位放到 table 的上面 来完成表头固定, 但比较复杂, 会发现宽度出现对不齐的情况. 优点是兼容性好, 实现起来复杂
    • 设置了 height 第一次渲染, 把 tHeadtBody 分开, 让 tBody overflow: auto
    • 会有 theadtBody 对齐的问题, 简单的弄, 让用户给每个 columns 设置宽度
    • 下面的实现 当 启用严格模式 时, 会触发副作用, 实现原理是这样的, 暂时先不修复
// 实现原理: 操作DOM

import {
  ChangeEvent,
  ReactNode,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { Checkbox } from "../index";

import classnames from "classnames";

import "./table.scss";

type orderType = "asc" | "desc" | "unsc";

type columns<T> = {
  title: string;
  key: keyof T;
  width?: number;
  render?: (text: string, record: T, index: number) => ReactNode;
  sorter?: (val: orderType) => void;
};

interface TableProps<T> {
  columns: columns<T>[];
  data: T[];
  bordered?: boolean;
  compact?: boolean;
  striped?: boolean;
  numberVisible?: boolean;
  // 选择框
  checkable?: boolean;
  changeSeletedItems?: (selected: T[]) => void;

  loading?: boolean;
  height?: number;
}
// function Table<T>(props: TableProps<T>)
const Table = <T,>(props: TableProps<T>) => {
  const {
    columns,
    data,
    bordered = false,
    compact = false,
    striped = true,
    numberVisible = false,

    checkable = false,
    changeSeletedItems,

    loading = false,
    height,
  } = props;

  const [_, setUpdate] = useState(0); // 更新页面
  const [selected, setSelected] = useState<any[]>([]);
  const order = useRef<"asc" | "desc" | "unsc">("unsc");
  const wrapRef = useRef<any>(null);
  const tableRef = useRef<any>(null);

  const tableClasses = {
    "g-table-bordered": bordered,
    "g-table-compact": compact,
    "g-table-striped": striped,
  };

  useEffect(() => {
    changeSeletedItems && changeSeletedItems(selected);
  }, [selected]);

  // 固定表头计算
  useEffect(() => {
    let table1: any;
    let table2: any;
    if (height) {
      table1 = tableRef.current.cloneNode(false);
      table2 = tableRef.current.cloneNode(false);

      const tHead = tableRef.current.children[0];
      const tBody = tableRef.current.children[1];
      const divBody = document.createElement("div");

      table1.appendChild(tHead);
      divBody.appendChild(table2).appendChild(tBody);
      divBody.style.height = height + "px";
      divBody.style.overflowY = "auto";

      wrapRef.current.appendChild(table1);
      wrapRef.current.appendChild(divBody);
    }

    return () => {
      height && table1.remove();
      height && table2.remove();
    };
  }, []);

  const handleSelectItem = (e: ChangeEvent<HTMLInputElement>, item: any) => {
    const { checked } = e.target;
    // 改变 checked 的值
    checked
      ? setSelected([...selected, item])
      : setSelected(selected.filter((i) => i.key !== item.key));
  };

  const handleSelectAllItem = (e: ChangeEvent<HTMLInputElement>) => {
    const { checked } = e.target;
    setSelected(checked ? data : []);
  };

  // 判断表格行是否被选中
  const areItemSelected = (item: T) =>
    useMemo(
      () => selected.filter((i) => i.key === item.key).length > 0,
      [selected]
    );

  // 表格头的状态
  const areAllItemsSelected: boolean = useMemo(
    () => data.length === selected.length,
    [selected]
  );

  // 不完全选择
  const areNotAllItemsSelected: boolean = useMemo(
    () => data.length !== selected.length && selected.length !== 0,
    [selected]
  );

  const handleOrderBy = (col: columns<T>) => {
    if (order.current === "unsc") {
      order.current = "asc";
    } else if (order.current === "asc") {
      order.current = "desc";
    } else if (order.current === "desc") {
      order.current = "unsc";
    }

    setUpdate(Math.random());

    col.sorter && col.sorter(order.current);
  };

  // 计算 colspan 的 值
  const colSpan = (): number => {
    let length = 0;
    if (numberVisible) {
      length += 1;
    }
    if (checkable) {
      length += 1;
    }

    return length;
  };

  return (
    <div ref={wrapRef} className="g-table-wrap">
      <table ref={tableRef} className={classnames("g-table", tableClasses)}>
        <thead className={classnames("g-table-head")}>
          <tr>
            {checkable && (
              <th style={{ width: "50px" }}>
                <Checkbox
                  checked={areAllItemsSelected}
                  indeterminate={areNotAllItemsSelected}
                  onChange={(e) => handleSelectAllItem(e)}
                />
              </th>
            )}
            {/* 是否显示序号 */}
            {numberVisible && <th style={{ width: "50px" }}>序号</th>}

            {columns.map((col) => {
              return (
                <th key={col.key as string} style={{ width: `${col.width}px` }}>
                  {/* 排序按钮 */}
                  {col.sorter ? (
                    <span
                      className="g-table-sort-wrap"
                      onClick={() => handleOrderBy(col)}
                    >
                      {col.title}
                      <span className="g-table-sort">
                        <i
                          className={classnames("g-table-up", {
                            "g-table-active": order.current === "asc",
                          })}
                        ></i>
                        <i
                          className={classnames("g-table-down", {
                            "g-table-active": order.current === "desc",
                          })}
                        ></i>
                      </span>
                    </span>
                  ) : (
                    <>{col.title}</>
                  )}
                </th>
              );
            })}
          </tr>
        </thead>

        <tbody className="g-table-body">
          {data.map((item, index) => {
            return (
              <tr key={index}>
                {checkable && (
                  <td style={{ width: "50px" }}>
                    <Checkbox
                      checked={areItemSelected(item)}
                      onChange={(e) => handleSelectItem(e, item)}
                    />
                  </td>
                )}
                {/* 显示序号的字段 */}
                {numberVisible && (
                  <td style={{ width: "50px" }}>{index + 1}</td>
                )}
                {columns.map((col) => {
                  return (
                    <td
                      key={col.key as string}
                      style={{ width: `${col.width}px` }}
                    >
                      {/* 渲染的数据 */}
                      {col.render
                        ? col.render(
                            item[col.key] as unknown as string,
                            item,
                            index
                          )
                        : (item[col.key] as unknown as string)}
                    </td>
                  );
                })}
              </tr>
            );
          })}
          {data.length === 0 && (
            <tr>
              <td
                className="g-table-empty"
                colSpan={columns.length + colSpan()}
              >
                <span className="default">暂无数据</span>
              </td>
            </tr>
          )}
        </tbody>
      </table>
      {loading && <div className="g-table-loading">加载中...</div>}
    </div>
  );
};

export default Table;
固定表头

后续会增加 固定一列, 展开行等功能

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,496评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,407评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,632评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,180评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,198评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,165评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,052评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,910评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,324评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,542评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,711评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,424评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,017评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,668评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,823评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,722评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,611评论 2 353

推荐阅读更多精彩内容