Antd Form相信大家并不陌生,在中后台业务中,表单页面经常用到,但是大家知道它是如何设计和实现的吗?本文并不涉及具体源码分析,而是手把手带你实现一个简易版的Antd Form。

1、Form组件解决的问题

我们从官网摘下来一段Form代码,可以很清晰的看出一个简单的表单,主要是为了统一收集和校验组件的值。

  1. <Form
  2. onFinish={(values) => {
  3. console.log('values', values)
  4. }}
  5. >
  6. <Form.Item
  7. label="Username"
  8. name="username"
  9. rules={[{ required: true, message: 'Please input your username!' }]}
  10. >
  11. <Input />
  12. </Form.Item>
  13. <Form.Item
  14. label="Password"
  15. name="password"
  16. rules={[{ required: true, message: 'Please input your password!' }]}
  17. >
  18. <Input.Password />
  19. </Form.Item>
  20. <Form.Item>
  21. <Button type="primary" htmlType="submit">
  22. Submit
  23. </Button>
  24. </Form.Item>
  25. </Form>

那么它是如何做到统一收集和校验呢?原理很简单,只需要通过监听表单组件的onChange事件,获取表单项的 value,根据定义的校验规则对 value 进行检验,生成检验状态和检验信息,再通过setState驱动视图更新,展示组件的值以及校验信息即可。

2、Antd Form 是怎么实现的

要实现上面的方案需要解决这几个问题:

  • 如何实时收集组件的数据?
  • 如何对组件的数据进行校验?
  • 如何更新组件的数据?
  • 如何跨层级传递传递
  • 表单提交

接下来我们就带着这几个问题,一起来一步步实现

3、目录结构

image.png

  • src/index.tsx用于放测试代码
  • src/components/Form文件夹用于存放Form组件信息
    • interface.ts用于存放数据类型
    • useForm存放数据仓库内容
    • index.tsx导出Form组件相关
    • FiledContext存放Form全局context
    • Form外层组件
    • Filed内层组件

      4、数据类型定义

      本项目采用ts来搭建,所以我们先定义数据类型; ```typescript // src/components/Form/interface.ts

export type StoreValue = any; export type Store = Record; export type NamePath = string | number;

export interface Callbacks { onFinish?: (values: Values) => void; }

export interface FormInstance { getFieldValue: (name: NamePath) => StoreValue; submit: () => void; getFieldsValue: () => Values; setFieldsValue: (newStore: Store) => void; setCallbacks: (callbacks: Callbacks) => void; }

  1. <a name="eb82d8a3"></a>
  2. # 5、数据仓库
  3. 因为我们的表单一定是各种各样不同的数据项,比如input、checkbox、radio等等,如果这些组件每一个都要自己管理自己的值,那组件的数据管理太杂乱了,我们做这个也就没什么必要性了。那要如何统一管理呢?<br />其实就是我们自己定义一个数据仓库,在最顶层将定义的仓库操作和数据提供给下层。这样我们就可以在每层都可以操作数据仓库了。<br />数据仓库的定义,说白了就是一些读和取的操作,将所有的操作都定义在一个文件,代码如下:
  4. ```typescript
  5. // src/components/Form/useForm.ts
  6. import { useRef } from "react";
  7. import type { Store, NamePath, Callbacks, FormInstance } from "./interface";
  8. class FormStore {
  9. private store: Store = {};
  10. private callbacks: Callbacks = {};
  11. getFieldsValue = () => {
  12. return { ...this.store };
  13. };
  14. getFieldValue = (name: NamePath) => {
  15. return this.store[name];
  16. };
  17. setFieldsValue = (newStore: Store) => {
  18. this.store = {
  19. ...this.store,
  20. ...newStore,
  21. };
  22. };
  23. setCallbacks = (callbacks: Callbacks) => {
  24. this.callbacks = { ...this.callbacks, ...callbacks };
  25. };
  26. submit = () => {
  27. const { onFinish } = this.callbacks;
  28. if (onFinish) {
  29. onFinish(this.getFieldsValue());
  30. }
  31. };
  32. getForm = (): FormInstance => {
  33. return {
  34. getFieldsValue: this.getFieldsValue,
  35. getFieldValue: this.getFieldValue,
  36. setFieldsValue: this.setFieldsValue,
  37. submit: this.submit,
  38. setCallbacks: this.setCallbacks,
  39. };
  40. };
  41. }

当然,数据仓库不能就这么放着,我们需要把里面的内容暴露出去。这里用ref来保存,来确保组件初次渲染和更新阶段用的都是同一个数据仓库实例;

  1. // src/components/Form/useForm.ts
  2. export default function useForm<Values = any>(
  3. form?: FormInstance<Values>
  4. ): [FormInstance<Values>] {
  5. const formRef = useRef<FormInstance>();
  6. if (!formRef.current) {
  7. if (form) {
  8. formRef.current = form;
  9. } else {
  10. const formStore = new FormStore();
  11. formRef!.current = formStore.getForm();
  12. }
  13. }
  14. return [formRef.current];
  15. }

6、实时收集组件的数据

我们先来定义一下表单的结构,如下代码所示:

  1. // src/index.tsx
  2. import React from "react";
  3. import Form, { Field } from "./components/Form";
  4. const index: React.FC = () => {
  5. return (
  6. <Form
  7. onFinish={(values) => {
  8. console.log("values", values);
  9. }}
  10. >
  11. <Field name={"userName"}>
  12. <input placeholder="用户名" />
  13. </Field>
  14. <Field name={"password"}>
  15. <input placeholder="密码" />
  16. </Field>
  17. <button type="submit">提交</button>
  18. </Form>
  19. );
  20. };
  21. export default index;

定义了数据仓库,就要想办法在每一层都要拥有消费它的能力,所以这里在最顶层用context来跨层级数据传递。
通过顶层的form将数据仓库向下传递,代码如下:

  1. // src/components/Form/Form.tsx
  2. import React from "react";
  3. import FieldContext from "./FieldContext";
  4. import useForm from "./useForm";
  5. import type { Callbacks, FormInstance } from "./interface";
  6. interface FormProps<Values = any> {
  7. form?: FormInstance<Values>;
  8. onFinish?: Callbacks<Values>["onFinish"];
  9. }
  10. const Form: React.FC<FormProps> = (props) => {
  11. const { children, onFinish, form } = props;
  12. const [formInstance] = useForm(form);
  13. formInstance.setCallbacks({ onFinish });
  14. return (
  15. <form
  16. onSubmit={(e) => {
  17. e.preventDefault();
  18. formInstance.submit();
  19. }}
  20. >
  21. <FieldContext.Provider value={formInstance}>
  22. {children}
  23. </FieldContext.Provider>
  24. </form>
  25. );
  26. };
  27. export default Form;

子组件来做存与取的操作。这里有个疑问,为什么不直接在input、radio这些组件上直接加入存取操作,非得在外面包一层Field(在正式的antd中是Form.Item)呢?这是因为需要在它基础的能力上扩展一些能力。

  1. // src/components/Form/Field.tsx
  2. import React, { ChangeEvent } from "react";
  3. import FieldContext from "./FieldContext";
  4. import type { NamePath } from "./interface";
  5. const Field: React.FC<{ name: NamePath }> = (props) => {
  6. const { getFieldValue, setFieldsValue } = React.useContext(FieldContext);
  7. const { children, name } = props;
  8. const getControlled = () => {
  9. return {
  10. value: getFieldValue && getFieldValue(name),
  11. onChange: (e: ChangeEvent<HTMLInputElement>) => {
  12. const newValue = e?.target?.value;
  13. setFieldsValue?.({ [name]: newValue });
  14. },
  15. };
  16. };
  17. return React.cloneElement(children as React.ReactElement, getControlled());
  18. };
  19. export default Field;

这样我们就完成了数据收集以及保存的功能了。

很简单吧,我们来试一下onFinish操作!
image.png

接下来我们继续完善其他的功能。

7、完善组件渲染

我们来修改一下Form的代码,加入一条设置默认值:

  1. // src/index.tsx
  2. import React, { useEffect } from "react";
  3. import Form, { Field, useForm } from "./components/Form";
  4. const index: React.FC = () => {
  5. const [form] = useForm();
  6. // 新加入代码
  7. useEffect(() => {
  8. form.setFieldsValue({ username: "default" });
  9. }, []);
  10. return (
  11. // ...省略...
  12. );
  13. };
  14. export default index;

来看一眼页面,发现我们设置的默认值并没有展示在表单中,但是我们提交的时候还是可以打印出数据的,证明我们的数据是已经存入到store中了,只是没有渲染到组件中,接下来我们需要做的工作就是根据store变化完成组件表单的响应功能。
image.png

我们在useForm中加入订阅和取消订阅功能代码;

  1. // 订阅与取消订阅
  2. registerFieldEntities = (entity: FieldEntity) => {
  3. this.fieldEntities.push(entity);
  4. return () => {
  5. this.fieldEntities = this.fieldEntities.filter((item) => item !== entity);
  6. const { name } = entity.props;
  7. name && delete this.store[name];
  8. };
  9. };

forceUpdate的作用是进行子组件更新;

  1. // src/components/Form/Field.tsx
  2. // ...省略...
  3. const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
  4. useLayoutEffect(() => {
  5. const unregister =
  6. registerFieldEntities &&
  7. registerFieldEntities({
  8. props,
  9. onStoreChange: forceUpdate,
  10. });
  11. return unregister;
  12. }, []);
  13. // ...省略...

当然光是注册是不够的,我们需要在设置值的时候完成响应;

  1. // src/components/Form/useForm.tsx
  2. setFieldsValue = (newStore: Store) => {
  3. this.store = {
  4. ...this.store,
  5. ...newStore,
  6. };
  7. // 新加入代码
  8. // update Filed
  9. this.fieldEntities.forEach((entity) => {
  10. Object.keys(newStore).forEach((k) => {
  11. if (k === entity.props.name) {
  12. entity.onStoreChange();
  13. }
  14. });
  15. });
  16. };

我们来看一下效果,发现组件已经可以更新啦;
image.png

8、加入校验功能

到现在为止,我们发现提交表单还没有校验功能。表单校验通过,则执行onFinish。表单校验的依据就是Field的rules,表单校验通过,则执行onFinish,失败则执行onFinishFailed。接下来我们来实现一个简单的校验。
修改代码结构,添加校验信息;

  1. import React, { useEffect } from "react";
  2. import Form, { Field, useForm } from "./components/Form";
  3. const nameRules = { required: true, message: "请输入姓名!" };
  4. const passworRules = { required: true, message: "请输入密码!" };
  5. const index: React.FC = () => {
  6. const [form] = useForm();
  7. useEffect(() => {
  8. form.setFieldsValue({ username: "default" });
  9. }, []);
  10. return (
  11. <Form
  12. onFinish={(values) => {
  13. console.log("values", values);
  14. }}
  15. onFinishFailed={(err) => {
  16. console.log("err", err);
  17. }}
  18. form={form}
  19. >
  20. <Field name={"username"} rules={[nameRules]}>
  21. <input placeholder="用户名" />
  22. </Field>
  23. <Field name={"password"} rules={[passworRules]}>
  24. <input placeholder="密码" type="password" />
  25. </Field>
  26. <button type="submit">提交</button>
  27. </Form>
  28. );
  29. };
  30. export default index;

添加validateField方法进行表单校验。注意:此版本校验只添加了required校验,后续小伙伴们可以根据自己的需求继续完善哦!

  1. // src/components/Form/useForm.tsx
  2. // ...省略...
  3. validateField = () => {
  4. const err: any[] = [];
  5. this.fieldEntities.forEach((entity) => {
  6. const { name, rules } = entity.props;
  7. const value: NamePath = name && this.getFieldValue(name);
  8. let rule = rules?.length && rules[0];
  9. if (rule && rule.required && (value === undefined || value === "")) {
  10. name && err.push({ [name]: rule && rule.message, value });
  11. }
  12. });
  13. return err;
  14. };

我们只需要在form提交的时候判断一下就可以啦;

  1. submit = () => {
  2. const { onFinish, onFinishFailed } = this.callbacks;
  3. // 调用校验方法
  4. const err = this.validateField();
  5. if (err.length === 0) {
  6. onFinish && onFinish(this.getFieldsValue());
  7. } else {
  8. onFinishFailed && onFinishFailed(err);
  9. }
  10. };

密码为空时的实现效果;
image.png

账号密码都不为空时的实现效果;
image.png

做到这里,我们已经基本实现了一个Antd Form表单了,但是细节功能还需要慢慢去完善,感兴趣的小伙伴们可以接着继续向下做!

9、总结

其实我们在看Antd Form源码的时候会发现它是基于rc-field-form来写的。所以想继续向下写的小伙伴可以下载rc-field-form源码,边学习边写,这样就可以事半功倍了,攻克源码!
image.png

本篇文章代码地址:https://github.com/linhexs/vite-react-ts-form