前端简洁表单模型
大家好,我是前端菜鸡木子
今天想和大家浅谈下前端表单的简洁模型。说起表单大家一定都不陌生,因为各自团队内部一定充斥着各种或简单或复杂的表单场景。为了解决表单开发问题,市面上也有着许多优秀的表单解决方案,例如:Formily、Ant Design、FormRender 等。这些框架的底层都维护着一套基础的「表单模型」,虽然框架不同,但是「表单模型」的设计却是基本一致,只是上层应用层的设计会随着业务的需求进行调整。今天的主题也会围绕着「表单模型」进行展开
前言
本文是偏基础层面的介绍,不会涉及到太多框架的源码解析。另外,我会以最近如日中天的 Formily
为例进行讲解,大家如果对 Formily
不太了解,可以先去了解和使用。
表单模型的基础概念
我们知道一个表单包含了 N 多个字段,每个字段都需要用户输入或者联动带出,当用户输入完成之后我们可以通过 Form.Values
的形式直接获取到表单内部 N 多个字段的值,那么这是如何实现的呢?
我们通过一张图来简单阐述下:
其中:
- 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
到此为止,表单模型的响应式也基本完成了
表单模型的规范
有了以上的表单模型,我们就可以构建一个简单的表单框架。但是真实的业务场景却不可能这么简单,迎面而来的第一个问题就是「联动」,举个例子:
需求:当城市名称改变后,城市编码字段需要联动带出对应的值。我们可以快速想到两种方案:
- 方案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 形式
表单模型的应用层
有了上述的「表单模型」概念和规范之后,我们就可以来构建表单模型的应用层了
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
嵌套了很多层字段
下面这个案例就可能触发上述的两个问题:
其中,每个分类都对应着一组商品,所以最终表单的数据格式应该是这样的:
{
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