写这篇文章的目的是记录一下如何在前端用handsontable这个最接近Excel体验的表格做一些复杂交互。
handsontable对Vue的支持还是很友善的,甚至它已经有了对Vue3的支持了,因此我们也就不用像之前写前端视频流播放一样被如何引入折磨了。
按官网的指引,安装@handsontable/vue3
和handsontable
两个包:
$ 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的。
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去保存选项标签和值的映射关系。
表格事件是直接在表格的setting里编写的函数定义,具体事件列表可以看hooks这里,我写几个常见的hook:
一般用于在表格数据量大的时候做筛选保存(即只保存修改过的值,不保存没修改的值),同时也可以对某些有格式要求的数据进行格式化及验证。
举个例子:
// 注意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;
}
}
}
可以近似当作单元格点击事件(因为没有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,
}));
}
}
与上面的事件用处差不多,一般用于双击捕获(因为没有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配置,而是单纯的字符串,显示值就是实际值,因此需要自己多做一点映射。