React “动态”表单设计(一)

March 23, 2024
project

React “动态”表单设计(一)

在本节中,我会介绍动态表单的定义,实现一个这样的表单会遇到的问题,和给出它的基础数据结构和生成函数。 在下一节中,我会更详细的介绍,这个表单中字段的值的存储方式。和如何实现这些功能和组件

动态表单是什么

在常见B/S架构的项目下,我们有的时候会遇到,需要根据不同用户的权限和需求,为他们提供包含不同字段的表单。

例如,一个工单系统,我们假设有三个角色。可以接受工单的人(A和B),审核工单进度的人(C)。对于A,他可能需要填写工单处理的开始和结束时间,确认什么时候可以处理工单。如果A接受了工单,但发现在约定时间无法处理完成,他可以将该工单分享给B。那么B看到的工单内容,应该是不可以编辑,并且可以选择接受或不接受。 当A B完成了这个工单后,C应该收到工单完成审核。C可以看到工单的所有信息,并且选择是否关闭这个工单。

对于上述业务逻辑,我们可以看到一个表单的大部分内容被复用了3次(工单号,处理人等字段),而对于A,B,C他们又有一些特别的字段和操作,并且他们这些区别的可以见性控制完全来自于Server端下发的数据。那么对于这种场景我们是否可以使用被复用的字段和每个人的特殊操作来组成最终展现的表单么?动态表单就可以解决这个问题。

当然对于上面这段简单描述来讲,直觉上我们可能会偏向于下面这种方案:

typescript
const userInfo = getUserInfo();
const {isResolverSelf, isJudger} = userInfo;
const renderForm = () => {
	if(isResolverSelf){
		return <FullEditableForm/>;
	}
	if(isJudger){
		return <CloseActionForm />;
	}
	if(!isResolverSelf){
		return <ReciveSharedForm />;
	}
}
return renderForm();

但随着角色的增加和对权限精细管控的需求,renderForm会快速膨胀导致维护成本的提高。 所以,当你的系统会出现可以预见的需求范围的扩展,请考虑使用动态表单这样的实现方式。

要解决的问题

为了实现这样的表单,我们需要处理以下问题:

  • 提供表单中可能出现的组件(可控组件),并统一组件的onChange,value,label等props。
  • 设计状态管理中,数据的存储方式。方便组件间进行交互,联动和最终的表单提交。
  • 设计一个UI渲染引擎,能通过接收配置文件的方式,正确渲染UI。
  • 提供统一Actions,包括onChange onInValid onSubmit onCancel等Form中常见的Actions。
  • (可选)当Form的State受Form外的操作出现频繁变更时的性能优化。

基础知识

阅读下面的内容时,需要的基础知识:

  • typescript
  • React
  • React的常见hooks,如useContext useReducer useState 接下来内容中出现的代码,将会使用到它们。

概要模块设计

对于这样动态表单系统,大致由这4个模块来组成:

  1. UI 渲染引擎
  2. 表单字段组件。
  3. 状态管理
  4. 表单操作函数 接下来,我们来逐一了解如何设计这三个模块。

UI render

对于UI渲染引擎,我们一般有两种数据结构可以选择。 一是一维的数据结构,如:

typescript
enum ComponentTypeEnum {
  text = 0,
  select = 1,
  checkbox = 2,
  container = 3
}

interface FormFieldUIProps {
	value: string | number | boolean;
	label: string | ReactNode;
  componentType: ComponentTypeEnum;
	name: string; // unique 
	parentField: string; // it will save other field name
  // The componentsProps should from field component. e.x the select component should has options;
  required?: boolean;
  componentsProps: Props 
};
type FormUIStruct = FormFieldUIProps[];

另一个是嵌套的数据结构,如:

typescript
interface FormFieldUIProps {
	value: string | number | boolean;
	label: string | ReactNode;
  componentType: ComponentTypeEnum;
	name: string;
  required?: boolean;
  components: Props;
	children: FormFieldUIProps[];
};
type FormUIStruct = FormFieldUIProps[];

这里我们先对比下这两种数据结构特性:

一维数据结构嵌套数据结构
渲染时需要转换为嵌套结构渲染可以直接渲染
查找表单项时直接通过name进行查找需要使用DFS进行查找
可读性和DOMTree完全不同,很难看出从属关系和DOMTree中的从属关系高度近似
复用性需要考虑name的唯一性多层嵌套存在天然的隔离,name不需要唯一
这两种数据结构没有明显的优劣,它们在不同的场景有各自的用处。 比如,根据一维数据结构的特点,如果我们的表单经常出现对于表单项的CRDU操作,那么它的检索速度快这个特点就很适合这个场景。 如果我们的表单配置需要出现大量的作为children复用,那么嵌套结构很适合这个场景。 所以,基于上述情况,我们应该结合具体的使用场景,来选择要使用的数据结构。

PS:为了更直观的描述这个系统的构成,笔者将使用嵌套数据结构做为UI渲染的数据结构。

State management

PS:为了更好的表述系统的设计,笔者将尽量减少引入三方依赖,因此,状态管理将使用React的Context和hooks来实现。 如上所述,状态管理需要收集表单项的值的变更,并且通知和其关联的组件做出更新。在表单完成填写后,能支持提交和取消更改的操作。 基于此,我们需要如下的代码:

typescript
interface FieldValueCollection {
	[key in string]: FormFieldUIProps['value'];
};
interface FieldActionsCollection {
	submit: (fieldValues: FieldValueCollection) => void;
	reset: () => void;
	handleValueChange: (cachePath: string, value: FormFieldUIProps['value']) => void;
}

Action Design

typescript
type validForm = () => {
  [key in keyof FieldValueCollection]: message;
} | undefined
type submitForm = () => void;
type resetForm = () => void;
// we want change run some actions when the value updated in path
type getValueByPath = (path: keyof FieldValueCollection) => FormFieldUIProps['value'];

使用DSL描述表单

现在我定义了描述表单的数据结构。现在我们将这个数据结构做为DSL解释器函数的输入来生成一个完整的表单,这个函数实现如下:

typescript
const renderForm = (conf: FormUIStruct) => {
  const travelConf = (conf?: FormUIStruct, combinePath: string = '', listIndex: number =NaN) => {
    conf?.forEach((item, index) => {
      if (!item) {
        return null;
      }
      const { children, componentType, name } = item;
      if (componentType === ComponentTypeEnum.container) {
        const newCombinePath = isNaN(listIndex) ? `${combinePath}.${name}` : `${combinePath}.${listIndex}.${name}`;
        return (
          <FieldContainer key= { combinePath } path = { combinePath }>
            { travelConf(children, newCombinePath)}
          </FieldContainer>
        )
      }else if (componentType === ComponentTypeEnum.list){
        const newCombinePath = `${ combinePath }.${ name } `;
        return (
          <FieldListContainer key= { combinePath } path = { combinePath } >
            { travelConf(children, newCombinePath, index)}
          </FieldListContainer>
        )
      }
      else{
        const newCombinePath = `${ combinePath }.${ name } `;
        return (
          <FieldComponent { ...item } key = { combinePath } path = { newCombinePath } />
        )
      }
    })
  }
  return travelConf(conf);
}