某评测系统基于 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 示例
相关推荐
GIS程序媛—椰子32 分钟前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_00138 分钟前
前端八股文(一)HTML 持续更新中。。。
前端·html
ZL不懂前端41 分钟前
Content Security Policy (CSP)
前端·javascript·面试
木舟10091 小时前
ffmpeg重复回听音频流,时长叠加问题
前端
王大锤43911 小时前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
我血条子呢1 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js
黎金安1 小时前
前端第二次作业
前端·css·css3
啦啦右一1 小时前
前端 | MYTED单篇TED词汇学习功能优化
前端·学习
半开半落1 小时前
nuxt3安装pinia报错500[vite-node] [ERR_LOAD_URL]问题解决
前端·javascript·vue.js·nuxt