Ant Design 的 Form 表单组件是我们最常用的组件之一,它可以帮助我们数据录入、校验等功能。
大多数开发者认为 Form 表单使用起来非常方便,那是因为组件的内部承担了许多功能,比如状态管理、状态分配、表单验证等诸多环节。接下来我们一起看看具体如何实现一个表单功能。
在正式开始前,请大家带着以下 2 个小问题阅读:
- Form 组件是如何管理整体的数据流,为什么能从 Form 中获取表单控件的值?
- Form.Item 的 name 属性如何替代表单控件(如:Input、Select)的 value、onChange 属性,使其受控?
先附上一张知识图谱,正式进入 Form 组件的学习:
一、表单的整体设计
在设计之前,我们以 Ant Design 中的 Form 为例,来看看一个基本的表单长什么样,又具备什么样的功能(文件位置:example/AntDForm):
<Form
initialValues={{ book: "玩转 React Hooks" }}
onFinish={(data: any) => {
console.log("表单数据:", data);
}}
onReset={() => {
console.log("重制表单成功");
}}
>
<Form.Item label="小册名称" name="book">
<Input placeholder="请输入小册名称" />
</Form.Item>
<Form.Item label="作者" name="name">
<Input placeholder="请输入作者" />
</Form.Item>
<Form.Item wrapperCol={{ offset: 8, span: 16 }}>
<Button type="primary" htmlType="submit">
提交
</Button>
<Button style={{ marginLeft: 4 }} htmlType="reset">
重制
</Button>
</Form.Item>
</Form>
效果:
在这个基础表单案例中,可以大体将表单分为 Form => Form.Item => 表单控件
三层结构,分别承担不同的作用,如:
- Form 组件:满足原生 form 表单功能,具备提交、重置、初始化、管理表单整体的数据结构等。
- Form.Item 组件:具备 label 功能(表单左侧的展示)、name 功能(对应整体数据的传递)、校验等功能属性。
- 表单控件:可以是各种数据录入组件(如:Input、Select),在不影响原本功能的前提下,需要将数据内容通过 Form.Item 绑定,由 Form.Item 控制 value、onChange 等属性,而不是自身绑定触发事件。
将示例转化成关系图,如下所示:
接下来,我们就一步一步实现出自己的 Form 组件。
二、整体布局
经过上面的示例,我们需要创建 Form 和 Form.Item 组件作为容器,表单控件需要通过包裹的形式(children 属性)进行展示。
大体结构为:
// Form
<form> // 满足原生的 form 表单
{children} // 包裹 Form.Item
</form>
// Form.Item
<Layout> // 布局组件
{children} // 包裹表单控件
</Layout>
其中,Layout 组件属于布局组件,可控制表单的样式。为了让后续的效果更加好看,我们在这里简单处理下,可通过 Col 和 Row 进行宽度的设置,如:
// Layout
import { Col, Row } from "antd";
const Index = ({ children, label }: any) => {
return (
<>
<Row gutter={8}>
<Col
span={4}
style={{ textAlign: "right", lineHeight: "32px", fontSize: 14 }}
>
{label ? label + ":" : ""}
</Col>
<Col span={9}> {children}</Col>
</Row>
<div style={{ height: 12 }}></div>
</>
);
};
export default Index;
效果:
提示语
提示语也是表单常见的功能之一,也相对简单,只需要通过 tooltip 字段控制配合即可,如:
// Layout
import { Col, Row } from "antd";
const Index = ({ children, label }: any) => {
return (
<>
<Row gutter={8}>
<Col
span={4}
style={{ textAlign: "right", lineHeight: "32px", fontSize: 14 }}
>
{label || ""}
{tooltip && (
<Tooltip title={tooltip}>
<QuestionCircleOutlined style={{ margin: "0 3px" }} />
</Tooltip>
)}
{label && ":"}
</Col>
<Col span={9}> {children}</Col>
</Row>
<div style={{ height: 12 }}></div>
</>
);
};
export default Index;
效果:
三、数据管理与通信
在整个的表单的设计中,最核心点莫过于数据的状态管理。数据源如同整个表单的大脑,因此掌握好数据源是我们首要解决的问题。
其中,Form 组件需要承担表单的数据流向,当表单控件的值发生变化时,Form 管理的数据流也应该发生对应的改变。
除此之外,Form 组件还需要承担状态下发的作用,不仅可以管理这些数据,也要让这些数据通过 Form.Item 的 name 属性控制对应的表单控件,使其成为受控,这样做的目的是:可以自由传递 value,也能得到最新的 value,向上传递。
因此,我们通过 useForm (自定义 Hooks)来集中管理表单的数据,通过对应的实例,暴露对应的方法,在 Form、FormItem 组件中传递数据,更好地帮助管理表单。 如:
import { useRef } from "react";
import { FormInstance, DataProps } from "./interface.d";
import FormStore from "./FormStore";
const useForm = () => {
const formRef = useRef<FormInstance | null>();
if (!formRef.current) {
// 创建一个实例,帮我们获取对应的方法
formRef.current = new FormStore().getDetail();
}
return [formRef.current];
};
export default useForm;
其中 FormStore 是 useForm 的核心,而 getDetail 用于暴露 FormStore 的方法,防止将多余的方法暴露出来。
此外,Form 和 Form.Item 组件可能存在深层的嵌套关系,所以我们可以通过 context( createContext + useContext )跨层级方式传递数据。
数据如何通信?
通过上面的分析,我们需要将整个表单的数据源通过 useForm 来保存,但数据是通过表单控件而来,换言之我们需要将表单控件受控,使 Form 组件进行状态下发,精确控制对应的表单控件。
那么,如何在不改变结构的情况下,还能使组件受控,就变成了一个有趣的点,我们先来看看通常情况下如何让组件受控:
<Input value={value} onChange={(e) => setValue(e.target.value)} />
在通常情况下,Input 受控,需要 value
和 onChange
属性的帮助,但在表单的场景中,并不需要通过 value 和 onChange 进行控制,主要原因有以下两点:
- 操作麻烦,不能确定具体表单控件的个数,如果每个控件都需要配置,比较麻烦。
- 破坏结构,相当于增加的两个属性是必须存在的,这样做会破坏表单控件的原有结构。
所以,我们并不希望通过 value、onChange 直接控制,而是通过 Form.Item 中的 name 属性来代替 value 和 onChange。为达到这一目的,就需要 React.cloneElement 的帮助,将这两个属性强行剥离出来,使组件受控。
问:React.cloneElement 是什么?
答:cloneElement 可以克隆并返回一个新的 React 元素。其结构为:
React.createElement(element, [props], [...children])
element: 一个有效的 React 元素,大部分情况下是 JSX 节点;
props: 对象或者为 null,如果存在,则会赋值给 element,如果不存在,则保留原来的 props;
children: 零个或多个子节点,可以是任何 React 节点。
举个小例子:
import React from "react";
const Index: React.FC = () => {
const children = React.cloneElement(
<div>大家好,我是小杜杜,一起玩转Hooks吧!</div>,
{
book: "玩转 React Hooks",
}
);
console.log(children);
return <>{children}</>;
};
export default Index;
打印下 children 的结果:
可以看出,React.cloneElement 将 book 这个属性赋值给了 div,而 children 实际上等价于:
const children = (
<div book="玩转 React Hooks">大家好,我是小杜杜,一起玩转Hooks吧!</div>
);
所以,我们可以通过 React.cloneElement 给表单控件加入 value、onChange 事件,使其受控。
检查 children 元素
在 React.cloneElement 要注意一个点,就是它的第一个参数 element
,这个参数代表为:有效的 React 元素,换言之,Form.Item 所包裹的表单控件必须要符合这个条件。
而对于 Form.Item 来说,表单控件就是 children 属性,但 children 属性可能具备多种情况,比如字符串、单节点、多节点等情况,不同的情况,children 的形式不同,如:
很明显,只有单节点的情况才符合 React.cloneElement 的条件,至于其他情况,我们均不处理,只需正常展示即可。
单节点的本质是 React 元素,所以我们可以借助 React.isValidElement 来帮助我们判别下是否属于有效的 React 元素,如果是,则对其受控,如果不是,则不处理。如:
const FormItem = (props: any) => {
const { name, children } = props;
const update = useUpdate();
const contextValue = useContext(FormContext);
const { getFieldValue, dispatch, registerField, unRegisterField } = contextValue;
let childrenPro;
// 利用 isValidElement 来判断传递的数据是否是 React.ReactElement. 注意他可以判断多节点的情况,和无值的情况
if (isValidElement(children) && name) {
// 利用 cloneElement 给传递的组件加入 value 和 onChange 属性,剥离出对应的方法
childrenPro = cloneElement(children as React.ReactElement, {
value: getFieldValue(name),
onChange: (v: any) => {
let payload: any = {};
payload[name] = v.target.value;
// 更新 store 中的值
dispatch({
type: "updateValue",
name
,
value: v.target?.value,
});
update(); // 触发更新
},
});
} else {
childrenPro = children;
}
return <Layout {...props}>{childrenPro}</Layout>;
};
在 cloneElement 中,共涉及三个部分,分别是:
- getFieldValue: 获取对应表单的 value;
- dispatch: 触发更新,用于更新 useForm 中的 store;
- update: 强制刷新表单控件(有缺陷,后续会讲到)。
值的获取和更新
当学习完 cloneElement 和 isValidElement 后,值的获取和更新就变得非常简单,只要简单处理下 useForm 的核心:FormStore 即可。如:
class FormStore {
store: DataProps = {}; // 管理表单的整体数据
// 用于暴露方法
public getDetail = (): FormInstance => ({
getFieldValue: this.getFieldValue,
dispatch: this.dispatch,
});
// 获取对应的值
getFieldValue = (name: NameProps) => {
return this.store[name];
};
// 触发更新
dispatch = (action: ReducerAction) => {
switch (action.type) {
case "updateValue": {
const { name, value } = action;
this.updateValue(name, value);
break;
}
default:
}
};
// 更新
updateValue = (name: NameProps, value: any) => {
this.store = {
...this.store,
[name]: value
};
};
}
只需要一个 store 变量去整体维护表单的值即可。
强制更新表单
当我们使用 dispatch 后,可以通过 useUpdate 实现对应控件的更新,但这么做存在一个缺陷:更新表单的操作,并不在 useForm 中,如果之后的操作涉及到更新(如:重置),是不是还要单独处理一套新的逻辑?
很明显,这样做多此一举,所以我们将更新的逻辑单独存储在 FormStore 中(update_store),有需要的话直接调用即可。
所以,我们需要记录当前的表单控件,一个 name 对应一个表单控件,同时在 Form.Item 进行注册和卸载,将更新方法进行保存。
然后,当值发生改变后,判断对应的表单控件进行控制,执行更新方法,使视图发生改变。如:
// Form.Item
const FormItem = (props: any) => {
const contextValue = useContext(FormContext);
const { getFieldValue, dispatch, registerField, unRegisterField } =
contextValue;
// 优化
const updateChange = useCreation(() => {
return {
updateValue: () => update(),
};
}, [contextValue]);
useEffect(() => {
// 注册
name && registerField(name, updateChange);
return () => {
//卸载
name && unRegisterField(name);
};
}, [updateChange]);
...
}
// FormStore
class FormStore {
update_store: DataProps = {}; // 保存更新的对象
// 用于暴露方法
public getDetail = (): FormInstance => ({
unRegisterField: this.unRegisterField,
registerField: this.registerField,
...
});
// 注册表单方法
registerField = (name: NameProps, updateChange: DataProps) => {
this.update_store[name] = updateChange;
};
// 卸载表单方法
unRegisterField = (name: NameProps) => {
delete this.update_store[name];
};
// 更新
updateValue = (name: NameProps, value: any) => {
this.store = {
...this.store,
[name]: value,
};
this.updateStoreField(name);
};
// 更新对应的表单
updateStoreField = (name: NameProps) => {
const update = this.update_store[name];
if (update) update?.updateValue();
};
}
四、表单的基本操作
表单的基本操作有:初始化、提交、重置三个功能,简单分析下对应的功能点,来帮助我们更好地掌握表单。
- initialValues: 初始化,如果存在,则赋值给 FormStore 中的 store,并将值进行保留,用于重置;
- onFinish: 提交,将 store 的数据传递给 onFinish;
- onReset: 重置,进行表单重置,如果存在 initialValues,则设为初始化值。
初始化
在初始化的过程中,我们将 initialValues(初始值)传入给 useForm,并将其赋到 FormStore 中的 store 和 initialValues 中。
// Form
const [formRef] = useForm(initialValues);
// useForm
const useForm = (initialValues: DataProps) => {
...
if (!formRef.current) {
formRef.current = new FormStore(initialValues).getDetail();
}
...
};
// FormStore
class FormStore {
...
initialValues: DataProps = {}; // 保存初始值
constructor(initialValues: DataProps) {
this.store = initialValues;
this.initialValues = initialValues;
}
...
}
提交、重置
跟刷新的逻辑一样,我们希望 useForm 去统一管理表单的提交和重置,将 onFinish 和 onReset 通过 setConfigWays 保留到 FormStore 的 configWays 中,然后再提交和重置的时候进行调用即可。如:
// Form
const Index = (props: FormProps) => {
...
formRef.setConfigWays({
onFinish,
onReset,
});
return (
<form
{...payload}
onSubmit={(e) => {
// 阻止默认事件
e.preventDefault();
e.stopPropagation();
formRef.submit();
}}
onReset={(e) => {
e.preventDefault();
e.stopPropagation();
formRef.resetFields(); /* 重置表单 */
}}
>
<FormContext.Provider value={formRef}>{children}</FormContext.Provider>
</form>
);
};
// FormStore
class FormStore {
...
configWays: ConfigWayProps = {}; // 收录对应的方法集合
...
// 设置方法区间
setConfigWays = (configWays: ConfigWayProps) => {
this.configWays = configWays;
};
// 用于表单提交
submit = () => {
const { onFinish } = this.configWays;
onFinish && onFinish(this.store);
};
// 重置表单
resetFields = () => {
const { onReset } = this.configWays;
Object.keys(this.store).forEach((key) => {
// 重置表单的时候,如果有初始值,就用初始值,没有就删除
this.initialValues[key]
? (this.store[key] = this.initialValues[key])
: delete this.store[key];
this.updateStoreField(key);
});
onReset && onReset();
};
}
这样,一个基本的表单组件就完成了,来看看整体效果:
五、玩转 React Hooks 小册
小册已经上架一个多月了,销量对我而言非常好,也很感谢各位兄弟的捧场。
小册的内容偏向于基础,对新手更加友好,同时也更加倾向于思想,无论是 Hooks 的内容,还是实战篇的内容,建议大家都亲自实现一番,将这些思想应用于工作之中,封装出更加通用、全面的组件,才是我们学习完这本小册的目的。
小册链接:《玩转 React Hooks》
小册整体设计如下思维导图
所示:
另外,感兴趣的兄弟可以私信我,领取
七折
兑换码。
小结
本小节对应 rc-form(Antd form) 的实现,主要介绍 Form 组件的数据管理与通信。另外,表单的校验也是非常有趣的点,感兴趣的的小伙伴可以在小册中观看。