浅谈表格组件的实现:固定表头和固定列

8,110
原文链接: zhuanlan.zhihu.com

在前端开发中,表格组件相对来讲是比较复杂的组件之一,主要原因在于大家对表格组件的需求不同,笔者没看到过一个表格组件可以覆盖所有人的需求。

从业务场景来讲,可以把表格组件分成三种类型:

  • Bootstrap 类:CSS 就可实现的表格,主要用在信息展现类的页面上。
  • 中后台类:着重在展现,编辑通过按钮来操作,主要用在中后台系统中。与 Bootstrap 类表格比较大的一个分界点就是有固定表格头、固定列等功能。
  • Excel 类:着重在编辑上,主要在线数据编辑的场景,这类场景相对来讲比较小众。这类表格组件对性能要求较高,和前面两种相比,有一些独有的特性:比如双击行内编辑、固定行等功能。

对于适用于 Bootstrap 类表格的需求,建议直接使用 Bootstrap 提供的方式来使用。对于中后台类的表格组件,比较常见的有这么几个需求:

  1. 固定表格头
  2. 固定列
  3. 展开行
  4. 表格头排序、表格头过滤、多级表格头
  5. 单选、多选

这些特性里面最有趣的应该属于固定表格头、固定列的实现,本篇文章主要用来讲述这两种特性的不同实现方式,以及应该选取哪种实现方式。

本篇文章的内容和示例没有使用任何框架,只要你可以熟练的使用 HTML、CSS、jQuery,理论上都能读懂。

从一个简单表格开始

为了降低各位的阅读门槛,两个特性的实现都准备了在线示例。每个在线示例都是通过对一个最简单的 Bootstrap 3.0 的表格进行修改来完成的,这个示例可以在这里查看

固定表格头的实现

如果表格中的数据是有滚动条的,那么用户在滚动的时候会希望表格头是固定在上方,否则没办法看出单元格数据对应的含义。

浏览器的 <table> 不能显示滚动条,即使可以显示滚动条,也没办法实现 <thead> 不滚动, <tbody> 可以滚动。

最简单的思路是使用两个 <table>,一个用来显示表格头,一个用来显示表格内容。使用另外一个元素来包裹表格内容的 <table>,设置一个 overflow: auto 来展现滚动条。实现方式如下图所示:

按照这个思路快速的实现了一个版本,可以点击这里查看

这个版本有一个非常明显的问题,就是表格头和表格内容的列宽不一致,会出现错位的情况。错位的原因如下:表格内容需要显示一个滚动条、表格头不需要显示一个滚动条,这样两者的宽度就产生了一个宽度差。一般的做法是在表格头上增加一个额外的元素去弥补这个宽度差,这个元素一般叫做 Gutter。

修改后的实现方式如下图所示:

修正后的版本可以直接在线查看,效果如下图所示:

除了 Gutter 以外,还有一些其他的情况需要说明,所以加了四个箭头,下面分别做一些说明。

Gutter 的实现

Gutter 的定义参考上文中的说明,Gutter 在箭头 1 位置,实现方法请参考示例 HTML 中的 th.gutter 元素,以及 CSS 中的 th.gutter 的样式定义。在实际的场景中,这个 Gutter 的宽度会因为浏览器和操作系统产生变化,需要动态计算这个宽度。实现代码如下:

<th class="gutter"></th>
/** Header 右侧的空隙 */
.custom-table th.gutter {
  width: 15px;
  padding: 0;
}

右侧边框的实现

右侧边框参考箭头 2 的位置,因为 Gutter 的存在,表格内容的实际宽度就少了这个滚动条的宽度,表格右侧的边框也就无法正常显示。在这个例子里面,使用了伪元素 before 为最外层的元素添加了一个右侧边框。实现代码如下:

/** 右侧边框 */
.custom-table::before {
  content: "";
  display: block;
  position: absolute;
  top: 0;
  bottom: 0;
  right: 0;
  border-right: 1px solid #ddd;
}

底部边框的实现

底部边框在箭头 3 的位置,原有的底部边框显示在 <table> 上,<table>的父元素增加了滚动条之后,会导致因为 <table> 的底部的边框并不能显示出来。所以需要在最外层的元素上加上一个底部边框,用来弥补这个边框的缺失。在这个例子里面,使用了伪元素 before 为最外层的元素添加了一个右侧边框。实现代码如下:

/** 底部边框 */
.custom-table::after {
  content: "";
  display: block;
  height: 0px;
  position: relative;
  top: -1px;
  border-bottom: 1px solid #ddd;
}

列宽度的一致

因为表格头和表格内容现在变成了两个独立的 <table> 元素,两者的列宽度同步变成了一个问题。在这个例子里,恰好可以通过设置 table-layout 属性为 fixed 来解决,实现代码如下:

.custom-table .table {
  table-layout: fixed;
  margin-bottom: 0;
}

关于 table-layout 的说明,可以参考 这篇文章 。简单来讲,table-layout 的两个值的工作原理如下:

  • auto: 单元格的宽度会根据内容来调整。
  • fixed: 由 <table> 或者 <col> 的宽度属性来决定,如果没有,则由第一行的单元格的宽度来决定。

仅仅通过设置 table-layout 为 fixed 是无法解决问题的,修改下的样式就可以继续产生错位的情况,可以参考这个例子。在这个例子里面,只为表格内容中的第一行的第一个 td 加了一个 width 属性,表格头和表格内容就产生了错位。

根据 table-layout 的工作方式,解决这个问题的原理比较简单:为 <table> 增加 <col> 元素,在 <col> 元素上设置 width 属性,动态的同步表格头和表格内容的 <col> 元素上的宽度设置。

因为篇幅限制,不再赘述代码如何实现此功能,有兴趣可以自己实现试一试。

固定列

固定表格头是通过把表格头和表格内容分成两个 <table> 来实现,固定列的实现本质上是把固定列所需要的表格头和表格内容克隆一份来实现。实现原理如下图所示,固定列用的表头和表格体使用深色来表示:

为了简化这个例子,这个例子的实现加了两个前提条件:

  1. 表格的每行的行高是统一的。
  2. 固定列的列有固定宽度,固定为 80 。

固定列的实现依赖于固定表头的实现,所以是由固定表头的例子修改而来,可以在线查看。例子效果如下图所示:

如上图的箭头所示,实现这个效果有三个步骤:

  1. 渲染固定列
  2. 同步 scroll 事件
  3. 同步 hover 效果

渲染固定列

先把固定列的表格头和表格内容使用 HTML 渲染出来:

<div class="custom-table--fixed-wrapper">
  <div class="custom-table--header-wrapper">
    <table class = "table table-bordered">
      <tr>
        <th> Game ID </th>
      </tr>
    </table>  
  </div>

  <div class="custom-table--body-wrapper is-fixed" style="height: 300px;">
    <table class = "table table-hover table-bordered">
      <tr>
        <td> 1 </td>
      </tr>
      <tr>
        <td> 2 </td>
      </tr>
      <tr>
        <td> 13 </td>
      </tr>
      <tr>
        <td> 1 </td>
      </tr>
      <tr>
        <td> 2 </td>
      </tr>
      <tr>
        <td> 13 </td>
      </tr>
      <tr>
        <td> 1 </td>
      </tr>
      <tr>
        <td> 2 </td>
      </tr>
      <tr>
        <td> 13 </td>
      </tr>
      <tr>
        <td> 1 </td>
      </tr>
      <tr>
        <td> 2 </td>
      </tr>
      <tr>
        <td> 13 </td>
      </tr>
    </table>
  </div>
</div>

然后为固定列增加以下样式:

.custom-table--body-wrapper.is-fixed {
  overflow-y: hidden;
}

.custom-table--fixed-wrapper {
  box-shadow:4px 0px 4px rgba(0, 0, 0, 0.1);
  position: absolute; 
  left: 0;
  top: 0;
  bottom: 0;
  width: 81px;
  background-color: #fff;
}

.custom-table td, .custom-table th {
  white-space: nowrap;
  overflow: hidden;
  height: 42px;
  padding: 2px !important;
}

同步 scroll 事件

因为固定列和表格主体的滚动条是独立的,所以在表格主体滚动的时候,需要同步到固定列的表格内容的滚动条上。使用 jQuery 实现的代码如下:

// 同步两边的滚动
$('.custom-table--body-wrapper').on('scroll', function(event) {
    var scrollTop = $(this).prop('scrollTop');
  $('.custom-table--body-wrapper.is-fixed').prop('scrollTop', scrollTop);
});

同步 hover 效果

同步 hover 效果的会麻烦一些,因为除了表格主体进行 hover 会影响固定列之外,固定列的 hover 也会影响到表格主体。因为 Bootstrap 的 hover 效果使用了 :hover 来实现,所以需要增加一个使用 hover 的 class:

.custom-table tbody > tr.hover > td {
  background-color: #f5f5f5;
}

实现同步 hover 效果的 JavaScript 代码如下:

var getRowIndex = function(event) {
  var target = event.target;
  if (target.tagName === 'TR') {
    return $(target).index();
  } else {
    return $(event.target).parent('tr').index();
  }
};

// 同步两边的 hover 效果
$('.custom-table--body-wrapper tr').hover(function(event) {
  var index = getRowIndex(event);
    $('.custom-table--body-wrapper tr:nth-child(' + (index + 1) + ')').addClass('hover');
}, function(event) {
    var index = getRowIndex(event);
    $('.custom-table--body-wrapper tr:nth-child(' + (index + 1) + ')').removeClass('hover');
});

突破限制

这个例子是有两个限制,下面简单讲一下原因:

  1. 表格的每行的行高是统一的:因为这个例子中的固定列的表格内容只渲染了一列,所以固定列的行的高度是受那一列控制。但是表格主体中的行高是受所有行的控制,无法通过 CSS 来实现两个无任何关联的元素的高度同步。
  2. 固定列的列有固定宽度:因为表格主体的列宽是由表格的宽度决定的,固定列只渲染了一列,同样无法通过 CSS 来同步表格主体对应的列宽。

突破这两个限制一般情况下有两个选择:

  1. 动态同步左右两边的行高和列宽:优点是性能较好,只会渲染出需要渲染的列。缺点是同步行高、列宽的时机比较难以掌控,会有一些边缘案例导致有 Bug 出现,比如出现列宽、行高不一致导致的错位。
  2. 覆盖在表格主体上的固定列渲染所有的列:优点是不需要动态同步行高和列宽,让浏览器自己去控制行高和列宽,很难出现错位的 Bug。 缺点也很明显,就是在数据量较大或者表格内渲染内容较为复杂的场景下,会导致渲染次数过多,产生一定的性能问题。

BTW,表格组件不一定需要突破这两个限制,有时候加上这两个限制是必须的,这个需要看实际的业务场景。对于 Excel 类表格组件来讲,固定的行高是保证组件性能的基础,加上这个限制也让一些其他特性的实现难度降低。

写在最后

对于前端程序员来讲,实现复杂组件一直是小众工作。一方面是由于实现难度大,另一方面是因为投入产出比过低,不如直接使用开源产品或者商业产品。

如果你有意去自己实现一个表格组件,希望这篇文章能对你有所帮助。如果对文章中的内容有任何疑问,请直接在文章评论区留言。

最后,感谢你有耐心读完这篇文章。