在本节中,我会介绍动态表单的定义,实现一个这样的表单会遇到的问题,和给出它的基础数据结构和生成函数。 在下一节中,我会更详细的介绍,这个表单中字段的值的存储方式。和如何实现这些功能和组件
在常见B/S架构的项目下,我们有的时候会遇到,需要根据不同用户的权限和需求,为他们提供包含不同字段的表单。
例如,一个工单系统,我们假设有三个角色。可以接受工单的人(A和B),审核工单进度的人(C)。对于A,他可能需要填写工单处理的开始和结束时间,确认什么时候可以处理工单。如果A接受了工单,但发现在约定时间无法处理完成,他可以将该工单分享给B。那么B看到的工单内容,应该是不可以编辑,并且可以选择接受或不接受。 当A B完成了这个工单后,C应该收到工单完成审核。C可以看到工单的所有信息,并且选择是否关闭这个工单。
对于上述业务逻辑,我们可以看到一个表单的大部分内容被复用了3次(工单号,处理人等字段),而对于A,B,C他们又有一些特别的字段和操作,并且他们这些区别的可以见性控制完全来自于Server端下发的数据。那么对于这种场景我们是否可以使用被复用的字段和每个人的特殊操作来组成最终展现的表单么?动态表单就可以解决这个问题。
当然对于上面这段简单描述来讲,直觉上我们可能会偏向于下面这种方案:
typescriptconst userInfo = getUserInfo(); const {isResolverSelf, isJudger} = userInfo; const renderForm = () => { if(isResolverSelf){ return <FullEditableForm/>; } if(isJudger){ return <CloseActionForm />; } if(!isResolverSelf){ return <ReciveSharedForm />; } } return renderForm();
但随着角色的增加和对权限精细管控的需求,renderForm会快速膨胀导致维护成本的提高。
所以,当你的系统会出现可以预见的需求范围的扩展,请考虑使用动态表单这样的实现方式。
为了实现这样的表单,我们需要处理以下问题:
阅读下面的内容时,需要的基础知识:
对于这样动态表单系统,大致由这4个模块来组成:
对于UI渲染引擎,我们一般有两种数据结构可以选择。 一是一维的数据结构,如:
typescriptenum 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[];
另一个是嵌套的数据结构,如:
typescriptinterface 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不需要唯一 |
PS:为了更直观的描述这个系统的构成,笔者将使用嵌套数据结构做为UI渲染的数据结构。
PS:为了更好的表述系统的设计,笔者将尽量减少引入三方依赖,因此,状态管理将使用React的Context和hooks来实现。 如上所述,状态管理需要收集表单项的值的变更,并且通知和其关联的组件做出更新。在表单完成填写后,能支持提交和取消更改的操作。 基于此,我们需要如下的代码:
typescriptinterface FieldValueCollection { [key in string]: FormFieldUIProps['value']; }; interface FieldActionsCollection { submit: (fieldValues: FieldValueCollection) => void; reset: () => void; handleValueChange: (cachePath: string, value: FormFieldUIProps['value']) => void; }
typescripttype 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解释器函数的输入来生成一个完整的表单,这个函数实现如下:
typescriptconst 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); }