注册
web

前端简洁表单模型

gabriel-ramos-azbe3hSHNHU-unsplash.jpg


大家好,我是前端菜鸡木子


今天想和大家浅谈下前端表单的简洁模型。说起表单大家一定都不陌生,因为各自团队内部一定充斥着各种或简单或复杂的表单场景。为了解决表单开发问题,市面上也有着许多优秀的表单解决方案,例如:Formily、Ant Design、FormRender 等。这些框架的底层都维护着一套基础的「表单模型」,虽然框架不同,但是「表单模型」的设计却是基本一致,只是上层应用层的设计会随着业务的需求进行调整。今天的主题也会围绕着「表单模型」进行展开


前言


本文是偏基础层面的介绍,不会涉及到太多框架的源码解析。另外,我会以最近如日中天的 Formily 为例进行讲解,大家如果对 Formily 不太了解,可以先去了解和使用。


表单模型的基础概念


我们知道一个表单包含了 N 多个字段,每个字段都需要用户输入或者联动带出,当用户输入完成之后我们可以通过 Form.Values 的形式直接获取到表单内部 N 多个字段的值,那么这是如何实现的呢?


我们通过一张图来简单阐述下:


yuque_diagram.png


其中:



  • Form:是通过 JS 维护的一个表单模型实例,Formily 里 createForm 返回的就是这个实例,它负责维护表单的所有数据和每个字段 Field 的实例
  • Field: 是通过 JS 维护的每一个字段的实例,它负责维护当前字段的所有数据和状态
  • Component: 是每个字段对应的展示层组件,可以是 Input 或者 Select,也可以是其它的自定义组件

从图中不难看出,每个 Field 都对应着一个展示层的 Component,当用户在 Component 层输入时,会触发 props.onChange 事件,然后在事件内部将用户输入的值传入到 Field 里。同时当 Field 值变化时 (比如初始化时的默认值,或者通过 field.setValue 修改字段的值 ),又会将 Field.value 通过 props.value 的形式传入到 Component 内部,以此来达到 Componet 和 Field 的数据联动。


我们可以看下在 Formily 内部是如何实现的(已对源码进行一些优化和注释):


const renderComponent = () => {
// 获取 Field 的 value
const value = !isVoidField(field) ? field.value : undefined;

// 设置 onChange 事件
const onChange = !isVoidField(field)
? (...args: any[]) => {
field.onInput(...args)
field.componentProps?.onChange?.(...args)
}
: field.componentProps?.onChange

// 生成 Field 对应的 Component
return React.createElement(
getComponent(field.componentType),
{
value,
onChange,
},
content
)
}

这里面的 onChange 事件里触发了 field.onInput 的事件,在 field.onInput 内会做两件事情:



  • 将 onChange 携带的 value 赋值给 field.value
  • 将 onChange 携带的 value 赋值给 form.values

这里需要额外说明的是,一个 Form 会通过「路径」系统聚合多个 Field,每个 Field.value 也是通过路径系统被聚合到 Form.values 下。


我们通过一个简单的 demo 来介绍下路径的概念:


const formValues = {
key1: {
key2: 'value',
}
};

我们通过 key1.key2 可以找到一个具体的值,这个 key1.key2 就是一个路径。在 Formily 内维护了一个高级的路径模块,感兴趣的可以去看下 form-path


表单模型的响应式


聊完表单模型的基础概念后,我们知道



  • Component 组件通过 props.onChange 将用户的数据回传到 Field 和 Form 实例内
  • Field 实例内的 value 会通过 props.value 形式传递到 Component 组件内

那么问题来了,Field 实例内部的 value 改变后,Component 组件是如何做到细粒度的重新渲染呢?


不卖关子,直接公布答案:



  • formily: 通过 formily/reactive 进行响应式跟踪,能知道具体是哪个组件依赖了 Field.value, 并做到精准刷新
  • Antd:通过 rc-field-form/useForm 这个 hook 来实现,本质上是通过 const [, forceUpdate] = React.useState({}); 来实现的

虽然这两种方法都能实现响应式,但是 Ant 的方式比较暴力,当其中一个 Field.value 发生改变时,整个表单组件都需要 render 。而 Formily 能通过 formily/reacitve 追踪到具体改变的 Field 对应的 Componet 组件,只让这个组件进行 render



formily/reactive 实现比较复杂,这边不会深入探讨具体实现方式,感兴趣的小伙伴可以看下这篇文章 从零开始撸一个「响应式」框架 (本质上是通过 Proxy 来拦截 get 和 set,从而实现依赖追踪)



接下来,我们就看下如何借助 formily/reactive 来实现响应式


第一步:我们需要在 Field 初始化时将 value 变成响应式:


import { define, observable } from '@formily/reactive'

class Field {
constructor(props) {
// 初始化 value 值
this.value = props.value;

// 将 this.value 变成响应式
define(this, {
value: observable.computed
})
}
}

第二步:对 Field 对应的 Componet 进行下 "包装":


import { observer } from '@formily/reactive-react'

const ReactiveComponentInernal = () => {
// renderComponent 源码在 「基础概念」章节里
return renderComponent();
}

export const FieldComponent = observer(ReactiveComponentInernal);


observer 内部也和 rc-field-form/useForm 类似,通过 const [, forceUpdate] = React.useState({}); 来实现依赖改变时,子组件级别的动态 render



到此为止,表单模型的响应式也基本完成了


表单模型的规范


有了以上的表单模型,我们就可以构建一个简单的表单框架。但是真实的业务场景却不可能这么简单,迎面而来的第一个问题就是「联动」,举个例子:


QQ20230729-134607-HD.gif


需求:当城市名称改变后,城市编码字段需要联动带出对应的值。我们可以快速想到两种方案:



  • 方案1:在 城市名称 字段的 onChange 事件里通过 form.values.cityCode = hz 的形式去动态修改 城市编码 字段。
  • 方案2:在 城市编码 字段里显示的配置对 城市名称 字段的依赖,同时需要配置依赖改变时的处理逻辑,例如:

const formSchema = {
cityName: {
'x-component': 'Select',
},
cityCode: {
'x-component': 'Input',
'x-reactions': {
dependencies: ['cityName'],
fulfill: {
state: {
value: '{{ $deps[0]?.value }}',
},
},
},
},
};

无论方案 1 还是方案 2 都能实现需求,但是两个方案各有缺点


方案 1 有两个问题:



  • 问题一:打破了【表单模型的基础概念】,cityName 对应的组件的 onChange 事件里「直接」对 cityName 和 cityCode 字段进行了修改。
  • 问题二:我们不能「直观」的看到 cityCode对 cityName 字段产生了依赖,只有在看具体代码时才能知道

方案 2 也会有两个问题:



  • 问题一:schema 本身的可读性不强,且使用 formily schema 时,配置内容比较多
  • 问题二:使用 schema 配置 x-component-props 时不能使用 ts 特性

当表单逐渐复杂起来的时候,方案 1 的弊端会逐步显现出来,字段间会产生诸多的 「幽灵」依赖和控制,导致后续迭代的时候根本无从下手。所以在我自己的团队内部,我们规定出了几条「表单模型」的使用规范:




  • 规范 1: 每个 Field 对应的 Component 只对自己的字段负责,不允许通过 Form api 直接修改其他字段
  • 规范 2: 在 formSchema 里需要维护表单的所有字段配置和依赖,字段间不允许出现「幽灵」依赖
  • 规范 3: 尽量不要使用 form.setValues、form.queryField('xxx').setValue 等动态修改字段值的 Form api(特殊场景除外)
  • 规范 4: 表单涉及到的所有字段都尽量存储到表单模型中,不要使用外部变量来保存


这些规范其实是个普适性的范式,无论你在使用 Formily 也好,还是 Ant Design 也好,都需要去遵守。规范 2 里我用了 Formily 的 schema 来说明,但如果你使用的是 Ant Design,可以把 formSchema 理解为 <Form.Item reaction={{ xxx }}></Form.Item>



其实 formily 的 schema 最终会通过 RecursionField 组件递归渲染成具体的 FormItem 形式



表单模型的应用层


有了上述的「表单模型」概念和规范之后,我们就可以来构建表单模型的应用层了


yuque_diagram(1).png



  • Form Scheam: 整个表单的配置中心,负责表单各个字段的配置和联动 、校验等,它只负责定义不负责实现。它可以是个 Json Schema,也可以是 Ant Design 的 <FormItem>
  • Form Component: 表单内每个字段的 UI 层组件,可以再分为:基础组件 和 业务组件,每个组件都只负责和自己对应的 Field 字段交互
  • 业务逻辑:将复杂业务抽象出来的业务逻辑层,纯 JS 层。当然这一层是虚拟的概念,它可以存在于 Form Componet 里,也可以放在入口的 Index Component 内。如果业务复杂, 也可以放到 hooks 里或者单独的 JS 模块内部

有了应用层架构后,在写具体表单页面时,我们需要在脑海中清晰的勾勒出每层(Schema Component Logic)的设计。当页面足够简单时,也许会没有 Logic 层,Component 层也可以直接使用自带的基础表单组件,但是在设计层面我们不能混淆


表单模型的实践 - Formily


从去年开始,我们团队便引入 formily 作为中后台表单解决方案。在不断的实践过程中,我们逐步形成了一套自己的开发范式。主要有以下几个方面


Formily 的取舍


我们借助了 formily 的以下几个能力:



  • formily/reactive: 通过 reacitve 响应式框架来构建业务侧的数据模型
  • formily/schema: 通过 json-schema 配置来描述整个表单字段的属性,当然其背后还携带着 formily 关于 schema 的解析、渲染能力
  • formily/antd: 一些高级组件

同时,我们也在尽量避免使用 formily 的一些灵活 API:



  • Form 相关 API:比如 useForm、form.setValues 等,我们不希望在任何组件内部都能「方便」的窜改整个表单的所有字段值,如果当前字段对 XX 字段有依赖或者影响,你应该在 schema 里显示的声明出来,而不是偷偷摸摸的修改。
  • Query 相关 API: 比如 form.query('field'),原因同上

当然,这不代表我们绝不会使用这些 API ,比如在表单初始化时需要回填信息的场景,我们就会用到 form.setValues 。我想说明的是不能滥用!!!


静态化的 schema


我们认为 schema 和普通的 JSX 相差不大,只不过前者是通过 JSON 标准语言来表述而已,举个例子:


// chema 形式
const formSchema = {
name: {
type: 'string',
'x-decorate': 'FormItem',
'x-component': 'Input',
'x-component-props': {
placeholder: '请输入名称'
}
}
}

// jsx 形式
const Form = () => {
return (
<Form>
<FormItem name"name">
<Input placeholder="请输入名称" />
</FormItem>
</Form>

)
}

schema 最终也会被 formily/react 解析成 jsx 格式。那为什么我们推荐使用 schema 呢?



  • 原因一:schema 可以做到足够的静态化,避免我们做一些灵活的动态操作 (在 jsx 里我们几乎能做通过 form 实例动态的做任何事情)
  • 原因二: schema 更容易被解析和生成,为之后的智能化生成做铺垫(不一定是低代码)

表单模型的挑战


在真实业务开发过程中,我们对表单模型的使用会出现一些问题,以两个常见的问题为例:



  • 问题 1:我们是通过表单的 UI 结构来设计 schema 还是通过表单数据结构来设计?
  • 问题 2:有时候为了简单,我们会设计出一个巨大的 Component,这个 Componet 对应的 Field 嵌套了很多层字段

下面这个案例就可能触发上述的两个问题:


demo2.gif


其中,每个分类都对应着一组商品,所以最终表单的数据格式应该是这样的:


{
categoryList: [
{
categoryName: '分类一',
productList: [{ productName: '商品一', others: 'xxx' }],
},
{
categoryName: '分类二',
productList: [{ productName: '商品二', others: 'xxx' }],
}
],
}

我们提供两种思路来设计这个表单


方案一


我们发现简单的通过 ArrayTable 是实现不出这种交互的,所以我们直接设计出一个大而全的 Component,那么我们的实现方式应该是这样的:


// 设计一个大而全组件,过滤组件内部实现
const BigComponent = (props) => {
return (
<Row>
<CategoryArrayTable />
<ProductArrayTable />
</Row>

)
};

// schema 设计
const formSchema = {
categoryList: {
type: 'array',
'x-component': BigComponent,
}
}

在这种方案里,BigComponent 组件需要 onChange 整个表单的值(多层嵌套的对象数组),这会出现一个问题:formSchema 里看不到表单的所有字段配置,如果字段间需要有联动,那么只能在 BigComponent 组件内部去实现(违反了规范2)。


方案二


我们认为 schema 是面对表单数据结构设计的,Component 是面对 UI 设计的,两者的设计思路是分开的(但是在大多数场景下两者的设计结果是一致的)
那么我们的实现方式应该是这样的:


// 基于 formily/antd/ArrayTable + formily/react RecursionField 来实现
const CategoryArrayTable = (props) => {
return (
<Row>
<ArrayTableWithoutProductList />
<ArrayTableWithProductList />
</Row>

)
};

// schema 设计
const formSchema = {
categoryList: {
type: 'array',
'x-component': CategoryArrayTable,
items: {
categoryName: {
type: 'string',
'x-component': 'Select',
},
productList: {
type: 'array,
'
x-component': 'ArrayTable',
items: {
productName: {
type: '
string',
'
x-component': 'Select',
},
others: {},
}
}
},
}
}

在这种方案的 schema 里能够直接反映出表单的所有字段配置,一目了然,而且真实的代码实现会比方案一简洁很多


但是呢,这个方案有个难点,需要开发者对 formily 的渲染机制,主要是 RecursionField 和 ArrayTable 的源码有一定程度的了解。


当然,还有很多其他的方案可以实现这个需求,这边只是拿出两个方案来对比下设计思路上的差异,虽然最终的方案取舍是根据团队内部协商 + 规范而定的,但是在我自己的团队里,我们一直保持着一种设计准则:



schema 是面对表单结构的,Component 是面对 UI 的



后续


在实践过程中,我们发现了一些待优化点:


1、我们发现对于复杂的表单页面,schema 的配置会非常冗长,如果 schema 足够静态化的话,我们是否可以简化对 schema 的编写,同时能提高 schema 的可读性呢?低代码平台是个方案,但是太重,是否可以考虑弄个 vsocde 插件类接管 schema ?


2、如果表单配置、表单子组件、业务逻辑都由 schema、Component、Logic Fucntion 来负责了,我们是否可以取消表单页面的入口组件 index.tsx 呢?


当然随着对表单的不断深入研究,还有很多其他问题可以优化和解决

作者:木与子
来源:juejin.cn/post/7261262567304921146
,这边就不一一列举了

0 个评论

要回复文章请先登录或注册