沐光

记录在前端之路的点点滴滴

深入 AntD Form

前言

Form 是项目中经常使用的组件,为了能熟练驾驭它,深入 Form 源码还是有所必要的。我们知道,在 Ant Design 4 之后,Form 的底层使用的是 rc-field-form,因此在了解源码时,我们也得对其底层的依赖深挖一番,这样才能了解的更透彻。

rc-field-form

💻 仓库地址:【传送门】(建议动手运行一下,会了解的更快)

⚠️ 注:表单的所有操作都离不开顶层的 FormStore,所有的变动信息最终都会汇聚在 FormStore 内。

整体结构

在了解源码之前,我们先大致了解一下 rc-field-form 的结构,因为 Ant Design Form 是基于此封装的,因此熟悉 rc-field-form 对我们解读源码来说很重要。

页面渲染的结构树如下:

./rcFormComponentStructure

基于此,我们使用 ReactDom 的方式用代码描述大致为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 顶层 Form 的大致样式
*/
// 手动添加 Provider 并初始化部分能力
<Form.Provider {...props}>
// 重写原生 form 的部分事件能力
<form {...props} onSubmit={xxx} onReset={xxx}>
// FieldContext 传递顶层的 FormContext 内容,方便便捷获取(包括 formInstance
对象和 validator 方法) // childNode 对 function 和 JSX.Element 做了兼容处理
<FieldContext.Provider value={formContextValue}>
// WrapperField 用于获取 FieldContext、拼接 name、类型等基础操作
<WrapperField>
// Field 层做 Dom 元素重写(elementClone 方法)、校验方法封装、props
属性扩展等能力 // 值的变动、获取等能力都是从顶层 Store 传下来的
<Field>{element}</Field>
</WrapperField>
</FieldContext.Provider>
</form>
</Form.Provider>

结构解析

根据 rc-field-form 源码的渲染逻辑,这里我花了一张结构图:

rcFormStructure

其渲染的步骤大致就是:

  1. 通过 useForm 创建顶层的 FormStore 存储库,然后通过 FieldContext 向下传递;
  2. Field 获取到由 FieldContext 传下的顶层 FormStore ,获取需要的 name 和一些操作方法,对 DomeElement 做克隆和属性封装;
  3. 根据最终传入的 Dom 类型,做相应的格式化处理和呈现;(有可能传入的为函数)

能力拆解

整个 rc-field-form 的能力可简单拆解为:

  1. 数据存储:在 Form 顶层维护了一个 Context 存储库,对 Form 内注册的任何子表单项的变动,都通过注册好的 getset 方法来存入 store 内;
  2. 表单对象:顶层通过 useRef 生成一个不随组件更新的 formInstance 对象,然后通过 useForm 生成,当初次渲染时创建出 FormStore 绑定即可,后续如果需要刷新则触发初始化绑定的 forceUpdate 方法即可刷新(我们通过 form 拿到的表单对象就是这个 formInstance);
  3. 表单校验:使用 async-validate 插件库校验,拼接格式则是通过 name + rules 字段来生成(组件注册阶段时生成组件描述数组即能方便的获取结构);
  4. 变更事件:表单项的变更事件是在注册 Field 时绑定的,将传入的 children 进行 cloneElement 操作,生成目标子元素并改写 props(通过 getControlled 方法去改写 props 以及增加校验等操作)。
  5. 命名获取:表单项有个 name 字段是用于定义表单名的,层级越深的表单要设置其 name 时,需要通过 getNamePath 方法来拼接成最终的 name,最终 join 生成的 namePath 大致为 prefixPath_currentName

其中,formInstance 暴露出来的能力为(顶层 FormStore 暴露的能力):

rcFormInstance

而这也可以算是 rc-field-form 对外暴露的所有能力了(内部拼接 name 的方法是 getNamePath,建议留心一下)。

深入 AntD Form

AntD Form 组件及能力

antdForm

图中有 AntD4.20 支持的新特性:useWatchuseFormInstance

  • useFormInstance:返回 useForm 获取的 form 对象,这对于子组件拿顶层 form 对象来说很方便;

  • useWatch:监听字段变化返回更新后的值,仅当改字段变化时重新渲染(内部用 useEffect 做初始化赋值)。

⏰ 暴露的 Form.Provider 一般自己封装,相当于就是使用 rc-field-form 自己手写 Provider 层一样,一般用不着。

Form 结构

AntD Form 整体基于 rc-field-form 封装了一层,其拓展了对于 AntD 其余组件使用的能力和样式的填装。我们看个简单例子:

./antdFormComponentStructure

这里大致可分为四层:

  • Form Provider:此部分实现了 rc-field-form 需要手写的部分;
  • Custom Provider:此部分主要为 AntD 需要的样式控制的 Context
  • Field Provider:沿用 rc-field-formFieldContext,添加了一些“错误呈现”、“dependencies 依赖”等内容;
  • Content:主要做值绑定和一些校验结果、样式等的呈现,内部有自定义的关联 Context

InternalForm 相关内容

A. 内部调用的 useForm 封装了什么?

useForm

useForm 额外增添的内容不多,一个是 __INTERNAL__,它的作用就是为 React.forwardRef 暴露通过 useForm 生成的对象(可以了解一下 useImperativeHandle)。

B. 它在 Form 的基础上又做了什么?

  1. 初始化了很多关于样式控制相关的内容(这里将 disable、layout、size 等配置均归属于样式配置);
  2. 向外暴露 ref 对象;
  3. 添加 scroll 错误定位;
  4. 自动绑定 rc-field-form 需要用户写的 Provider

FormItem 相关内容

A. FormItem 主要作用

  1. 决定表单的呈现方式:函数式组件式数组
  2. 组件更新判断,局部更新处理;
  3. 校验相关的绑定;

总而言之,就是将 Field 组件做 AntD 的本地化处理(通过函数子组件获取 Field 解析好的所有配置)。

B. FormList 的对 FormItem 的封装有什么?

FormList 其实就是调用 rc-field-form 的列表操作,然后让前端自己去对应封装 FormItem

rc-field-formList 组件则是对外暴露了一个函数(我们用官网 List 组件常用的那个函数),它最终操作的就是 Field 组件暴露出来的能力。

可动手的点

AntD 对外暴露的能力来看,我们基本没法基于它包装内容,因为它基本上就是包装 rc-field-form,却又没有对外面暴露 rc-field-form 支持的能力。因此通过锁定版本,根据其依赖的 rc-field-form 版本来扩充一些能力,而这就是 @alife/form-tools 封装出的原因。目前业务上使用表单比较难受的点有:

  • form 对象的深层传入,深层次组件的 namePath 不好监听(4.20.0 版本已支持)。
  • 对于兄弟组件间的事件消息处理的很难受,特别是值变更事件(4.20.0 版本已支持);
  • 对象类表单数据的数组命名法很难受,无法复用表单组件;
  • 复杂表单的初始化逻辑均收敛在上层,一些需要根据值做接口查询呈现的组件开发起来很困难;

那么基于这些难点,我们根据 rc-field-form 内部暴露的一些能力可做一些封装。

form 对象跨层级传递

4.20 版本解决了跨层级传递 form 对象的问题,直接使用 Form.useFormInstance 获取到 form 对象,不用为之前需要通过 props 传值而烦恼了。

🌰 使用示例:示例传送门

但是它也有缺陷:

  • 获取 form 对象就得用到全量的 NamePath,难做到表单组件复用。

☕️ 如果需要将表单组件抽离出来做公共组件(它们仅包裹层的 name 不同),那么内部使用 form 的一些方法就会很难受(因为没法知道它的 prefixName 到底是什么)。

因此可基于 rc-field-form 做一层优化:直接获取当前路径下的 form 对象,即“自动前缀补全”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import { useCallback, useContext, useMemo } from 'react';
import { InternalFormInstance, InternalNamePath, NamePath } from 'rc-field-form/es/interface';
import FieldContext from 'rc-field-form/es/FieldContext';
import { FormInstance } from 'antd/es/form';

function getLocalForm(form: InternalFormInstance, prefixPath: InternalNamePath = []) {
// 获取所有值
function getLocalFieldValue(name: NamePath) {
return originForm.getFieldValue([...prefixPath, ...getNamePath(name)]);
}

// 获取单一值
function getLocalFieldsValue(
nameList?: NamePath[] | true,
filterFunc?: (meta: Meta) => boolean,
) {
if (!nameList || nameList === true) {
const value = originForm.getFieldsValue([prefixPath], filterFunc);
return getValue(value, fullPrefix);
}

// 为每个字段补全前缀
const formatNameList = nameList.map(nameItem => [...prefixPath, nameItem]);
const value = originForm.getFieldsValue(formatNameList, filterFunc);
return getValue(value, fullPrefix);
}

// 设置所有值
// ...

// 设置单一值
// ...

// 重置当前表单
// ...

// 其它...(基本照着 FormStore 基本重写一遍即可)

return {
getLocalFieldValue,
getLocalFieldsValue,
// ...
}
}

function useLocalForm() {
const context = useContext(FieldContext);
const prefixPath = context.prefixName || [];

const localForm = useMemo(() => getLocalForm(context, prefixPath), [context, prefixPath])

return useMemo(() => {
return [localForm] as const;
}, [localForm]);
}

export default useLocalForm;

这样即使组件拆分出来,也可以不用关注 Form 命名前缀的问题,直接使用即可(数组在套用了 FormAccess 之后也适用此方法)。

兄弟组件事件处理

我们知道,如果要获取到兄弟组件间值的变更,设置此类事件监听方法很是头疼,不过在 4.20 版本提供的 useWatch 方法能够比较方便的解决这个问题,同时其获取值的能力是通过 useEffect 获取的,初始化时也能监听到变更。

🌰 使用示例:示例传送门

useWatch 的缺陷(同 4.1 所述问题)

  • 一旦需要复用组件,限定死的 NamePath 会导致组件最终耦合太大,无法拆分。

因此同样可按照 4.1 中的处理方式,注册个虚拟的 Field ,然后通过它来做事件监听,从而模拟 dependencies 的能力:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
useEffect(() => {
const { registerField } = getInternalHooks(HOOK_MARK) as InternalHooks;

// 模拟 Field 的功能,创建一个虚拟的 Field 并绑定监听事件
const unregister = registerField(new FakeField(handleChange, {
names: dependenciesCache.current.map((dep) => getNamePath(dep)),
trigger,
requirePathExist,
}));

return () => {
unregister();
};
}, [requirePathExist, handleChange, getInternalHooks, dynamic && dependencies]);

更为舒适的命名方式

先前提到过,FormItem 内的表单 name 值的获取其实是用 Field 的函数方式来获取到 prefixName,最终拼接好 props 上传入的 name 来设置全名的,整体的使用很被动,这就导致:

  • 获取值、监听事件等都需要拼接全名,对于复杂表单来说很麻烦;

  • 数组形式的命名,会使得表单难以解耦,难以做到表单模块的复用;

rc-field-form 拼接名称的方式:

WrapperField 获取到当前 fieldContextprefixName 值,再传入 Field 组件根据传入的 name 来拼接;

既然它的 prefixName 是来自 fieldContext 的,而 fieldContextvalue 又来自 formContextValue,且 fieldContext 内部并没包裹额外逻辑,那么我们为何在包裹表单对象的时候再包一层 FieldContext,直接敲定它的 prefixName,这样不就能优化掉 Form.Item 的数组命名方式吗?

@alife/form-tools 的功能拓展参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* 可参考 Field 的 WrapperField 的写法来写
**/
import React, { PropsWithChildren, useContext, useMemo } from 'react';
import FieldContext, { HOOK_MARK } from 'rc-field-form/es/FieldContext';
import { getNamePath } from 'rc-field-form/es/utils/valueUtil';
import { NamePath } from 'rc-field-form/es/interface';

interface FormAccessProps {
/* 拼接的 name 前缀,将 [objName, keyName] 的 objName 单独拆出来 */
name: NamePath;
}

const FormAccess = (props: PropsWithChildren<FormAccessProps>) => {
const { name, children } = props;
const context = useContext(FieldContext);

const value = useMemo(() => {
return {
...context,
// 主要目的就是将 prefixName 尾部添加当前的 name 字段
prefixName: [
...(context.prefixName || []),
...getNamePath(name),
],
};
}, [context, name]);

// 重新包一个新的 Provider
return (
<FieldContext.Provider value={value}>
{children}
</FieldContext.Provider>
);
};

export default FormAccess;

使用方法示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 值为: { obj: { test: xxx } }
<FormAccess name="obj">
<Form.Item name="test">
// ...
</Form.Item>
</FormAccess>

// 值为 { arr: [] };
<Form.List name="test">
{(fields, operations) => {
// 做一些逻辑封装
const { add, remove, move } = operations;

return fields.map((field) => {
// fieldKey === key
const { name, fieldKey, key } = field;

// 简化 name 的赋值,children 部分可以抽离成公用模块
return (
<FormAccess key={key} name={name}>
<Form.Item name="name">
<Input />
</Form.Item>
</FormAccess>
);
});
}}
</Form.List>

参考文章