Form使用场景?
前端开发中,经常通过输入组件,去获取用户的输入内容。每新增一个输入组件的时就要在页面中增加一个新的状态来记录用户输入的值。 但是如果为每一个控件都声明一遍 value
和 onChange
,或者是通过 useReducer
的方式来实现,但都不可避免的会增加代码的复杂程度。
此时,就需要 Form
组件来统一管理整个表单的状态。每一个输入组件通过 Form
+ Form.Item
组件嵌套的形式,把状态托管给了 FormInstance
(组件内部的状态实例)来维护。
Form 方案:
这里参照了抱枕老师的 🍓中台表单技术选型实践(表单实践) 这篇文章内容。首先来总体介绍一下目前主流的 Form
方案,然后分析其中的优缺点。 然后会针对其中的发布订阅模式进行一个基本实现。
高阶组件(Antd Form 3.X ):
场景: 每次新增一个表单组件时,就需要声明一组 value
和 onChange
或者 useReducer
的方式去管理每一个表单组件的用户输入。
实现: 表单一开始的形态,是通过高阶组件的形式来完成的, 首先将自定义的 Form
组件整体通过一个高阶函数包裹起来, 以便向这个自定义的 Form
组件中注入 form
实例,在这一过程中也创建一个状态管理上下文, 通过 form
实例, 获取相关的操作 API。 然后通过这些 API
来管控状态树, 每次 value
的变化都会触发状态树的更新,进而调用 forceUpdate
刷新了整个表单。
优点: 此时通过 getFieldDecorator
高阶函数,完成了对组件的状态管理, 省略了重复的状态和事件监听函数的声明。
缺点: 每次表单中某个组件的 value
变化, 会引起整个表单的渲染,面临复杂的场景联动的情况下就显得有些捉襟见肘。
发布订阅模式(Antd Form 4.X、Arco ):
场景: 希望通过类似状态管理的思路,去完成表单的状态管理,达成细粒度更新的效果。
实现: 表单函数式组件时的形态,则是通过更细粒度的发布订阅模式完成的,此时的 Form
的状态就分成两部分进行维护。 首先,通过 Context
传递整个表单实例,以此在 Form
以及 FormItem
来操作状态树,然后在每个 FormItem
中代理 onChange
事件, 负责同步状态树和真实值。 而不是在 Form
中统一的通过 forceUpdate
进行管控。其次, 外界获取到表单实例, 也可以操作整体的状态树。这也就意味着每一个 FormItem
也会订阅整个 Store
的变化,进而更新 FormItem
。 此时已经达到了更好的更新粒度。
优点: 通过发布订阅,完成整体表单的状态管理,达成细粒度的更新每个 FormItem
的目的,在性能表现上基本已经基本够用。
缺点: 面对复杂的表单场景(动态表单,联动表单)时,需要通过 shouldUpdate
去额外处理细粒度的更新。
非受控组件(react-hook-form):
场景: 同样为了解决细粒度更新的问题,还有一种方案是完全拥抱非受控组件, 比如 react-hook-form
。
实现: 通过 register
注册字段后的返回值代理表单组件中的 ref
、 onChange
等属性,通过代理 onChange
事件在表单的生命周期外维护一套 form
的输入数据, 然后 onSubmit
时, 将维护的表单数据作为参数传入。相当于是把值存在了函数式组件的闭包中, 这也就意味着每次更新值不会触发 rerender
。但是为了触发渲染,又不得不设置整个表单的数据模型,比如 errors
、 isSubmiting
等状态,所以当提交、校验时,就会触发整个表单的重新渲染。
优点: react-hook-form
在获取输入的值时不使用传统的 useState
的方案,尽可能规避掉了输入过程中的重新渲染。
缺点: 整体设计中,为整个表单创建了一个 state
作为表单的数据模型,尽可能的描述表单所处的状态,当校验或者复杂的表单场景时, 会调用这个表单的 setState
的方法去更新整体表单的状态,这会导致整个表单的重新渲染。
响应式(Formily):
场景: 上述的解决方案,对于表单之间的字段联动,复杂表单关系的嵌套的处理仍然是表现不足。那有没有一套为使用者提供像 react
的各种生命周期和钩子一样的表单解决方案呢?答案是有的,那就是 formily
。
实现: formily
在 @formily/core
包中对表单进行建模,通过属性、方法对 Form
、Field
的状态进行描述,在 @formily/reactive
中通过 Proxy
实现响应式的数据代理,进而在 @formily/reactive-react
中结合 React
本身的更新机制,借此收集触发组件渲染的回调。最终在 @formily/react
中完成了 API
到 React
生态的桥接。但是最终使用的组件库需要针对 formily
的生态进行拓展二开。
优点: 在细粒度更新之上 formily
为 Form
以及 Field
的生命周期和事件提供了对应的钩子, 同时还支持 json schema
协议。
缺点: 上手难度大,并且如果是企业内部的组件库,需要针对 formily
的特性去进行二开。
发布订阅方案思路:
核心要素大概有两步:
- 通过
Context
传递整个表单的实例,在不同层级的FormItem
中能够订阅其中发布的变更, 完成表单和字段之间的串联。 - 创建包含整个表单的状态、相对应的修改的事件(包含内部和外部)、发布订阅的实例。 这里相当于有一个订阅者(表单控件内部的
ui
更新)以及两个发布者(外部直接操作表单实例, 用户操作ui
引发的组件更新), 主流的组件库通过将Control
设为类组件完成调用, 这里直接通过声明的回调函数实现 。
创建表单实例:
在表单实例中设计 4 个变量分别用来存储以下内容:
store
: 存放form
的数据,之后不管外部使用form
实例操作还是内部借助FormItem
完成变更初始化操作的都是store
。initialValues
: 用来存放form
表单的初始值,方便之后调用reset
方法。subscribers
: 存放FormItem
中的订阅,在这里我们要先约定好协议。(目前 antd 和 arco 的做法是将控制组件变化的组件做成类组件,然后在表单实例中直接操作这个类组件中的数据。)callbacks
:存放 **form
组件通过 props 传入的 回调函数 。
定义好上面的数据存储项之后, 我们就可以一步步的完成表单组件中与表单实例之间的数据传递的事件。
ts
export class Store<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
FormData = any,
FieldKey extends FieldKeyType = keyof FormData
> {
store: Partial<FormData>; // 存放 form 数据
initialValues: Partial<FormData>; // form 表单的初始值
subscribers: StoreSubscriberProtocol<FormData> = {} // Control 组件中注册的 FormItem 相关操作
callbacks: StoreCallbacks<FormData> = {} // 存放 form 组件通过 props 传入的回调函数
constructor() {
this.store = {}
this.initialValues = {}
// this.registerFieldChangeCallbacks = {}
}
// ... do something 稍后补全表单与字段通信的内容
}
完善表单实例的通信:
首先来介绍一下目录及每个文件对应的功能:
sh
/mini-ui/packages/ui/src/components/Form
├── Control.tsx // FormItem 核心:复制、代理表单控件的事件,并对改变、校验进行处理。
├── Form.tsx // Form 表单,初始化表单实例、创建上下文,传递数据。
├── FormItem.tsx // 组装 Control、FormLabel 以及 错误提示等元素
├── FormLabel.tsx // 表单控件的标题
├── context
│ └── index.ts // 表单整体的上下文
├── index.ts // 表单组件导出文件
├── interface.ts // 类型声明文件
├── store.ts // 表单实例文件
├── styles // 样式
│ ├── Form.less
│ └── FormItem.less
├── useForm.ts // 单例模式创建表单实例
└── utils.ts // 通用函数,包含校验。
上下文初始化:
使用 useContext
的过程中, 个人习惯将 Provider
的声明, 以及获取对应的 Context
的钩子声明, 都放在一起。
类型声明:
ts
/**
* @desc FormContext 和 FormItem 和 FormProps 共用的 API
* */
export interface CommonProps {
/**
* @zh 标签的文本对齐方式
* */
labelAlign?: 'left' | 'right'
/**
* @zh 表单的布局
* */
layout?: FormLayout
/**
* @zh label 栅格布局的比例
* */
labelCol?: ColProps;
/**
* @zh value 容器的布局比例
* */
wrapperCol?: ColProps;
/**
* @zh 是否在 required 的时候显示加重的红色星号, 可通过设置 position 设定展示在 label 的位置('start' | 'end')
* */
requiredSymbol?: boolean | { position: "start" | "end" }
/**
* @zh 是否显示标签后的一个冒号
* */
colon?: boolean | ReactNode
}
export interface FormContextType extends CommonProps {
/**
* @zh form实例
* */
form?: Store
}
Context 初始化:
ts
// packages/ui/src/components/Form/context/index.ts
const defaultFormContext: FormContextType = {
}
const FormContext = createContext<FormContextType>(defaultFormContext)
export const FromProvider = FormContext.Provider
export function useFormContext () {
return useContext(FormContext)
}
初始化表单实例:
通过 useForm
钩子实现单例模式创建 form
实例。
ts
// packages/ui/src/components/Form/useForm.ts
import { useRef } from "react";
import { Store } from "./store";
function getInstance() {
return new Store()
}
export function useForm(form?: Store) {
const formRef = useRef(form)
if(form) {
formRef.current = form
} else {
formRef.current = getInstance()
}
return formRef.current
}
传递布局配置以及表单实例:
通过上文中的 useForm
创建表单的实例, 然后根据 Form
组件中的属性, 设定表单 Context
中关于布局配置的值。
tsx
// packages/ui/src/components/Form/Form.tsx
const FormInner = <FormData, >(props: PropsWithChildren<FormProps<FormData>>) => {
// props
const {
form,
initialValue,
layout = 'horizontal',
labelAlign = 'right',
labelCol = { span: 5 },
wrapperCol = { span: 18, offset: 1},
requiredSymbol = true,
colon = false,
children
} = props
// status
const formInstance = useForm(form) // form 实例
// 派生数据
const formContext = { // form 组件的上下文
form: formInstance,
layout: layout,
labelAlign,
labelCol,
wrapperCol,
requiredSymbol,
colon
}
// class names
const prefix = getPrefix("form")
const classNames = cls(
prefix,
{
[`${prefix}-${layout}`]: layout
}
)
return (
<FromProvider value={formContext}>
<form className={classNames} onSubmit={handleSubmit}>
{children}
</form>
</FromProvider>
)
}
表单 props 事件处理:
这一步,将用户以 props
形式传入 Form
组件的事件进行绑定,完成用户操作 Form
中的 UI
发生的事件的订阅。然后设置通知的机制,当用户操作导致值改变的事件时,发布给订阅者(Form props
中的事件)。
表单实例订阅、发布的方法:
- 提供给组件中使用的方法,用来订阅组件
props
中的事件。 - 完成发布的函数,将内部更新事件发布给
props
订阅事件。
ts
export class Store<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
FormData = any,
FieldKey extends FieldKeyType = keyof FormData
> {
callbacks: StoreCallbacks<FormData> = {} // 存放 form 组件通过 props 传入的回调函数
/**
* @desc 任何变更引发 store 变更的事件, 都会通知 form 的 onValuesChange
* */
private triggerValueChange(changeValues: Partial<FormData>) {
if (changeValues && Object.keys(changeValues).length) {
this.callbacks?.onValuesChange?.(changeValues, this.getFields())
}
}
/**
* @desc Control 触发内部 store 发生变更后通知 form 中的 onChange
* */
private triggerTouchChange(changeValues: Partial<FormData>) {
if (changeValues && Object.keys(changeValues).length) {
this.callbacks?.onChange?.(changeValues, this.getFields())
}
}
/**
* @desc 注册 Form props 中的事件 onChange、onValuesChange、onSubmit、 onSubmitFailed
* */
innerRegisterEventCallbacks(callbacks: StoreCallbacks<FormData>) {
this.callbacks = callbacks
}
表单 props 事件注册(订阅):
- 调用上一步的方法,将
Form props
中的事件存入到表单实例中, 方便FormItem
被操作后调用。
tsx
const FormInner = <FormData, >(props: PropsWithChildren<FormProps<FormData>>) => {
// props
const {
form,
initialValue,
layout = 'horizontal',
labelAlign = 'right',
labelCol = { span: 5 },
wrapperCol = { span: 18, offset: 1},
requiredSymbol = true,
colon = false,
children
} = props
// status
const formInstance = useForm(form) // form 实例
// ... do something
// 给 form store 注册事件
formInstance?.innerRegisterEventCallbacks({
onChange: props.onChange,
onValuesChange: props.onValuesChange,
onSubmit: props.onSubmit,
onSubmitFaild: props.onSubmitFaild
})
return (
<FromProvider value={formContext}>
<form className={classNames} onSubmit={handleSubmit}>
{children}
</form>
</FromProvider>
)
}
表单值初始化:
在 packages/ui/src/components/Form/Form.tsx
中完成整个表单值的初始化,有一个 useCreate
的钩子需要进行说明,我理解这里保证 表单实例创建之后,初始值就被设置到表单实例中(仅进行一次,避免 initial
可能带来的副作用,如果需要网络请求设置初始值,让用户自行通过表单实例操作。)然后 useCreate
执行时间在整个表单组件树构建的早起,能够确保后续FormItem
组件在渲染是,能够正确的获取到正确的初始值。
useCreate:
ts
// packages/ui/src/hooks/useCreate.ts
export function useCreate(fn: () => void) {
const hasCreated = useRef(false)
if(!hasCreated.current) {
fn?.()
hasCreated.current = true
}
}
初始化:
Form
组件中调用表单实例中的方法,初始化props
中的初始值。。
tsx
const FormInner = <FormData, >(props: PropsWithChildren<FormProps<FormData>>) => {
// ... do something
// status
const formInstance = useForm(form) // form 实例
// effect 初始化form的初始值
useCreate(() => {
formInstance?.innerSetInitialValues(initialValue)
})
// ...do something
return (
<FromProvider value={formContext}>
<form className={classNames} onSubmit={handleSubmit}>
{children}
</form>
</FromProvider>
)
}
ts
export class Store<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
FormData = any,
FieldKey extends FieldKeyType = keyof FormData
> {
store: Partial<FormData>; // 存放 form 数据
initialValues: Partial<FormData>; // form 表单的初始值
subscribers: StoreSubscriberProtocol<FormData> = {} // Control 组件中注册的 FormItem 相关操作
callbacks: StoreCallbacks<FormData> = {} // 存放 form 组件通过 props 传入的回调函数
/**
* @desc 内部初始化
* */
innerSetInitialValues = (values?: Partial<FormData>) => {
if (!values) return;
this.initialValues = cloneDeep(values);
Object.keys(values).forEach((field) => {
set(this.store, field, values[field as keyof Partial<FormData>]);
});
// 触发内部 store 的变更
this.triggerValueChange(values)
}
表单控件订阅更新&校验:
外界会通知内部进行的操作主要是 更新、校验两部分,我把这两个函数单独抽离出来,然后统一注册进表单实例中。
这一步完成之后,方便后续的外部更新、内部设置的处理。
订阅的数据规范:
ts
/**
* @desc 注册进入store的回调函数的协议
* */
export type StoreSubscriberProtocol<FormData> = Record<
FieldKeyType,
{
validate: (value?: FormData[keyof FormData]) => Promise<FieldErrorType>;
onStoreChange: (type: StoreChangeType, info: StoreChangeInfo<FormData>) => void
}
>
表单实例中的订阅函数 & 发布函数:
ts
export class Store<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
FormData = any,
FieldKey extends FieldKeyType = keyof FormData
> {
subscribers: StoreSubscriberProtocol<FormData> = {} // Control 组件中注册的 FormItem 相关操作
// ... do something
/**
* @desc store 发布订阅注册符合协议的订阅
* */
innerRegistFieldCallback(field: FieldKey, subscriber: StoreSubscriberProtocol<FormData>[FieldKey]) {
this.subscribers = {
...this.subscribers,
[field]: subscriber
}
}
/**
* @desc 发布Store内部的状态变更
* */
private notifyStoreChange(type: StoreChangeType, info: StoreChangeInfo<FormData>) {
this.subscribers?.[info?.field as keyof StoreSubscriberProtocol<FormData>]?.onStoreChange?.(type, info)
}
}
Control
中订阅:
onValidate
: 用来校验输入的值是否符合校验关系,如果不符合就返回相应的值。handleStoreChange
: 用来响应不同数据源(外部、内部)的更新。(PS: 仅简单实现细粒度的更新,field
的对比还应该考虑到深层级嵌套的问题。)。
ts
import { cloneElement, PropsWithChildren, ReactElement, useEffect } from "react"
import { useFormContext } from "./context"
import { ControlProps, StoreChangeInfo, StoreChangeType } from "./interface"
import { validate } from "./utils";
import { isArray } from "lodash-es";
import { useUpdate } from "../../hooks/useUpdate";
export const Control = (props: PropsWithChildren<ControlProps>) => {
// props
const {
field,
rules,
validateStatus,
onError,
children
} = props
// force re-render
const forceUpdate = useUpdate()
// context
const { form } = useFormContext()
async function onValidateValue( _value: FormData[keyof FormData]) {
const errors = await validate(_value, field, rules)
onError?.(errors)
return errors
}
function handleStoreChange(type: StoreChangeType, info: StoreChangeInfo<FormData>) {
switch(type) {
case 'innerSetValue':
if(info.field === field) {
forceUpdate()
}
break
case 'setFieldValue':
if(info.errors)
onError?.(info.errors)
if(info.field === field) {
forceUpdate()
}
break
default:
break
}
}
// 副作用
useEffect(() => {
if(!field)
return
form?.innerRegistFieldCallback(field, {
validate: onValidateValue,
onStoreChange: handleStoreChange
})
}, [form])
return (
<div>
{
cloneChildElement()
}
</div>
)
}
表单内部设置字段值:
通过 React.cloneElement
的函数复制子元素,然后代理 value
和 onChange
使其能够成为受控组件,然后 onChange
被触发后就调用表单实例中的方法去完成设置 store
的值,并且通知订阅的事件。
表单实例中内部设置值的方法:
- 修改整体 store的值。
- 完成上文中 表单组件内部事件通信 对 表单 props 事件处理 以及表单控件订阅的更新事件的发布。
ts
export class Store<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
FormData = any,
FieldKey extends FieldKeyType = keyof FormData
> {
store: Partial<FormData>; // 存放 form 数据
initialValues: Partial<FormData>; // form 表单的初始值
subscribers: StoreSubscriberProtocol<FormData> = {} // Control 组件中注册的 FormItem 相关操作
callbacks: StoreCallbacks<FormData> = {} // 存放 form 组件通过 props 传入的回调函数
/**
* @desc Control 中触发改变事件后, 同步 store 中的状态。
* */
innerSetFieldValue(field?: FieldKey, value?: string) {
if (!field) return
this.store = {
...this.store,
[field]: value
}
this.triggerTouchChange({ [field]: value } as Partial<FormData>)
this.triggerValueChange({ [field]: value } as Partial<FormData>)
this.notifyStoreChange(
'innerSetValue',
{
field,
value: value as FormData[keyof FormData]
}
)
}
}
复制子元素,并代理部分 props
:
ts
export const Control = (props: PropsWithChildren<ControlProps>) => {
// context
const { form } = useFormContext()
function onValueChange(value?: string) {
// setValue(value)
form?.innerSetFieldValue(field, value)
}
function cloneChildElement() {
if (isArray(children)) {
return children.map((com, i) => {
return cloneElement (
com,
{
key: com.key || i
}
)
})
}
return cloneElement(
children as ReactElement,
{
value: getValue(),
onChange: onValueChange,
status: validateStatus
}
)
}
return (
<div>
{
cloneChildElement()
}
</div>
)
}
外部操作表单实例:
表单实例暴露给外层操作的方法大致可以分为: 获取、更新。 其中获取表单状态值直接可以通过获取表单实例中的 store
即可,或者对其在进行一些额外的处理。因此我们主要关注的还是外部更新表单值如何通知给内部组件、以及校验相关内容。
获取表单状态值:
通过 lodash
的 get
方法,实现多层级的数据获取。以及返回 store
的值。
ts
export class Store<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
FormData = any,
FieldKey extends FieldKeyType = keyof FormData
> {
store: Partial<FormData>; // 存放 form 数据
// ... do something
getFieldsValue() {
return this.store
}
getFieldValue(field: string) {
return get(this.store, field)
}
getFields() {
return cloneDeep(this.store)
}
// ... do something
}
更新表单状态值:
setFieldValue
用来设定指定字段的值, setFieldsValue
用来更新 object
类型的值,也就是 Partial<FormData>
的值。 但是上面这两个函数本质上都是做了一层转化然后调用 setFields
, setFields
是根据约定的数据更新协议进行设置。
ts
export class Store<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
FormData = any,
FieldKey extends FieldKeyType = keyof FormData
> {
store: Partial<FormData>; // 存放 form 数据
initialValues: Partial<FormData>; // form 表单的初始值
subscribers: StoreSubscriberProtocol<FormData> = {} // Control 组件中注册的 FormItem 相关操作
callbacks: StoreCallbacks<FormData> = {} // 存放 form 组件通过 props 传入的回调函数
//... do something
/**
* @desc 外部设置单个 field 的值。
* */
setFieldValue(field: FieldKeyType, value: FormData[keyof FormData]) {
this.setFields({
[field]: {
value
}
})
}
/**
* @desc 外部设置 form 表单的值
* */
setFields(obj: Record<FieldKeyType, Omit<StoreChangeInfo<FormData>, 'field'>>) {
const keys = Object.keys(obj)
const changeValues: Partial<FormData> = {}
keys.forEach(field => {
const item = obj[field]
if ('value' in item) {
set(this.store, field, item.value)
set(changeValues, field, item.value)
}
this.notifyStoreChange(
'setFieldValue',
{
...item,
field
}
)
})
this.triggerValueChange(changeValues)
}
/**
* @desc 外部设置多个表单控件的值
* */
setFieldsValue(obj: Record<FieldKey, unknown>) {
const changedValue = Object.keys(obj)
.reduce((prev, cur) => {
return {
...prev,
[cur]: {
value: obj[cur as keyof typeof obj]
}
}
}, {})
this.setFields(changedValue)
}
/**
* @desc 重置表单控件的值为初始值
* */
resetFields(fields?: string[]) {
fields = isArray(fields) ? fields : Object.keys(this.store)
const changeValue = {} as Record<FieldKey, unknown>
fields?.forEach(field => {
const value = this.initialValues?.[field as keyof FormData]
set(changeValue, field, value)
})
this.setFieldsValue(changeValue)
}
}
总结:
随着前端技术的不断发展,表单解决方案也日趋成熟。今天我们探讨了表单发展历程中的四种处理方案(高阶组件、 发布订阅、 非受控组件、 响应式),并重点实现了基于发布-订阅模式的方案。仓库代码中还包含了表单校验和提交功能,但由于篇幅限制,未做详细展开。嵌套表单场景目前也尚未完善。。。