某评测系统基于 VTable +React 自定义表格编辑器实践

本文内容基于@VisActor/VTable 用户访谈整理加工。

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


业务场景介绍

在传统的评测系统中,多位评审员通常需要在飞书表格上共同完成同一数据集的标注工作。这一过程涉及到多用户编辑同一文档,并将标注完成的数据上传到评测平台。然而,这种做法存在一定的弊端:首先,原始数据并未在平台上得到有效积累,导致数据集的构建无法形成一个完整闭环;其次,用户需要手动上传标注后的数据,这样的操作流程不仅效率低下,而且用户体验也远非理想。

为了解决这些问题,提升用户的标注效率,并减少对线下飞书表格的依赖,我们的平台采纳了 VTable 可视化编辑方案。该方案允许用户直接在我们系统中的table表单中进行编辑,从而可以实现数据的直接存储,历史记录留存等功能。

通过VTable的编辑器接口以及相关的事件监听,可以很容易的集成HTML或者React、Vue组件来扩展更多的编辑能力,本文通过实例来展示一个可以一般化的解决方案。

VTable 简介

VTable 是字节跳动推出的VisActor开源可视化解决方案中的重要组成部分------高性能表格组件。以超高的性能和丰富的可视化能力著称。详情参见:

  1. 官网:www.visactor.io/vtable
  2. github:github.com/VisActor/VT...

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方法,返回组件的optionsselectMode。该方法实现如下:

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;

完整代码

完整代码:

visactor.io/vtable/demo...

实现效果

双击单元格,进入到编辑模式,如下图:

一些期待

VTable也提供了 React-VTable组件,后续集成弹出类的React组件的整体方案会在React-VTable中被进一步完善,使得React 组件和VTable的结合更加易用,强大。

表格需求与实践场景征集

本实践场景业务方获得VIsActor提供的精美礼品一份。

我们持续征集表格方面的典型业务场景和案例,当然也包括需求,欢迎大家联系我们。

项目官网:www.visactor.io/vtable

微信公众号(通过公众号菜单可以加入微信群和飞书群):

今夜无月,期待你点亮星空,感谢Star:

githubgithub.com/VisActor/VT...

更多参考:

  1. VTable------不只是高性能的多维数据分析表格,开源,免费,百万数据秒级渲染
  2. 基于 VTable 的多维数据展示的原理与实践 - 掘金
  3. 基于Canvas 实现高性能多维分析表格原理剖析 - 掘金
  4. VisActor------面向叙事的智能可视化解决方案 - 掘金
  5. 火山引擎DataWind产品可视化能力揭秘 - 掘金
  6. 更多 VTable 示例
相关推荐
加班是不可能的,除非双倍日工资1 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi2 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip2 小时前
vite和webpack打包结构控制
前端·javascript
excel3 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国3 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼3 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy3 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT3 小时前
promise & async await总结
前端
Jerry说前后端3 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化
画个太阳作晴天4 小时前
A12预装app
linux·服务器·前端