当前位置: 首页 > 工具软件 > Handsontable > 使用案例 >

复杂表格-handsontable

沙宣
2023-12-01

复杂表格-handsontable

写这篇文章的目的是记录一下如何在前端用handsontable这个最接近Excel体验的表格做一些复杂交互。

1. 基本使用

handsontable对Vue的支持还是很友善的,甚至它已经有了对Vue3的支持了,因此我们也就不用像之前写前端视频流播放一样被如何引入折磨了。

官网的指引,安装@handsontable/vue3handsontable两个包:

$ npm install @handsontable/vue3 handsontable

而后在Vue文件中:

<template>
    <hot-table ref="table" :settings="settings"></hot-table>
</template>
<script>
    import { HotTable } from "@handsontable/vue3";
    import { registerAllModules } from "handsontable/registry";
    ...
    
    registerAllModules();
    
    export default defineComponent({
    	name: 'table',
        setup() {
            const table = ref(null);
            const settings = reactive<GridSettings>({
      			data: [], // 表格数据
                columns: [], // 表格列配置
                colHeaders: [], // 表头
      			rowHeaders: true, // 是否显示行数
      			columnSorting: true, // 排序
      			stretchH: "all", // 是否拉伸表格以占满父节点
      			manualColumnResize: true, // 是否自定义resize
      			height: "calc(100% - 110px)", // 表格高度,可以是calc函数及任意css长度单位
      			licenseKey: "non-commercial-and-evaluation", // 许可证,如果做了捐赠可以开启EX功能,这边用的默认
            )}
            onMounted(() => {
      			(table.value as any).hotInstance.loadData(settings.data as any);
    		});
        }
    })
</script>

最基础的表格就这么写好了。

欸,那这种空空如也的表格肯定不符合我们的要求,于是我们往里面加东西。不过在此之前让我们先看看这个GridSettings中最关键的三样(data, columns, colHeaders)的定义:

data?: CellValue[][] | RowObject[];
export type CellValue = any;
export interface RowObject {
  [prop: string]: any;
}

columns?: ColumnSettings[] | ((index: number) => ColumnSettings);

export interface ColumnSettings extends Omit<GridSettings, "data"> {
  data?: string | number | ColumnDataGetterSetterFunction;
  /**
   * Column and cell meta data is extensible, developers can add any properties they want.
   */
  [key: string]: any;
}

colHeaders?: boolean | string[] | ((index: number) => string);

可以看见data可以是任何类型的数组,实际显示由渲染函数来执行显示结果,这个后面再说,如果不定义渲染函数会执行默认的渲染,就是直接把data中的值按[行[列]]这种模式拆解渲染;columns可以定义每一列的各种属性,其中就包括渲染方法以及单元格类型、可否编辑等;colHeaders是我最不能理解的,居然就是单纯的把数组按顺序渲染为表头,而且没有提供任何重构方法,而众所周知,表头的label和它那一列的paramName往往不一样,所以这时候我们需要自己定义一个Map去保存这个对应关系,另外这个colHeaders经过实验,渲染应该用的是类似v-html的方式,并没有考虑XSS,因此这里是可以写HTML的。

2. 单元格配置

columns是一个数组,每一项的配置对应data中同index的一项生效,其常用属性定义如下(可以额外添加自定义的属性):

interface cloumns = {
  data: string; // 此列的标识
  readOnly?: boolean; // 列是否只读
  type?: string;  // 列类型,详情可以看官网[cells type]这一节
  columnSorting: boolean; // 是否排序
  sortOrder: "asc" | "desc"; // 排序方式
  selectOptions?: string[]; // 当type==="select"时生效,选择框选项
  source?: string[]; // 当type==="dropdown"时生效,下拉栏选项
  sourceMap?: { [key: string]: number | string }; // 自定义选项,由于source只包含label而没有value,自己加了个Map做映射,select同理
  dateFormat?: string; // 当type==="date"时生效,生成的date string格式,默认使用moment.js转换
  timeFormat?: string; // 当type==="time"时生效,生成的date string格式,默认使用moment.js转换
  correctFormat?: boolean; // 是否默认转换为统一格式
  defaultDate?: string; // 默认时间
  datePickerConfig?: Record<string, any>; // 当type==="date"时,日期选择框的设置
  renderer?(	// 单元格渲染方法
    instance: Core,	// 表格对象
    td: HTMLTableCellElement, // 单元格DOM
    row: number, // 所在行数,以0开始,-1是表头
    col: number, // 所在列数,以0开始,-1是列头
    prop: string | number, // column.data,列标识
    value: CellValue, // 单元格值,即ColumnSettings.data中的值
    cellProps: CellProperties // 单元格属性
  ): HTMLTableCellElement; // 返回渲染的DOM元素
}[]

其中renderer可供自定义渲染,例如当ColumnSettings.data中元素为一个Object的情况下,可以通过renderer将其某一项渲染(因为默认渲染器直接将其当作HTML插入了td中,会默认调用toString() || JSON.stringfy())。一个例子如下:

export function renderName(
  instance: Core,
  td: HTMLTableCellElement,
  row: number,
  col: number,
  prop: string | number,
  value: CellValue,
  cellProps: CellProperties
): HTMLTableCellElement {
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  // @ts-ignore
  // eslint-disable-next-line prefer-rest-params
  textRenderer.apply(this, arguments);

  const text = td.innerText;
  const name = JSON.parse(text).name;
  if (name.length > 40) {
    td.innerText = name.substring(0, 40) + "...";
  } else {
    td.innerText = name;
  }
  return td;
}

但一般由于这样做会导致额外经历两次JSON转义,数据量大之后很容易造成性能问题,因此一般都会自己写一个转换函数,并且保存一份name -> obj的映射副本。

当column.type定义不同时,可能会产生额外选项,详情可以看cell type。由于当选择选择类的类别时(如select/dropdown)并没有像element或antd的表格组件那样提供object选项,因此其显示值就是实际值,我的做法是在对应的column中自定义一个Map去保存选项标签和值的映射关系。

3. 表格事件

表格事件是直接在表格的setting里编写的函数定义,具体事件列表可以看hooks这里,我写几个常见的hook:

3.1 afterChange

一般用于在表格数据量大的时候做筛选保存(即只保存修改过的值,不保存没修改的值),同时也可以对某些有格式要求的数据进行格式化及验证。

举个例子:

// 注意changes是一个数组,因为表格还有类似excel的自动填充
afterChange: function (changes: (string | number)[][], action: string) {
  if (action !== "loadData" && changes) {
    for (const change of changes) handleChange(change);
  }
}

function handleChange(changes: (string | number)[]) {
  const [row, key, oldVal, newVal] = changes;
  /*
  ** column.type==="checkbox"时,表头上是没有一般表格组件的全选的,因此要自己去判断并做操作
  */
  if (key === "check" && row !== -1) {
    (settings.data as Record<string, string | number | boolean>[])[
      row as number
    ].check = newVal; // 更新当前选中checkbox的值
    // 判断是否全选,并对应改变header的innerHTML
    const allChecked = isAllChecked();
    const className = allChecked
      ? "mdi-checkbox-marked deepblue--text"
      : "mdi-checkbox-blank-outline";
    (
      settings.colHeaders as string[]
    )[0] = `<i class="mdi ${className} font-16"></i>`;
    (table.value as any).hotInstance.updateSettings(
      {
        colHeaders: settings.colHeaders,
      },
      false
    );
  } else {
    // 获取到对应的column,即这一列单元格的配置,xType为自定义属性,标记了单元格类型
    const column = headerMap[selected.value].columns.find(
      (item) => item.data === key
    );
    const xType = column?.xType;
    const data = (
      settings.data as Record<string, string | number | boolean>[]
    )[row as number];
    const id = data.id as string;
    // 新旧值不等时,进行处理
    if (oldVal !== newVal) {
      // 当类型为datetime时做格式化和验证
      if (xType && xType === "datetime") {
        const time = new Date(newVal).getTime();
        if (isNaN(time)) {
          (settings.data as Record<string, string | number | boolean>[])[
            row as number
          ][key] = oldVal;
          message.info('格式错误,正确格式为"YYYY-MM-DDThh:mm:ss"');
          return;
        } else {
          (settings.data as Record<string, string | number | boolean>[])[
            row as number
          ][key] = new Date(time + 28800000).toISOString().split(".")[0];
        }
      }
      // 如果变更表中没有此项(一行数据),则添加此项,有则修改此项对应的属性值
      if (!changedData[id]) changedData[id] = { id: data.id };
      changedData[id][key] = newVal;
    }
  }
}

3.2 afterOnCellMouseUp

可以近似当作单元格点击事件(因为没有cellClick事件),一般在readOnly: true时用于对应的特异性点击操作处理,或是全选操作。

举个例子:

afterOnCellMouseUp: function (event, coords, td) {
  // 判断是否是首行(表头)首列(即全选的header),更改其值并对应修改所有的check value
  if (coords.row === -1 && coords.col === 0 && settings.data) {
    const allChecked = isAllChecked();
    const className = allChecked
      ? "mdi-checkbox-blank-outline"
      : "mdi-checkbox-marked deepblue--text";
    (
      settings.colHeaders as string[]
    )[0] = `<i class="mdi ${className} font-16"></i>`;
    settings.data = settings.data.map((item) => ({
      ...item,
      check: !allChecked,
    }));
  }
}
3.3 afterOnCellMouseDown

与上面的事件用处差不多,一般用于双击捕获(因为没有cellDbClick事件)。

举个例子:

afterOnCellMouseDown: function (
  event,
  coords,
  td: HTMLTableCellElement & { lastClick?: number }
) {
  // 判断两次单机间隔事件,模拟双击事件
  const now = new Date().getTime();
  if (!(td.lastClick && now - td.lastClick < 200)) {
    td.lastClick = now;
    return;
  }
  // 双击事件处理
  ...
}

整个表格处理差不多就是这样,需要注意大部分column的配置都不是类似element那样的key-value配置,而是单纯的字符串,显示值就是实际值,因此需要自己多做一点映射。

 类似资料: