手把手教你實現一個常用的 antd form 元件

語言: CN / TW / HK

1、Form元件解決的問題

我們從官網摘下來一段Form程式碼,可以很清晰的看出一個簡單的表單,主要是為了統一收集和校驗元件的值。

<Form
     onFinish={(values) => {
       console.log('values', values)
     }}
   >
     <Form.Item
       label="Username"
       name="username"
       rules={[{ required: true, message: 'Please input your username!' }]}
     >
       <Input />
     </Form.Item>
     <Form.Item
       label="Password"
       name="password"
       rules={[{ required: true, message: 'Please input your password!' }]}
     >
       <Input.Password />
     </Form.Item>
     <Form.Item>
       <Button type="primary" htmlType="submit">
         Submit
       </Button>
     </Form.Item>
   </Form>

那麼它是如何做到統一收集和校驗呢?原理很簡單,只需要通過監聽表單元件的onChange事件,獲取表單項的 value,根據定義的校驗規則對 value 進行檢驗,生成檢驗狀態和檢驗資訊,再通過setState驅動檢視更新,展示元件的值以及校驗資訊即可。

2、Antd Form 是怎麼實現的

要實現上面的方案需要解決這幾個問題:

  •  如何實時收集元件的資料?
  •  如何對元件的資料進行校驗?
  •  如何更新元件的資料?
  •  如何跨層級傳遞傳遞
  •  表單提交

接下來我們就帶著這幾個問題,一起來一步步實現

3、目錄結構

1659421573127.jpg

  •  src/index.tsx用於放測試程式碼
  •  src/components/Form資料夾用於存放Form元件資訊
  •  interface.ts用於存放資料型別
  •  useForm存放資料倉庫內容
  •  index.tsx匯出Form元件相關
  •  FiledContext存放Form全域性context
  •  Form外層元件
  •  Filed內層元件

4、資料型別定義

本專案採用ts來搭建,所以我們先定義資料型別;

// src/components/Form/interface.ts
export type StoreValue = any;
export type Store = Record<string, StoreValue>;
export type NamePath = string | number;
export interface Callbacks<Values = any> {
 onFinish?: (values: Values) => void;
}
export interface FormInstance<Values = any> {
 getFieldValue: (name: NamePath) => StoreValue;
 submit: () => void;
 getFieldsValue: () => Values;
 setFieldsValue: (newStore: Store) => void;
 setCallbacks: (callbacks: Callbacks) => void;
}

5、資料倉庫

因為我們的表單一定是各種各樣不同的資料項,比如input、checkbox、radio等等,如果這些元件每一個都要自己管理自己的值,那元件的資料管理太雜亂了,我們做這個也就沒什麼必要性了。那要如何統一管理呢?其實就是我們自己定義一個數據倉庫,在最頂層將定義的倉庫操作和資料提供給下層。這樣我們就可以在每層都可以操作資料倉庫了。資料倉庫的定義,說白了就是一些讀和取的操作,將所有的操作都定義在一個檔案,程式碼如下:

// src/components/Form/useForm.ts
import { useRef } from "react";
import type { Store, NamePath, Callbacks, FormInstance } from "./interface";
class FormStore {
 private store: Store = {};
 private callbacks: Callbacks = {};
 getFieldsValue = () => {
   return { ...this.store };
 };
 getFieldValue = (name: NamePath) => {
   return this.store[name];
 };
 setFieldsValue = (newStore: Store) => {
   this.store = {
     ...this.store,
     ...newStore,
   };
 };
 setCallbacks = (callbacks: Callbacks) => {
   this.callbacks = { ...this.callbacks, ...callbacks };
 };
 submit = () => {
   const { onFinish } = this.callbacks;
   if (onFinish) {
     onFinish(this.getFieldsValue());
   }
 };
 getForm = (): FormInstance => {
   return {
     getFieldsValue: this.getFieldsValue,
     getFieldValue: this.getFieldValue,
     setFieldsValue: this.setFieldsValue,
     submit: this.submit,
     setCallbacks: this.setCallbacks,
   };
 };
}

當然,資料倉庫不能就這麼放著,我們需要把裡面的內容暴露出去。這裡用ref來儲存,來確保元件初次渲染和更新階段用的都是同一個資料倉庫例項;

// src/components/Form/useForm.ts
export default function useForm<Values = any>(
 form?: FormInstance<Values>
): [FormInstance<Values>] {
 const formRef = useRef<FormInstance>();
 if (!formRef.current) {
   if (form) {
     formRef.current = form;
   } else {
     const formStore = new FormStore();
     formRef!.current = formStore.getForm();
   }
 }
 return [formRef.current];
}

6、實時收集元件的資料

我們先來定義一下表單的結構,如下程式碼所示:

// src/index.tsx
import React from "react";
import Form, { Field } from "./components/Form";
const index: React.FC = () => {
 return (
   <Form
     onFinish={(values) => {
       console.log("values", values);
     }}
   >
     <Field name={"userName"}>
       <input placeholder="使用者名稱" />
     </Field>
     <Field name={"password"}>
       <input placeholder="密碼" />
     </Field>
     <button type="submit">提交</button>
   </Form>
 );
};
export default index;

定義了資料倉庫,就要想辦法在每一層都要擁有消費它的能力,所以這裡在最頂層用context來跨層級資料傳遞。通過頂層的form將資料倉庫向下傳遞,程式碼如下:

// src/components/Form/Form.tsx
import React from "react";
import FieldContext from "./FieldContext";
import useForm from "./useForm";
import type { Callbacks, FormInstance } from "./interface";
interface FormProps<Values = any> {
 form?: FormInstance<Values>;
 onFinish?: Callbacks<Values>["onFinish"];
}
const Form: React.FC<FormProps> = (props) => {
 const { children, onFinish, form } = props;
 const [formInstance] = useForm(form);
 formInstance.setCallbacks({ onFinish });
 return (
   <form
     onSubmit={(e) => {
       e.preventDefault();
       formInstance.submit();
     }}
   >
     <FieldContext.Provider value={formInstance}>
       {children}
     </FieldContext.Provider>
   </form>
 );
};
export default Form;

子元件來做存與取的操作。這裡有個疑問,為什麼不直接在input、radio這些元件上直接加入存取操作,非得在外面包一層Field(在正式的antd中是Form.Item)呢?這是因為需要在它基礎的能力上擴充套件一些能力。

// src/components/Form/Field.tsx
import React, { ChangeEvent } from "react";
import FieldContext from "./FieldContext";
import type { NamePath } from "./interface";
const Field: React.FC<{ name: NamePath }> = (props) => {
 const { getFieldValue, setFieldsValue } = React.useContext(FieldContext);
 const { children, name } = props;
 const getControlled = () => {
   return {
     value: getFieldValue && getFieldValue(name),
     onChange: (e: ChangeEvent<HTMLInputElement>) => {
       const newValue = e?.target?.value;
       setFieldsValue?.({ [name]: newValue });
     },
   };
 };
 return React.cloneElement(children as React.ReactElement, getControlled());
};
export default Field;

這樣我們就完成了資料收集以及儲存的功能了。

很簡單吧,我們來試一下onFinish操作!圖片

接下來我們繼續完善其他的功能。

7、完善元件渲染

我們來修改一下Form的程式碼,加入一條設定預設值:

// src/index.tsx
import React, { useEffect } from "react";
import Form, { Field, useForm } from "./components/Form";
const index: React.FC = () => {
 const [form] = useForm();
 // 新加入程式碼
 useEffect(() => {
   form.setFieldsValue({ username: "default" });
 }, []);
 return (
    // ...省略...
 );
};
export default index;

來看一眼頁面,發現我們設定的預設值並沒有展示在表單中,但是我們提交的時候還是可以打印出資料的,證明我們的資料是已經存入到store中了,只是沒有渲染到元件中,接下來我們需要做的工作就是根據store變化完成元件表單的響應功能。

我們在useForm中加入訂閱和取消訂閱功能程式碼;

// 訂閱與取消訂閱
 registerFieldEntities = (entity: FieldEntity) => {
   this.fieldEntities.push(entity);
   return () => {
     this.fieldEntities = this.fieldEntities.filter((item) => item !== entity);
     const { name } = entity.props;
     name && delete this.store[name];
   };
 };

forceUpdate的作用是進行子元件更新;

// src/components/Form/Field.tsx
// ...省略...
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
 useLayoutEffect(() => {
   const unregister =
     registerFieldEntities &&
     registerFieldEntities({
       props,
       onStoreChange: forceUpdate,
     });
   return unregister;
 }, []);
// ...省略...

當然光是註冊是不夠的,我們需要在設定值的時候完成響應;

// src/components/Form/useForm.tsx  
 setFieldsValue = (newStore: Store) => {
   this.store = {
     ...this.store,
     ...newStore,
   };
   // 新加入程式碼
   // update Filed
   this.fieldEntities.forEach((entity) => {
     Object.keys(newStore).forEach((k) => {
       if (k === entity.props.name) {
         entity.onStoreChange();
       }
     });
   });
 };

我們來看一下效果,發現元件已經將值更新啦;

8、加入校驗功能

到現在為止,我們發現提交表單還沒有校驗功能。表單校驗通過,則執行onFinish。表單校驗的依據就是Field的rules,表單校驗通過,則執行onFinish,失敗則執行onFinishFailed。接下來我們來實現一個簡單的校驗。

修改程式碼結構

import React, { useEffect } from "react";
import Form, { Field, useForm } from "./components/Form";
const nameRules = { required: true, message: "請輸入姓名!" };
const passworRules = { required: true, message: "請輸入密碼!" };
const index: React.FC = () => {
 const [form] = useForm();
 useEffect(() => {
   form.setFieldsValue({ username: "default" });
 }, []);
 return (
   <Form
     onFinish={(values) => {
       console.log("values", values);
     }}
     onFinishFailed={(err) => {
       console.log("err", err);
     }}
     form={form}
   >
     <Field name={"username"} rules={[nameRules]}>
       <input placeholder="使用者名稱" />
     </Field>
     <Field name={"password"} rules={[passworRules]}>
       <input placeholder="密碼" type="password" />
     </Field>
     <button type="submit">提交</button>
   </Form>
 );
};
export default index;

新增validateField方法進行表單校驗。注意:此版本校驗只添加了required校驗,後續小夥伴們可以根據自己的需求繼續完善哦!

// src/components/Form/useForm.tsx  
// ...省略...
validateField = () => {
   const err: any[] = [];
   this.fieldEntities.forEach((entity) => {
     const { name, rules } = entity.props;
     const value: NamePath = name && this.getFieldValue(name);
     let rule = rules?.length && rules[0];
     if (rule && rule.required && (value === undefined || value === "")) {
       name && err.push({ [name]: rule && rule.message, value });
     }
   });
   return err;
 };

我們只需要在form提交的時候判斷一下就可以啦;

submit = () => {
   const { onFinish, onFinishFailed } = this.callbacks;
   // 呼叫校驗方法
   const err = this.validateField();
   if (err.length === 0) {
     onFinish && onFinish(this.getFieldsValue());
   } else {
     onFinishFailed && onFinishFailed(err);
   }
 };

密碼為空時的實現效果;

賬號密碼都不為空時的實現效果;

做到這裡,我們已經基本實現了一個Antd Form表單了,但是細節功能還需要慢慢去完善,感興趣的小夥伴們可以接著繼續向下做!

其實我們在看Antd Form原始碼的時候會發現它是基於rc-field-form來寫的。所以想繼續向下寫的小夥伴可以下載rc-field-form原始碼,邊學習邊寫,這樣就可以事半功倍了,攻克原始碼!

本篇文章程式碼地址:https://github.com/linhexs/vite-react-ts-form