手撸一个「能打」的 React Table 组件

业务里的表格从来不是「行 + 列」那么简单:要分页(前端分页 / 服务端分页)、多选、分组合并、固定列、列宽拖拽、斑马纹、空状态......现成的 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 拉新数据,再更新 paginationdataSource,最 后同样走 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.rowSpantd.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>
  );
}

九、使用示例:配置即文档

业务侧只要组好 optionsdataSource,表格就能跑起来。下面是一个「带复选框 + 分页 + 自定义列」的简化示例:

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,说不定打一打业务需求会更顺手呢!

相关推荐
远山枫谷2 小时前
一文理清页面/组件通信与 Store 全局状态管理
前端·微信小程序
进击的尘埃2 小时前
用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具
javascript
HelloReader2 小时前
Tauri 应用安全从开发到发布的威胁防御指南
前端
bluceli2 小时前
WebAssembly实战指南:将高性能计算带入浏览器
前端·webassembly
yuki_uix2 小时前
Object.entries:优雅处理 Object 的瑞士军刀
前端·javascript
Lee川2 小时前
JavaScript 面向对象编程全景指南:从原始字面量到原型链的终极进化
javascript·面试
奇迹_h6 小时前
打造你的HTML5打地鼠游戏:零基础入门实践
前端
SuperEugene6 小时前
Vue生态精选篇:Element Plus 的“企业后台常用组件”用法扫盲
前端·vue.js·面试
Neptune16 小时前
JavaScript回归基本功之---类型判断--typeof篇
前端·javascript·面试