如何基于 AntV S2 打造大数据表格组件

lxf2023-05-09 01:09:44

前情提要:AntV 2021 年度发布了新产品多维交叉分析表格 S2,可以点这里了解详情。

导读

在蚂蚁的大数据研发平台中,数据表格是一类重要的业务组件。我们需要流畅的展示 SQL 查询出来的上万条结果,并对结果做筛选、排序、搜索、复制、框选、聚合分析等操作。同时也存在数据手工录入的场景,需要表格有可编辑的能力。所以我们最终需要的是一种 JavaScript 版的电子表格,类似一个简单的 Excel 工作簿。

本文记录的是,在开源软件不足以满足需求,同时商业软件价格高、定制难的情况下,我们如何基于开源 Canvas 表格渲染库 AntV S2 来实现拥有诸多功能的 React 大数据表格组件,并且解决了之前商业软件版本实现的性能问题和拓展性问题。

业务场景介绍

Dataphin 是蚂蚁内部的大数据研发平台,同时也有云上版本对外售卖。Dataphin 一站式提供数据采、建、管、用全生命周期的大数据能力。在 Dataphin 中有很多场景都用到了大数据表格。最典型的就是研发模块中,用来展示计算任务的运行结果:

如何基于 AntV S2 打造大数据表格组件

以下是数据表格常用功能的演示:

行列冻结 如何基于 AntV S2 打造大数据表格组件 搜索 如何基于 AntV S2 打造大数据表格组件 筛选 & 排序 如何基于 AntV S2 打造大数据表格组件 框选 & 复制 如何基于 AntV S2 打造大数据表格组件

技术选型:为什么使用 AntV S2 ?

对于复交互的电子表格类组件来说,基于 Canvas 实现会比 DOM 实现有着更大的优势。从性能上说,DOM 渲染需要经过如下的渲染流程,所有的 UI 改动都会涉及内部数据结构的构建、样式的解析、布局的计算。最终才渲染出来:

如何基于 AntV S2 打造大数据表格组件

而 Canvas 的渲染管线就比较简单,Canvas 的 API 直接和 Skia 这样的底层 2D 图形调用相对应,免去了 DOM 的解析和布局流程。所以基于 Canvas 的图形应用,有着更高的性能上限。特别是在富交互,大数据量的场景下。同时基于 Canvas 的应用也更容易编写复杂的图形交互,因为图形不再以矩形作为最小单元,而是可以任意绘制。所以我们在选型时主要考虑基于 Canvas 的产品。下面是对市场上已有的类 Excel 前端表格组件的一些分析:

产品名称技术栈开发商费用结论
SpreadJSCanvas公司收费市场上最强大的 Excel 组件,但需要比较高的授权费用,同时在多实例、大数据量的场景下,性能比较差,并且很难拓展和定制 UI
LuckySheet、x-spreadsheetCanvas个人开源开源的类 Excel 组件。都是个人作品,由社区力量维护,投入的持续性、稳定性无法保障,在产品细节体验上也不如 SpreadJS
HandsontableDOM公司收费DOM 实现。同时需要收费。
语雀、钉钉等表格服务Canvas公司/通过服务化的 SDK 提供前后端一体的服务,而不是单纯的前端组件形式。同时拓展性也不如开源产品。

在市场上无法找到合适的选择之后,我们蚂蚁数据智能前端团队选择将内部的交叉表组件孵化为开源的 AntV S2 表格渲染引擎。

S2 是 AntV 团队推出的数据表可视化引擎,旨在提供高性能、易扩展、美观、易用的多维表格。不仅有丰富的分析表格形态,还内置丰富的交互能力,帮助用户更好地看数和做决策。

S 取自于 SpredSheet 的两个 S,2 也代表了透视表中的行列两个维度。S2 不仅有交叉表模式,还支持明细表模式。

如何基于 AntV S2 打造大数据表格组件

明细表就是普通的表格。类似 Antd Table 组件。适用于明细数据的展示。比如我们一开始说到的 SQL 查询结果的预览等等。明细表组件流畅在支持 10w+ 条大数据展示的基础上,还支持丰富的交互:单选、刷选、复制到 Excel、快捷键多选、列宽高拖拽调节等等。方便用户快速的对数据做选择和操作。

表格功能实现

S2 的明细表为我们提供了一个很好的基础底座,关于明细表的功能,大家可以参考文档。接下来就聊聊如何在 明细表的基础上,利用 S2 的高拓展性设计,实现一款功能丰富的 React 大数据表格组件。其中一些常用功能内置到了 S2 中,比如行列冻结、复制等。另外还揭秘了一些 S2 的内部实现,比如虚拟滚动的原理

功能大图

首先看一下大数据表格组件的功能大图:

如何基于 AntV S2 打造大数据表格组件

上图的能力中,包含了数据展示、检索、导出、编辑等核心能力。其中分析方面的公式、聚合等能力是未来规划的。接下去会讲讲目前实现的这些核心功能的设计与实现。

交互

单元格编辑

单元格编辑是大数据表格在交互上遇到的最核心诉求。用户可以直接的编辑数据,录入数据。在手工维度表、调试录入 Mock 数据等等场景有着广泛的使用。由于 S2 的定位是分析表、明细表核心库,所以单元格编辑的能力并没有内置。这个能力需要大数据表格组件来扩展实现。

首先需要考虑的问题便是技术实现上,编辑能力是使用 DOM 还是 Canvas 实现?在功能开发之前,我们调研了业界各类主体基于 Canvas 的电子表格库,结果大致如下:

单元格编辑实现
SpreadJSDOM
语雀表格DOM
x-spreadsheetDOM

可以看到,主流的实现都选择了 DOM 作为单元格编辑的载体。考虑到光标绘制,文本选中,换行逻辑等等一系列在 DOM 中都将由浏览器实现,在 Canvas 中则需要重新实现,使用 Canvas 上覆盖 DOM 节点做文本编辑是性价比最高的方案。所以我们最终决定使用 DOM 来实现,效果如下:

如何基于 AntV S2 打造大数据表格组件

下面简要介绍实现的过程:

1. 计算DOM遮罩大小和坐标

编辑态其实类似一种 Modality。需要用户的 Focus,在编辑时需要阻断和底部表格的交互。所以我们需要挂载一个 DOM 遮罩容器节点,大小和 Canvas 保持一致,用于放置 TextArea。元素的定位使用到了目前的窗口滚动的 Offset 和 Canvas 容器的坐标值。

const EditableMask = () => {
  const { left, top, width, height } = useMemo(() => {
    const rect = (spreadsheet?.container.cfg.container as HTMLElement).getBoundingClientRect();
    const modified = {
      left: window.scrollX + rect.left,
      top: window.scrollY + rect.top,
      width: rect.width,
      height: rect.height,
    };

    return modified;
  }, [spreadsheet?.container.cfg.container]);
  return (
      <div
        className="editable-mask"
        style={{
          zIndex: 500,
          position: 'absolute',
          left,
          top,
          width,
          height,
        }}
      />
  );
};

EditableMask 组件会被 ReactDOM.render 渲染到 body 中,作为顶层的子元素:

如何基于 AntV S2 打造大数据表格组件

2. 注册事件并渲染 TextArea 元素

编辑操作一般会由双击来触发,而 S2 为我们提供了 DATA_CELL_DOUBLE_CLICK 事件。然后来看看 TextArea 的渲染逻辑,我们需要拿到单元格的定位和当前数据值。在触发事件时,从被双击的 DataCell 对象中可以拿到 x/y/width/height/value 这几个核心数据。需要注意,这里的 x 和 y 针对的是整个表格,而不是当前的 ViewPort,因此我们需要使用 spreadsheet.facet.getScrollOffset() 获取表格内部的滚动状态并对 x/y 做针对性的修改。

核心逻辑如下:

// 从事件回调拿到 Cell 对象
const cell: S2Cell = event.target.cfg.parent;

// 计算定位和宽高
const {
  x: cellLeft,
  y: cellTop,
  width: cellWidth,
  height: cellHeight,
} = useMemo(() => {
  const scroll = spreadsheet.facet.getScrollOffset();

  const cellMeta = _.pick(cell.getMeta(), ['x', 'y', 'width', 'height']);

  // 减去滚动值,获取相对于 ViewPort 的定位
  cellMeta.x -= scroll.scrollX || 0;
  cellMeta.y -=
    (scroll.scrollY || 0) -
    (spreadsheet.getColumnNodes()[0] || { height: 0 }).height;

  return cellMeta;
}, [cell, spreadsheet]);

// 获取当前单元格数值并存到 state 中
const [inputVal, setinputVal] = useState(cell.getMeta().fieldValue);

接着我们使用 x/y/width/height 把 TextArea 渲染到 DOM 遮罩中,填充单元格当前的值,并手动 .focus(),方便用户直接开始编辑操作。

3. 编辑完成后清理逻辑

在用户触发了 onBlur 事件或者输入回车的时候,我们销毁 TextArea,并将修改后的值回填到spreadsheet.originData 中。并且抛出对应的事件,通知使用者。

刷选

在 Excel 中,刷选是一种重要的交互。用户可以自由的批量框选单元格,并且在鼠标移动到画布外时,画布会自动滚动,同时更新框选区域:

如何基于 AntV S2 打造大数据表格组件

S2 中内置了刷选的交互。下面我们来看这个交互的实现方式。首先明确一些概念:

  • StartBrushPoint 刷选开始点。MouseDown 事件时记录。
  • EndBrushPoint 刷选结束点。MouseMove 事件时更新。
  • BrushRange 刷选范围。包含了开始点的行列 Index 和结束点的行列 Index。
如何基于 AntV S2 打造大数据表格组件

刷选就是监听鼠标拖动事件,然后将开始和结束点之间的格子设置为高亮状态。但这只是开始,我们需要支持刷选的自动滚动,核心流程如下:

如何基于 AntV S2 打造大数据表格组件

首先在滚动触发阶段,监听 MouseMove 事件时,需要增加判断,如果鼠标不在画布范围内,就进入自动滚动流程,并把 EndBrushPoint 限制在画布上。如图:

如何基于 AntV S2 打造大数据表格组件

MouseMove 事件触发的点的坐标是在画布之外的,所以我们会把这个点,垂直投射到画布的边缘。得到最终的 EndBrushPoint。

同时在这个过程中,我们还可以得到滚动的方向。我们把滚动方向分为 Leading 和 Trailing 两种(头部和尾部)。同时又分 X 轴和 Y 轴两个方向。这样就有了 8 种滚动的可能方向。根据 MouseMove 坐标所在的位置,就可以计算出需要滚动的方向。如下图所示:

如何基于 AntV S2 打造大数据表格组件

在知道滚动方向之后,就可以触发自动滚动了。

还有一些细节问题需要考虑。比如在滚动中,每个格子的宽和高都有可能是不同的,如果滚动的距离是一个恒定的值,那在遇到很高或者很宽的格子时,就会出现龟速滚动几次才能滚完一个格子的情况。所以每次滚动的距离,必须是一个动态的值。实际落地的方案是这样的:拿向右滚动举例,滚动的 Offset 会定位到下一个格子的右侧边缘。比如这样:

如何基于 AntV S2 打造大数据表格组件

保证滚动后,下一个格子会完整的进入视口内。

在循环滚动时,滚动的频率也是一个需要注意的细节。不同的用户对于滚动频率有不同的诉求。所以滚动频率也不应该是恒定的。一种方案是将滚动速度和滚动离开画布的距离关联起来。往下面拉的越多,滚动就越快,直到一个最大值。比如 Excel 中的实现:

如何基于 AntV S2 打造大数据表格组件

滚动持续时间的大致计算逻辑:

const MAX_SCROLL_DURATION = 300;
const MIN_SCROLL_DURATION = 16;
let ratio = 3;
// x 轴滚动速度慢
if (config.x.scroll) {
  ratio = 1;
}
this.spreadsheet.facet.scrollWithAnimation(
  offsetCfg,
  Math.max(MIN_SCROLL_DURATION, MAX_SCROLL_DURATION - this.mouseMoveDistanceFromCanvas * ratio),
  this.onScrollAnimationComplete,
);

里面的一些参数,是根据实际的用户体感来确定的。需要经过不断的优化迭代,达到一个比较理想的状态。

渲染

虚拟滚动

在大数据表格场景中,如何高性能的渲染大量数据一直都是最重要的问题之一。我们可以把大数据表格看成一个长列表,对于长列表,最常见的优化策略就是只渲染可见区域的内容。在滚动事件触发后,根据滚动 Offset 调整相应渲染的内容即可。在用户看来,还是一个完整的长列表。

DOM 的虚拟列表实现需要考虑的东西比较多,也有 React-Virtualized 这样的库可以直接使用。基于 Canvas 的长列表优化方式,我们叫虚拟滚动。因为基于 Canvas 的应用每次渲染都会重绘整个界面。我们使用 requestAnimationFrame 来定时 Schedule 一次渲染,让频繁的滚动事件落到浏览器的渲染周期中。然后在每个 animationFrame 的回调中,只需要计算出当前视口内的元素范围然后渲染这些格子就可以了。

下面讲讲具体的实现流程:

第一步:计算可视区坐标范围,得出可视区内的格子列表

首先根据行列信息和当前滚动 Offset 计算出可视区的范围,获得一个数组,包括 [xMin, xMax, yMin, yMax]。也就是行和列的 index 范围。如下图所示:

如何基于 AntV S2 打造大数据表格组件

第二步:和上一次格子列表做对比,得到 Diff

这一步中,我们将上一次渲染的可视区 Index 和当前进行 Diff,拿到需要新增和删除的格子:

export const diffPanelIndexes = (
  sourceIndexes: PanelIndexes,
  targetIndexes: PanelIndexes,
): Diff => {
  const allAdd = [];
  const allRemove = [];

  Object.keys(targetIndexes).forEach((key) => {
    const { add, remove } = diffIndexes(
      sourceIndexes?.[key] || [],
      targetIndexes[key],
    );
    allAdd.push(...add);
    allRemove.push(...remove);
  });

  return {
    add: allAdd,
    remove: allRemove,
  };
};

第三步:分别对 Diff 结果做 add 和 remove 操作

这一步中,我们通过 AntV/G 的 API 对 Canvas 对象树做操作,把格子实例化并添加/删除。实际的效果类似以下的动画演示(图中的 add 操作做了延迟处理,让效果更明显):

如何基于 AntV S2 打造大数据表格组件

这样就实现了虚拟滚动,让每次滚动渲染的时间都只和视口的大小有关,而不是线性增长。

这个官网例子展示了明细表在 100W 数据下流畅渲染的表现。

自定义列头

在我们的业务中,在数据的列头之外,还需要实现类似 Excel 的序号列头,就像这样:

如何基于 AntV S2 打造大数据表格组件

数据列头上还需要支持筛选、排序、脱敏的展示,有着众多不同的状态:

如何基于 AntV S2 打造大数据表格组件

对于这样的需求,S2 可以轻松满足。因为 S2 的底层绘制引擎是 AntV/G,所以我们不需要去使用最底层的 Canvas API,降低了门槛和维护成本。最重要的是 S2 支持自定义 Cell。我们只需要继承内置的 Cell 类,重写相应的方法,然后把新的 Class 传给 S2 即可。

S2 的 Options 中提供了诸多 API 来自定义单元格的渲染:

如何基于 AntV S2 打造大数据表格组件

对于上述的场景,我们可以自定义 dataCell。通过重写 drawActionIcon 方法来实现完全自定义的 Icon 绘制:

import { TableDataCell } from '@antv/s2';

class S2Cell extends TableDataCell {
  
  private drawActionIcon() {
    // 绘制逻辑,因为 TableDataCell 继承了 AntV/G 的 Group 对象
    // 所以这里直接使用 addShape 这样的属性就可以做 Canvas 的绘制
  }
}

export default S2Cell;

在上述代码中,用户可以通过 S2 内置的 renderIcon 工具方法,来绘制 Icon,并且根据筛选和排序的状态来控制 Icon 的样式。同时还可以注册自定义的事件,来定义 Icon 点击时的行为。这些绘图 API 底层都是 AntV/G。所以在自定义 S2 的单元格时,我们只需要简单学习 G 的 API 就可以上手。

通过自定义角头,还可以实现类似 Excel 的三角形角头效果:

import { TableCornerCell } from '@antv/s2';

export default class CornerCell extends TableCornerCell {
  protected drawTextShape() {
    this.textShape = this.addShape('polygon', {
       // 图形属性
    });
  }
}

布局

行列冻结

前端的表格一般都支持左侧或者右侧一定数量列的固定,比如 Antd。除了列的固定,基于 Canvas 的 SpreadJS,还支持行的固定。这也是 Excel 的常用功能之一:

如何基于 AntV S2 打造大数据表格组件

固定行列一般是比较重要的参考信息,比如 id、名称等。在列数量较多的时候,信息固定可以方便用户在查看信息的同时,快速了解每个行列的上下文。

冻结的意思就是,不管表格的内容如何滚动,冻结的行始终显示在视图上方或者下方,冻结的列会始终显示在视图的左侧或者右侧,也就是说,冻结的行的 y 坐标在滚动时保持不变,冻结的列的 x 坐标在滚动时保持不变。

在 S2 中,我们可以通过设置这些 Options 实现行列冻结:

如何基于 AntV S2 打造大数据表格组件

下面聊聊如何实现行列冻结,因为我们需要在滚动时对冻结的部分做特殊处理。所以首先需要对做明细表的内容区域做分组:

如何基于 AntV S2 打造大数据表格组件

我们首先分出 frozenRowGroup、frozenColGroup、frozenTrailingRowGroup 和 frozenTrailingColGroup 来分别对应上下左右四种冻结方向,把每个方向需要冻结的格子放到这些分组。然后还需要一个 panelScrollGroup 分组存放普通格子,也就是会跟随 x、y 两个方向自由滚动的格子。

除了这几个常规的分组之外,我们还注意到,四个冻结分组在四个角上是有交叉的,这些交叉区域的格子,在 x、y 两个方向都不需要滚动,所以这些格子需要专门放到一个分组里,并做类似 postion: fixed 的特殊定位处理。对这些格子,我们放入 frozenTopGroup 和 frozenBottomGroup 两个分组。

同时,对于列的固定,不仅仅是数据格子需要固定,列头也要做固定。所以我们把列头区域也分为三个分组,分别是 frozenColGroup、scrollGroup 和 frozenTrailingColGroup。

所以接下去的思路是,首先在渲染时,确定格子属于哪个区域,然后加入对应的分组中。然后在 translate 时,对不同分组做不同的 translate 操作。比如固定列只需要在 y 方向上滚动,固定行只需要在 x 方向滚动。

如何基于 AntV S2 打造大数据表格组件 新分组概览

接下来我们需要对渲染链路做改造:

第一步:渲染交叉冻结区域格子

对于交叉冻结区的格子,也就是四个角上的区域: frozenTopGroup/frozenBottomGroup 两个分组。这些格子在表的渲染周期中只需要添加一次,所以可以和 header 一样,在 S2 初始化的时候把节点插入即可。

第二步:计算可视区域格子坐标范围

S2 中用的是上文介绍的虚拟滚动,因此在渲染时会通过 scrollX 和 scrollY 计算出当前可视区域内的格子有哪些。之前内容区只有一个分组,计算出的格子坐标范围是这样的结构:

export type Indexes = [number, number, number, number]; // x Min, x Max, y Min, y Max

坐标在这个矩形范围内的格子会被渲染。

在支持行列冻结之后,这段逻辑就需要做修改,不仅要计算出滚动区域的格子范围,还需要计算出 frozenRow、frozenCol、frozenTrailingRow、frozenTrailingCol 这四个冻结区域的格子范围。因为行列冻结区域是支持单向滚动的,所以也需要做虚拟滚动。可视区坐标计算结果需要改成如下结构:

export type PanelIndexes = {
  center: Indexes;
  frozenRow: Indexes;
  frozenCol: Indexes;
  frozenTrailingRow: Indexes;
  frozenTrailingCol: Indexes;
};

第三步:对格子分组渲染

这一步很简单,对于可视区的每个格子,判断格子属于那一个分组,然后把格子加入这个分组即可。

第四步:滚动时做 translate

在 translate 时,对于行列冻结的分组,需要做单向滚动。

  • 其中 frozenRow、frozenTrailingRow 需要跟随 X 方向的滚动。
  • 其中 frozenCol、frozenTrailingCol 需要跟随 Y 方向的滚动。

ColHeader 改造

和 Panel 区一致,ColHeader 也要做分组渲染改造。在 layout 时,把 ColHeader 分为 frozenColGroup(左侧固定列头),scrollGroup(非固定的普通列头),frozenTrailingColGroup(右侧固定列头)。然后在 ColHeader 做 offset 操作时,只对 scrollGroup 做偏移即可。

需要注意的是,列头是可以做 Resize 操作的。因此还绘制了 Resize 热区。对于冻结的列,我们也需要固定热区的绘制位置。所以对于冻结列的 Resizer,是需要专门画到一个分组里面的,类似列头格子的 scrollGroup。同时对于非冻结列的热区,要做一个 Clip 操作,防止热区溢出到冻结列的区域。

工具栏操作

筛选 & 排序

筛选是用户使用的高频操作之一。目前我们已经实现的筛选模式有两种:数值筛选和表达式筛选。下面就分别聊聊两者的设计和实现:

数值筛选

如何基于 AntV S2 打造大数据表格组件

数值筛选通过 S2 的 DataCfg 的 filterParams 参数设置:

export interface FilterParam {
  filterKey: string;  // 要筛选的列的 key
  filteredValues?: unknown[]; // 被筛选(去掉)的值
}

在大数据表格中实现的主要是帮助用户可视化编辑要过滤的值。难点如下:

一、筛选细节逻辑需要对齐 Excel,保障用户习惯不变

  • 搜索文本筛选:搜素框有筛选值时,点确定的时候以筛选框里的值为基数(不在筛选范围内的值会被全部过滤掉)。
  • 筛选值联动:当其他列已经有筛选条件且当前项没有筛选条件的情况时,数值筛选的勾选框中会隐藏已经被其他行的筛选条件筛选掉的值。此时如果再做筛选,所有设置过筛选参数的列的筛选值会共同起作用。类似一个多级联动的筛选。

二、筛选器列表在大数据量下的卡顿问题

  • 因为 Checkbox 是采用 DOM 渲染的,而大数据表格组件会经常性的承载 1w+ 的数据量,导致用户在筛选时会卡顿,影响用户体验。解决方案是将数值筛选的 UI 组件整体做一层虚拟滚动,比如使用 react-virtualized。只渲染可视区域内的 DOM 元素。

表达式筛选

在表达式筛选中,支持用户通过配置表单的方式,使用由字段、操作符、数值构成的表达式做筛选。同时两个表达式可以通过 AND 和 OR 两种逻辑条件进行组合。

如何基于 AntV S2 打造大数据表格组件

表达式是一个构建 DSL 的过程,在不妥协未来扩展能力的情况下,我们在实现中支持了一元运算符和二元运算符两种形态:

interface Serializable<T> {
  // 序列化,支持从string重建方法
  serialize(): ExpressionOf<T>;
}

// 组合类型
export enum JoinType {
  OR = 'OR',
  AND = 'AND',
}

// 一元运算符
type UnitExpressionOf<T> = {
  key: FilterFunctionTypes;
  operand: T;
};

// 二元运算符
type JoinExpressionOf<T> = {
  type: JoinType;
  funcA: ExpressionOf<T>;
  funcB: ExpressionOf<T>;
};

export type ExpressionOf<T> = UnitExpressionOf<T> | JoinExpressionOf<T>;

export abstract class FilterFunction<T> implements Serializable<T>, Filterable {
  filter(value: unknown): boolean {
    throw new Error('Method not implemented.');
  }
  
  key: FilterFunctionTypes;

  private _operand: T;

  // 表达式另一侧的值:e.g., value > 2,则2是这个值
  public get operand(): T {
    return this._operand;
  }
  
  public set operand(value: T) {
    if (isValidNumber(value)) this._operand = Number(value) as unknown as T;
    else this._operand = value;
  }

  public serialize() {
    return {
      key: this.key,
      operand: this.operand,
    };
  }
}

继承FilterFunction,实现.filter()方法,我们就获得了能在业务组件中使用的筛选表达式类型,并且支持任意原子能力的 AND 和 OR:

如何基于 AntV S2 打造大数据表格组件

排序

排序使用 S2 的 DataCfg 的 sortParams 实现。

排序的实现和筛选基本类似,有一个需要注意的点:筛选可以在多个数据列中同时进行,而排序是排它的,因此在其他列已有排序的情况下设置当前列的排序,会导致其他列的排序条件被覆盖。

搜索

搜索的实现流程:

  1. 对数据集做遍历,找出所有匹配关键词的单元格
  2. 用户选择搜索结果,对下一个搜到的单元格做 Focus 操作

难点主要在 Focus 操作,需要把不在视口内的格子滚动进入视口内。比如这样:

如何基于 AntV S2 打造大数据表格组件

S2 提供了 facet.scrollWithAnimation API。参数是滚动的 Offset。所以问题最终可以归结到如何计算滚动的 Offset,从而让格子进去视口内。

核心逻辑就是使用之前提到的 calculateInViewIndexes 拿到当前可视区域的范围。然后判断当前格子在 X 和 Y 两个方向上处于当前视口的哪个方向,并计算出相应的滚动 Offset。还有一点就是行列冻结区域的宽高需要从 Offset 中减去,这样才能让格子从行列冻结的区域之下“露”出来。

列展示控制

列展示控制可以让用户通过勾选的方式选择部分列做展示,有助于列数据较多时,让用户专注在当前关注的几列上:

如何基于 AntV S2 打造大数据表格组件

实现方面,S2 提供了便捷的 API 来控制表格的状态,比如 setDataCfg 来更新数据、setOptions 来更新表格选项。我们通过控制 dataCfg 的 columns 传入的值即可方便的控制列的展示和隐藏。我们可以在 S2 外部增加一个表单组件,这个组件将需要隐藏的列存在 hiddenCols 数组中。在用户更新列展示设置后,我们只需要执行下面的逻辑就可以控制 S2 的列展示:

const hiddenCols = []  // 用户选择的要隐藏的列
const cols = [] // 所有的列
s2.setDataCfg({
  fields: {
    // 筛掉在 hiddenCols 数组中的列
    columns: cols.map((col) => hiddenCols.indexOf(col) === -1)
  }
})

只要修改 hiddenCols 然后重新渲染,即可做到外部 UI 控制的隐藏列功能。

复制

S2 实现了页面表格到 Excel 的保留单元格的复制功能。下面聊聊实现这个功能要注意的一些点:

1 数据内容的获取

S2 使用虚拟滚动的模式,所以我们不能直接从 DataCell 实例上去获取数据。比如在整行整列选择的时候,不可见的区域内的 Cell 实际上都是未渲染的,也就不能从这里去获取数据。为了解决这个问题,我们对 S2 做了改造,在选中交互时,S2 提供的是 Cell Meta(Cell 的元数据,比如格子类型、行列 index)。在复制时,我们最终是通过 Cell Meta 从源数据中选出被选中格子的信息,再重新拼接成需要复制的内容。

2 数据内容的拼接

获取到数据内容后,我们需要把这些内容拼合在一起,使之符合表格复制的规范。我们知道剪切板中的数据最终是一个字符串,那 Excel 又是如何识别不同的单元格呢?

答案就是制表符'\t'和换行符 '\r\n' 。每一行的数据,通过换行符 '\r\n' 做区分。每行中每个格子的数据后,需要跟一个制表符'\t'

有一个情况需要特别考虑,就是单元格内部的换行。行内和整体换行的标记都是 '\r\n' ,那么如何区别这两种情况呢?在实际操作时,只需要在单元格的内容外包一层双引号,告诉表格这是一个单元格里面的内容,那么就可以实现行内换行了。实际代码如下:

export const convertString = (v: string) => {
  if (/\n/.test(v)) {
    // 如果单元格内容里有换行符,在外面包一个引号
    return '"' + v + '"';
  }
  return v;
};

3 复制 API 的选择

之前主流的复制 API 是 document.execCommand('copy')。但这个 API 在数据量大的情况下(数万),会有卡顿的情况。所以在有条件的浏览器中,我们使用 navigator.clipboard API 做复制。工具函数 copyToClipboard 的大致逻辑如下:

export const copyToClipboard = (str: string, sync = false): Promise<void> => {
  if (!navigator.clipboard || sync) {
    return copyToClipboardByExecCommand(str);
  }
  return copyToClipboardByClipboard(str);
};

export const copyToClipboardByClipboard = (str: string): Promise<void> => {
  return navigator.clipboard.writeText(str).catch(() => {
    return copyToClipboardByExecCommand(str);
  });
};

export const copyToClipboardByExecCommand = (str: string): Promise<void> => {
  return new Promise((resolve, reject) => {
    const textarea = document.createElement('textarea');
    textarea.value = str;
    document.body.appendChild(textarea);
    textarea.focus();
    textarea.select();

    const success = document.execCommand('copy');
    document.body.removeChild(textarea);

    if (success) {
      resolve();
    } else {
      reject();
    }
  });
};

成果:成倍性能提升、完美自定义 UI ,顺利替换商业表格产品

上面我们介绍了如何基于 AntV/S2 实现全功能大数据表格。现在来看看这个新的表格解决了哪些问题。之前我们的线上的大数据表格使用 SpreadJS(v13),存在着以下的问题:

基于 SpreadJS 的老组件的问题

  1. 内置组件样式定制难

SpreadJS 的内置筛选、排序等 UI 组件是原生 JS 编写的。无法使用 React 组件做拓展或者替换。也无法定制 DOM 结构。所以内置的图标、布局都很难修改。基本是一锤子买卖。同时 Modal 出现的位置也无法定制,限制在表格内部,经常会出现遮挡表格内容的情况。

如何基于 AntV S2 打造大数据表格组件
  1. 单元格渲染定制难

SpreadJS 的单元格理论上可以拓展,但是只能使用 Canvas 原生的 API 去绘制。并且缺少文档和源码支持。整体来说渲染定制非常困难。比如加一个脱敏的 icon,就需要耗费数天时间,并且实现比较勉强。

  1. 使用复杂,经常有未知的 Bug

SpreadJS 和 React 一起使用时,是需要自行封装 React 组件的。这就是额外的成本。同时在使用过程中我们也发现了一些偶发的渲染异常,但没有完整源码,所以排查比较困难。

  1. 体量大,数据模型复杂,导致性能较差

同时打开 10 个数据结果 Tab(10 个表格实例),每个展示 1000 条数据。此时切换有明显的卡顿。切换时间大致在 500ms 左右。用户感知明显。

在大数据量场景下,预览用户上传的 5 万条数据文件,SpreadJS 需要加载 8 秒时间才能正常展示。

基于 S2 的大数据表格如何应对这些问题

拓展性 & 组件化:解决内置组件样式定制 & 单元格渲染定制和上手难问题

利用 S2 内置的筛选和排序等 API,还有自定义单元格渲染的能力,我们只需要封装 React 组件,就可以自由的定制组件的 UI。比如在业务中,我们使用了 Antd 作为 UI 基础库,来封装符合业务风格的筛选 & 排序 Modal。我们可以借助 Antd 的能力,而不用重复建设 Modal 能力。同时 Modal 的整体的布局和内容都是自定义的,有着很大的自由度:

如何基于 AntV S2 打造大数据表格组件

同时 S2 提供的内置 SheetComponent 组件也让我们可以免去封装纯 JS 库到 React 组件这样的过程。在 React 中上手只需要复制一下 Demo 代码就可以跑起来。

开源 & 自研:解决疑难杂症

在商业软件中,我们很难在购买到的编译混淆后的代码中做 Debug。而在开源软件中,有源码在手,一切疑难问题都不是问题,都可以看源码,排查问题得到解决。同时借助开源社区的力量,Bug 也会很快得到修复。经过一段时间的进化和磨练之后,S2 会变的更加强大和完善。

轻量级数据模型 & 虚拟滚动:解决大数据量下性能问题

S2 定位是一个轻量级的表格渲染核心,所以没有 Excel/SpreadJS 那样复杂的功能和数据模型。在大数据表格组件这个场景下,其实不需要那么重的数据模型,S2 的轻量级设计是更合适的。在性能表现上,10 个Tab 切换时,切换耗时为 80ms,是老方案的 6倍。在 5 万条数据的情况下,S2 渲染和初始化只需要 1秒。是老方案的 8 倍。

同时 S2 也可以根据实际情况做拓展,未来我们也会推出基于 S2 的类 Excel 电子表格场景组件,提供插件化、场景化的能力,用户可以按需使用,也可以通过插件化的方式引入公式等等高级功能。


结语

感谢你看到了这里,关于如何使用 AntV/S2 打造大数据表格组件的故事已经讲完了。但 AntV/S2 才刚刚起步。接下来我们会把大数据表格场景的单元格编辑、搜索、高级排序等等能力沉淀到 s2-react 中,让整个社区都可以使用到。同时也会继续加强 S2 明细表的底层能力。支持多层级列头、聚合计算等能力,对大分辨率下的渲染性能做优化。也欢迎社区的同学和我们一起共建 AntV/S2,打造最强的开源大数据表格引擎。如果看完这篇文章你有所收获,欢迎给我们的仓库 Star⭐️⭐️⭐️⭐️⭐️ 鼓励。

S2 的相关链接:

  • Github
  • 官网
  • 核心层: @antv/s2 V1.11.0
  • 组件层: @antv/s2-react V1.11.0
本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!