花了半天时间,我开发了一个json-schema-editor-visual

686 阅读8分钟

背景

在公司的项目业务当中,需要基于json-schema实现一个可以可视化编辑器,当前有不错的实现方案,如:json-schema-visual-editor。但事实上,这个交互并不满足于我们需求设计的那样,于是我重新开发了一款json-schema-visual-editor。

然后我将业务当中的实现提取出来,封装成了一个开源的组件,详情可见ew-json-schema-editor-visual

预览效果

如下图所示:

截屏2024-10-27 下午5.12.50.png

使用方式

使用方式非常简单,如下所示:

import schemaEditor from "ew-json-schema-editor-visual"
const [state,setState] = useState([])
const onChange = (value) => {
    console.log(value);
}
render(
    <SchemaEditor value={state} onChange={onChange} />,
  document.getElementById('root')
)

ps: 当然目前只是算是开发了第一版,既然要做成组件,肯定要做的通用化,要暴露很多api,目前的用法比较简单。

目前只是简单完成了初版的工作,后续还需要不停迭代,将组件打造的更精细。

重难点分析

个人理解实现这个editor的难点主要有如下几点:

难点1

难点1主要是在数据转换上,我们知道json-schema的数据结构是一个无限嵌套对象,而我们的table是一个树形数据,因此我们需要写2个方法来实现转换与反转的功能。

为此,我使封装了2个工具方法transformData与reverseTransformData,都是通过递归去修改数据的。我们先来看transformData方法:

export const transformData = (input: InputData[]) => {
  const deepProcessProperties = (
    properties: Property[],
  ): {
    result: OutputProperty;
    requiredFields: string[];
  } => {
    const result = {} as OutputProperty;
    const requiredFields: string[] = [];

    properties.forEach(property => {
      const { title, properties: itemProperties = [], items, ...rest } = property;
      const { is_required, rule, type, description, enum: enumValue } = rest || {};
      const propertyObject = {} as OutputProperty;
      if (type) {
        propertyObject.type = type;
      }
      if (description) {
        propertyObject.description = description;
      }
      if ((Array.isArray(enumValue) && enumValue.length > 0) || isBoolean(enumValue)) {
        propertyObject.enum = enumValue;
      }
      if (type === 'object' && itemProperties) {
        const { result: nestedResult, requiredFields: nestedRequiredFields } = deepProcessProperties(itemProperties);
        propertyObject.properties = nestedResult;
        if (is_required) {
          propertyObject.required = nestedRequiredFields;
        }
      }

      if (rest?.type === 'array' && items!.length > 0) {
        const { result: nestedResult } = deepProcessProperties(items!);
        const nestedResultValue = nestedResult[items![0]?.title!];
        // 目前数组仅支持单一类型
        propertyObject.items = nestedResultValue;
        // 也不需要增加必填,如果是多个类型,就需要加
        // if (is_required) {
        //   propertyObject.required = nestedRequiredFields;
        // }
      }

      if (!isEmpty(rule)) {
        Object.keys(rule).forEach(k => {
          if (rule[k]) {
            propertyObject[k] = rule[k];
          }
        });
      }

      if (is_required) {
        requiredFields.push(title!);
      }
      if (title) {
        result[title] = propertyObject;
      }
    });
    return { result, requiredFields };
  };

  const res = {} as OutputProperty;

  input.forEach(item => {
    const { properties = [], items = [], rule, ...rest } = item;
    const deepKey = getDeepKey(rest?.type!);
    Object.keys(rest).forEach(k => {
      if (rest[k] && !['is_required', 'key'].includes(k)) {
        res[k] = rest[k];
      }
    });
    if (!isEmpty(rule)) {
      Object.keys(rule).forEach(k => {
        if (rule[k]) {
          res[k] = rule[k];
        }
      });
    }
    if (deepKey && (properties.length || items.length)) {
      const { result, requiredFields } = deepProcessProperties(item[deepKey]!);
      res[deepKey] = result;
      res['required'] = requiredFields;
    }
  });

  return res;
};

以上这段代码定义了一个名为 transformData 的函数,主要目的是对输入数据进行转换,并生成一个新的结构化对象。下面将逐步解析这段代码的逻辑和功能。

1. 函数概述

transformData 函数接受一个类型为 InputData[] 的数组作为输入,最终返回一个结构化的对象,类型为 OutputProperty。该函数的内部使用了一个辅助函数 deepProcessProperties,该函数负责递归处理属性。

2. 深度处理属性

deepProcessProperties 函数是整个转换过程的核心。它接受一个 Property 类型的数组,并返回一个对象,包含两个属性:

  • result: 处理后的输出属性。
  • requiredFields: 一个字符串数组,包含所有必填字段的标题。

2.1 处理属性

deepProcessProperties 内部,首先初始化一个空对象 result 和一个空数组 requiredFields。接下来,针对每个属性进行处理:

  • 从属性中解构出 titleproperties(子属性)、items(数组项)以及其他剩余属性(如 is_requiredruletypedescription 和 enum)。

  • 根据属性类型进行分类处理:

    • 对象类型: 如果属性的 type 为 object,则递归调用 deepProcessProperties 处理其子属性。
    • 数组类型: 如果属性的 type 为 array,则处理数组项,当前实现支持单一类型数组。
    • 规则: 如果存在规则,则将其附加到属性对象中。
    • 必填字段: 如果属性被标记为必填,则将其标题添加到 requiredFields 中。

2.2 结果构建

最后,如果属性存在标题,将其与处理后的属性对象一起存储到 result 中。

3. 主函数逻辑

transformData 函数中,首先初始化一个空的结果对象 res。接着对输入数据进行迭代处理:

  • 对于每个输入项,解构出 propertiesitemsrule 等信息,并确定处理的深层键(deepKey)。
  • 将 rest 中的属性复制到结果对象中,但排除 is_required 和 key
  • 处理规则信息,将符合条件的规则添加到结果对象中。
  • 如果存在深层属性(deepKey)并且有属性或项,则调用 deepProcessProperties 进行处理,并将结果存储在 res 中。

4. 返回结果

最后,函数返回处理后的结果对象 res,它包含了结构化的属性和必填字段信息。

5. 总结

整个函数的设计旨在高效地将复杂的输入数据结构转换为更易于使用的输出格式。通过递归处理嵌套属性,transformData 函数能够灵活地处理不同类型的属性,并确保在最终结果中包含必要的验证信息。这种模式在处理表单数据、API响应或任何层次化数据时非常有用。

接下来我们来看反转函数的代码:

export const reverseTransformData = (output: OutputProperty) => {
  const deepKey = getDeepKey(output.type!);

  const convertProperties = (properties: OutputProperty): Partial<OutputProperty> => {
    return Object.entries<OutputProperty>(properties).map(([key, prop], index) => {
      if (!prop) {
        return {};
      }
      const {
        type,
        description,
        enum: enumValue,
        properties,
        items,
        ...rest
      } = prop || {};
      const res: OutputProperty = {
        title: key,
        key: `schema-1-${index + 1}`,
        is_required: required!.includes(key),
        type,
        description,
        enum: enumValue,
        rule: {
          ...rest,
        },
      };
      if (properties) {
        res['properties'] = convertProperties(properties);
      }
      if (items) {
        res['items'] = convertProperties(items);
      }
      return res;
    });
  };

  const { title, type, description, required, ...rest } = output;

  const converted: Partial<InputData> & { key?: string } = {
    title,
    type,
    description,
    key: 'schema-1',
  };

  if (deepKey) {
    converted[deepKey] = output[deepKey] ? (convertProperties(output[deepKey]!)) as any : [];
  }
  if (rest) {
    converted.rule = {};
    Object.keys(rest).forEach(k => {
      if (rest[k] && !['properties', 'items'].includes(k)) {
        converted.rule![k] = rest[k];
      }
    });
  }
  return [converted] as InputData[];
};

以上这段代码定义了一个名为 reverseTransformData 的函数,其主要功能是将一个结构化的输出对象(OutputProperty)转换回输入格式(InputData)。此函数通常用于将经过处理或验证后的数据还原为原始格式,以便进一步使用或存储。以下是对这段代码的逐步解析。

1. 函数概述

reverseTransformData 函数接受一个类型为 OutputProperty 的对象,并返回一个数组,其中包含转换后的 InputData 类型的对象。这种转换可以在数据流动的过程中,帮助在不同格式之间进行适配。

2. 获取深层键

在函数开始时,通过调用 getDeepKey 函数来获取输出对象的深层键(deepKey),这通常用于指示当前处理的属性类型(如 propertiesitems)。

3. 属性转换

接下来,定义了一个内部函数 convertProperties,该函数负责将 OutputProperty 中的属性转换为一个部分 OutputProperty 对象。它的工作流程如下:

  • 使用 Object.entries 遍历输入的属性对象,将每个属性的键值对转为数组。
  • 对于每个属性,解构出其相关信息,如 typedescriptionenumpropertiesitems 及其他剩余的属性(rest)。
  • 创建一个新的属性对象 res,包括属性的标题、唯一键、必填状态和其他类型信息。
  • 如果属性中包含子属性(properties)或数组项(items),则递归调用 convertProperties 进行处理,确保所有层级的属性都能被正确转换。

4. 转换结果构建

在处理完所有属性后,convertProperties 返回一个数组,代表所有转换后的属性。

5. 主函数逻辑

reverseTransformData 的主体中,首先解构出输出对象的基本信息,包括 titletypedescriptionrequired 字段。接着,构建一个新的对象 converted,用于存放转换后的数据:

  • 初始化 converted 对象,包括基本的 titletype 和 description,并赋予一个默认的键 key

接下来,根据 deepKey 的存在情况,将输出对象的深层属性转换并存储在 converted 中。如果有其他剩余的规则信息(rest),则将这些信息整理到 converted.rule 中,但排除 propertiesitems 这两个键。

6. 返回结果

最后,函数返回一个包含 converted 对象的数组,确保转换后的结果符合 InputData 的格式要求。

7. 总结

整体来看,reverseTransformData 函数通过递归和解构的方式,灵活地将复杂的输出对象转换回易于使用的输入格式。它实现了从结构化数据到原始数据格式的反向转换,确保在数据交互过程中能够高效且准确地处理不同的数据结构。这种方法对于表单数据、API响应以及其他嵌套数据结构的转换非常有用。

特别说明:这里为了实现响应式的数据,我还使用了valtio这个状态管理库。

难点2

难点2主要在于理解json-schema的数据结构,并且基于数据结构实现一些规则,例如对象和数组是不需要规则的,还有就是可以允许添加一些字段,例如默认值,传入方法等等。

只要搞懂以上2个难点,那么接下来就是基于ui组件库一些表单组件的封装,这些都是很基础的代码,所以也不需要详解。

未来还需要加入的更多功能

  • 多语言
  • 数据结构配置化
  • 单元测试
  • 算法优化
  • api设计
  • 抽离ui组件库,使其不仅支持antd,还支持其它ui组件库
  • 抽离出valtio库
  • 更多功能

也期待大家一起来贡献,将这个组件的功能打造的更完美,如果觉得有用,点赞收藏不迷路,觉得没用可以忽略。