本文内容基于@VisActor/VTable 用户访谈整理加工。
本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
业务场景介绍
在传统的评测系统中,多位评审员通常需要在飞书表格上共同完成同一数据集的标注工作。这一过程涉及到多用户编辑同一文档,并将标注完成的数据上传到评测平台。然而,这种做法存在一定的弊端:首先,原始数据并未在平台上得到有效积累,导致数据集的构建无法形成一个完整闭环;其次,用户需要手动上传标注后的数据,这样的操作流程不仅效率低下,而且用户体验也远非理想。
为了解决这些问题,提升用户的标注效率,并减少对线下飞书表格的依赖,我们的平台采纳了 VTable 可视化编辑方案。该方案允许用户直接在我们系统中的table表单中进行编辑,从而可以实现数据的直接存储,历史记录留存等功能。
通过VTable的编辑器接口以及相关的事件监听,可以很容易的集成HTML或者React、Vue组件来扩展更多的编辑能力,本文通过实例来展示一个可以一般化的解决方案。
VTable 简介
VTable 是字节跳动推出的VisActor开源可视化解决方案中的重要组成部分------高性能表格组件。以超高的性能和丰富的可视化能力著称。详情参见:
VTable 的编辑能力
VTable 目前提供的编辑能力主要有两种:
- 单元格编辑
- 数据填充
其中数据填充使用填充柄组件,
单元格编辑基于@visactor/vtable-editors组件,本文的主要介绍基于@visactor/vtable-editors组件的自定义表格编辑能力。
@visactor/vtable-editors
该组件内置了文本输入框、日期选择器、下拉列表等编辑器,用户可以直接使用或者进行扩展和自定义。
首先,确保已经正确安装了VTable库 @visactor/vtable和相关的编辑器包@visactor/vtable-editors。你可以使用以下命令来安装它们:
安装VTable:
sql
使用 npm 安装
npm install @visactor/vtable
使用 yarn 安装
yarn add @visactor/vtable
安装@visactor/vtable-editors:
sql
使用 npm 安装
npm install @visactor/vtable-editors
使用 yarn 安装
yarn add @visactor/vtable-editors
在代码中引入所需类型的编辑器模块(可以自定义实现或者引用vtable-editors包里的编辑类):
ts
// vtable-editors提供的编辑类
import { DateInputEditor, InputEditor, ListEditor } from '@visactor/vtable-editors';
接下来创建需要使用的编辑器实例:
ts
const inputEditor = new InputEditor();
const dateInputEditor = new DateInputEditor();
const listEditor = new ListEditor({ values: ['女', '男'] });
在上面的示例中,我们创建了一个文本输入框编辑器(InputEditor)、一个日期选择器编辑器(DateInputEditor)和一个下拉列表编辑器(ListEditor)。你可以根据实际需求选择适合的编辑器类型。
要想使用创建的编辑器实例,需要在VTable中进行注册。
ts
// 注册编辑器到VTable
VTable.register.editor('name-editor', inputEditor);
VTable.register.editor('name-editor2', inputEditor2);
VTable.register.editor('number-editor', numberEditor);
VTable.register.editor('date-editor', dateInputEditor);
VTable.register.editor('list-editor', listEditor);
接下来需要在 columns 配置中指定使用的编辑器(如果是透视表则在 indicators 配置 editor):
ts
columns: [
{ title: 'name', field: 'name', editor(args)=>{
if(args.row%2==0)
return 'name-editor';
else
return 'name-editor2';
} },
{ title: 'age', field: 'age', editor: 'number-editor' },
{ title: 'gender', field: 'gender', editor: 'list-editor' },
{ title: 'birthday', field: 'birthDate', editor: 'date-editor' },
]
现在用户可以通过双击
单元格来开始编辑,然后选择使用的编辑器进行输入。
自定义编辑器
如果VTable-ediotrs库提供的几种编辑器无法满足你的需求,你可以自定义实现一个编辑器。为此,你需要创建一个类,实现编辑器接口(IEditor)的要求,并提供必要的方法和逻辑。
可以结合下面这个流程图来理解编辑器和VTable之间的关系:
以下是一个自定义编辑器的示例代码,算是一个比较复杂的级联列表选择器,继承自@visactor/vtable-editors中的接口IEditor,IEditor中要求必须实现的接口有onStart,onEnd和getValue。
IEditor
接口定义如下:
ts
export interface IEditor<V = any> {
/** * 单元格进入编辑状态时调用 */
onStart: (context: EditContext<V>) => void;
/** * 单元格退出编辑状态时调用 */
onEnd: () => void;
/**
如果提供了此函数,VTable 将会在用户点击其他地方时调用此函数。
如果此函数返回了一个假值,VTable 将会调用 onEnd 并退出编辑状态。
如果未定义此函数或此函数返回了一个真值, VTable 将不会做任何事。
这意味着,你需要手动调用 onStart 中提供的 endEdit 来结束编辑模式。
*/
isEditorElement?: (target: HTMLElement) => boolean;
/** 获取编辑器当前值。将在 onEnd 调用后调用。 */
getValue: () => V;
/**
校验输入新值是否合法
*/
validateValue?: () => boolean;
}
export interface EditContext<V = any> {
/** VTable 实例所处的容器元素 */
container: HTMLElement;
/** 正在编辑的单元格位置信息 */
referencePosition: ReferencePosition;
/** 正在进入编辑状态的单元格当前值 */
value: V;
/**
用于结束编辑状态的回调。
*
大多数情况下你不需要使用此回调,因为 VTable 已经自带了 Enter 键按下
来结束编辑状态的行为;而鼠标点击其他位置来结束编辑状态的行为你也
可以通过 isEditorElement 函数来获得。
*
然而,如果你有特殊的需求,比如你想在编辑器内部提供一个"完成"按钮,
或者你有像 Tooltip 这样无法获取到的外部元素,
这时你可以保存这个回调并在你需要的时候来手动结束编辑状态。
*/
endEdit: () => void;
col: number;
row: number;
}
自定义编辑器实战
功能定义
我们的目标是定义一个React 级联组件Cascader,目标是通过该组件进行编辑交互,将结果更新到VTable中。
方便起见,我们直接使用 arco-design 的 Cascader 组件。其他React 组件的集成方法也是类似的。
代码实现
我们首先引入必须的组件和相关定义,从@visactor/vtable-editors
中导入IEditor
接口定义。
ts
import { Cascader } from '@arco-design/web-react';
import React from 'react';
import type { Root } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
import type { IEditor } from '@visactor/vtable-editors';
接下来我们实现CascaderEditor类,整体定义如下:
ts
export class CascaderEditor extends IEditor{
editorType: string;
cascaderOptions: null | []; // 全部columns信息
field: null | string; // 选中的cell的field
inputRef: React.RefObject<HTMLInputElement>;
root: null | Root; // 为了挂reactDOM
container: null | HTMLElement;
element: null | HTMLElement;
constructor(editorConfig: any) {
this.editorType = 'Cascader';
this.cascaderOptions = null;
this.field = null;
this.root = null;
this.element = null;
this.container = null;
this.init(editorConfig);
this.inputRef = React.createRef();
}
/**
* @description:
* @param {any} editorConfig
* @return {*}
*/
init(editorConfig: any) {
const { options, value } = editorConfig;
const filed = value.field;
this.cascaderOptions = options;
this.field = filed;
}
/**
* @description: 复写editor内置方法
*/
onStart(editorContext:{container: HTMLElement | null, referencePosition: any, value: string}) {....}
//创建组件
createElement(selectMode: string, Options: [], defaultValue: (string | string[])[]) {....}
//定位
adjustPosition(rect: { top: string; left: string; width: string; height: string }) {...}
/**
* @description:复写editor内置方法
* @param {object} rect
* @return {*}
*/
onEnd() {
console.log('endEditing cascader');
}
/**
* @description:复写editor内置方法
* @param {object} rect
* @return {*}
*/
exit() {
this.container.removeChild(this.element);
}
/**
* @description:复写editor内置方法,targetIsOnEditor为false时执行
* @param {object} rect
* @return {*}
*/
getValue() {... }
/**
* @description:复写editor内置方法
*/
setValue(value: (string | string[])[]) {....}
/**
* @description: 每点击一次,都会执行,目的是为了判断当前点击的区域是否是editor范围
* @param {Node} target 被点击的元素
* @return {Boolean}
*/
isEditorElement(target: Node | null) {....}
bindSuccessCallback(successCallback: any) {
this.successCallback = successCallback;
}
/**
* @param {object} rect
* @return {*}
*/
changeValue(value: []) {....}
/**
* @description: 根据field从全量cascaderOptions filter出对应的option
* @param {*} value 进入编辑状态时,输入框中的文本,也是records当中的值
* @param {*} field
* @param {*} cascaderOptions 全量的options
* @return {*}
*/
getCascaderOptions(value: string, field: null | string, cascaderOptions: null | []) {.....}
/**
* @description: 根据文字,返回对应的value
* @param {*} options
* @param {*} searchTexts
* @return {*}
*/
findValuesAndParents(options: [], searchTexts: string) {.....}
isClickPopUp(target: { classList: { contains: (arg0: string) => any }; parentNode: any }) {....}
}
用户通过交互触发编辑态之后,VTable会调用 onStart 方法,我们在OnStart 方法中对React组件进行初始化,使用editorContext 获取单元格的位置,对组件进行定位。onStart 方法如下:
ts
/**
* @description: 复写editor内置方法
* @param {HTMLElement} container
* @param {any} referencePosition
* @param {string} value
* @return {*}
*/
onStart(editorContext:{container: HTMLElement | null, referencePosition: any, value: string}) {
const {container,referencePosition} = editorContext;
this.container = container;
const { selectMode, options } = this.getCascaderOptions(value, this.field, this.cascaderOptions);
const defaultOptions = this.findValuesAndParents(options, value);
this.createElement(selectMode, options, defaultOptions);
setTimeout(() => {
value && this.setValue(value);
(null == referencePosition ? void 0 : referencePosition.rect) && this.adjustPosition(referencePosition.rect);
this.element?.focus();
}, 0);
}
onStart方法首先调用了getCascaderOptions
方法,返回组件的options
和selectMode
。该方法实现如下:
ts
/**
* @description: 根据field从全量cascaderOptions filter出对应的option
* @param {*} value 进入编辑状态时,输入框中的文本,也是records当中的值
* @param {*} field
* @param {*} cascaderOptions 全量的options
* @return {*}
*/
getCascaderOptions(value: string, field: null | string, cascaderOptions: null | []) {
const advancedConfig = cascaderOptions.filter((option) => option.name === field);
const selectMode = advancedConfig[0]?.advancedConfig?.selectMode;
const options = advancedConfig[0]?.advancedConfig?.Cascader;
return { selectMode, options };
}
然后调用findValuesAndParents
方法,返回组件上用户选定的值,findValuesAndParents
方法实现如下:
ts
/**
* @description: 根据文字,返回对应的value
* @param {*} options
* @param {*} searchTexts
* @return {*}
*/
findValuesAndParents(options: [], searchTexts: string) {
const searchLabels = searchTexts?.split(', ').map((text) => text.trim());
const results: any[][] = [];
function search(options, parents: any[]) {
for (const option of options) {
// 记录当前节点的 value 和 parent_id
const currentParents = [...parents, option.value];
// 如果找到匹配的 label,将其 value 和 parent_id 添加到结果中
if (searchLabels?.includes(option.label)) {
results.push(currentParents);
}
// 如果有子节点,递归搜索
if (option?.children && option.children.length > 0) {
search(option.children, currentParents);
}
}
}
search(options, []);
return results;
}
接下来调用 createElement
方法加载组件。
ts
/**
* @description:复写editor内置方法
* @param {string} selectMode
* @param {*} Options
* @param {*} defaultValue
* @return {*}
*/
createElement(selectMode: string, Options: [], defaultValue: (string | string[])[]) {
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.width = '100%';
div.style.padding = '4px';
div.style.boxSizing = 'border-box';
div.style.backgroundColor = '#232324';
this.container?.appendChild(div);
this.root = createRoot(div);
this.root.render(
<Cascader
ref={this.inputRef}
options={Options}
expandTrigger="hover"
onChange={this.changeValue.bind(this)}
mode={selectMode}
defaultValue={defaultValue}
maxTagCount={1}
style={{ border: 'none' }}
bordered={false}
/>
);
this.element = div;
}
此时,react 组件已经显示出来,我们通过setValue
方法更新VTable的值,setValue
实现如下:
ts
/**
* @description:复写editor内置方法
* @param {object} rect
* @return {*}
*/
setValue(value: (string | string[])[]) {
if (this.inputRef.current) {
this.inputRef.current.value = value;
}
}
调用 adjustPosition
方法,调整组件的位置。adjustPosition
方法实现如下:
ts
/**
* @description:复写editor内置方法
* @param {object} rect
* @return {*}
*/
adjustPosition(rect: { top: string; left: string; width: string; height: string }) {
if (this.element) {
(this.element.style.top = rect.top + 'px'),
(this.element.style.left = rect.left + 'px'),
(this.element.style.width = rect.width + 'px'),
(this.element.style.height = rect.height + 'px');
}
}
如果希望VTable自动结束编辑模式,需要提供 isEditorElement
方法,判断鼠标是否点击在组件内。实现如下:
ts
/**
* @description: 每点击一次,都会执行,目的是为了判断当前点击的区域是否是editor范围
* @param {Node} target 被点击的元素
* @return {Boolean}
*/
isEditorElement(target: Node | null) {
// cascader创建时时在cavas后追加一个dom,而popup append在body尾部。不论popup还是dom,都应该被认为是点击到了editor区域
return this.element?.contains(target) || this.isClickPopUp(target);
}
需要更新单元格的值的时候,VTable会调用 getValue
方法,本示例中该方法实现如下:
ts
/**
* @description:复写editor内置方法,targetIsOnEditor为false时执行
* @param {object} rect
* @return {*}
*/
getValue() {
return this.inputRef?.current?.value;
}
注册并使用编辑器
首先引用自定义的编辑器定义。
ts
// 自定义实现的编辑类
import { CascaderEditor, InputNumberEditor, SelectEditor, TextAreaEditor } from '@/client/components/TableEditor';
在使用编辑器前,需要将编辑器实例注册到VTable中,
ts
useEffect(() => {
if (!dataTable?.datasetQueryDataList?.columns || !clickedCellValue?.field) return;
const cascaderEditor = new CascaderEditor({
options: dataTable?.datasetQueryDataList?.columns,
value: clickedCellValue,
});
VTable?.register?.editor('cascader-editor', cascaderEditor);
}, [dataTable?.datasetQueryDataList?.columns, clickedCellValue, VTable]);
在上面的示例中,我们创建了根据接口返回的dataTable?.datasetQueryDataList?.columns
,和当前用户点中的单元格数据clickedCellValue
,设置自定义CascaderEditor的传参,初始化编辑器后注册使用。上述 VTable?.register?.editor('cascader-editor', cascaderEditor);
即是。
接下来需要在 columns
配置中指定使用的编辑器(如果是透视表则在indicators配置editor):
ts
const buildTableColumns = useCallback(
(columns: DatasetColumnSchema[], isView: boolean) => {
const temp = columns.map((colItem) => {
const dataType = colItem?.dataType;
if (dataType === DatasetColumnDataType.Category) {
return {
field: colItem.name,
title: colItem.displayName,
editor: 'cascader-editor',
icon: 'edit',
};
} else if (dataType === DatasetColumnDataType.Int) {
return {
field: colItem.name,
title: colItem.displayName,
editor: 'input-number-editor',
icon: 'edit',
};
} else if (dataType === DatasetColumnDataType.Boolean) {
return {
field: colItem.name,
title: colItem.displayName,
editor: 'list-editor',
icon: 'edit',
};
} else {
return {
field: colItem.name,
title: colItem.displayName,
editor: 'text-editor',
icon: 'edit',
};
}
});
!isView &&
temp.unshift({
field: 'isCheck',
title: '',
width: 30,
headerType: 'checkbox',
cellType: 'checkbox',
});
return temp;
},
[dataTable?.datasetQueryDataList]
);
编辑事件监听
VTable提供了编辑事件监听的功能,你可以监听编辑数据事件,并在事件回调中执行相应的逻辑。
以下是一个编辑事件监听的示例代码:
ts
const tableInstance = new VTable.ListTable(option);
tableInstance.on('change_cell_value', () => {
// 编辑单元格数据
});
编辑后数据获取
当用户完成编辑并提交数据后,你可以获取编辑后的数据以进行后续处理。可以直接取records值
ts
// 获取当前表格的全量数据
tableInstance.records;
完整代码
完整代码:
实现效果
双击单元格,进入到编辑模式,如下图:
一些期待
VTable也提供了 React-VTable组件,后续集成弹出类的React组件的整体方案会在React-VTable中被进一步完善,使得React 组件和VTable的结合更加易用,强大。
表格需求与实践场景征集
本实践场景业务方获得VIsActor提供的精美礼品一份。
我们持续征集表格方面的典型业务场景和案例,当然也包括需求,欢迎大家联系我们。
微信公众号(通过公众号菜单可以加入微信群和飞书群):
今夜无月,期待你点亮星空,感谢Star:
github :github.com/VisActor/VT...
更多参考: