最近有一個很是複雜的表單需求,可能須要對錶單作「任何事情」,現有的 UI
組件庫選用的是 Ant Design
簡稱 antd
。它的 Form
表單已經幫咱們把「表單項校驗」、「表單項錯誤信息」等常見操做所有封裝好了。使用起來很是便捷。翻看了 antd Form
源碼發現其核心能力都是經過 rc-field-form
庫,提供出來的。所以閱讀它的源碼將是做者項目開始前必需要作的。
本文將模擬 rc-field-form
庫,手寫一個「學習版」 ,深刻學習其思想。javascript
若是本文對你有所幫助,請點個👍 吧!html
rc-field-form
使用的是 Dumi
和 father-build
對組件庫進行打包,爲了保持一致,做者也將使用這兩個工具來完成項目。前端
dumi
中文發音嘟米,是一款爲組件開發場景而生的文檔工具,與 father-builder
一塊兒爲開發者提供一站式的組件開發體驗, father-builder
負責構建,而 dumi
負責組件開發及組件文檔生成。
java
father-build
屬於 father
(集文檔與組件打包一體的庫)的一部分,專一於組件打包。
node
使用 @umijs/create-dumi-lib
來初始化項目。這個腳手架整合了上面說起的兩個工具。react
mkdir lion-form // 建立lion-form文件夾
cd lion-form // 進入文件夾
npm init -y // 初始化 package.json
npx @umijs/create-dumi-lib // 初始化總體項目結構
複製代碼
├──README.md // 文檔說明
├──node_modules // 依賴包文件夾
├──package.json // npm 包管理
├──.editorconfig // 編輯器風格統一配置文件
├──.fatherrc.ts // 打包配置
├──.umirc.ts // 文檔配置
├──.prettierrc // 文本格式化配置
├──tsconfig.json // ts 配置
└──docs // 倉庫公共文檔
└──index.md // 組件庫文檔首頁
└──src
└──index.js // 組件庫入口文件
複製代碼
npm start 或 yarn start
複製代碼
集文檔,打包爲一體的組件庫就這樣快速的搭建完成了。下面就讓咱們來手寫一個 rc-field-form
吧。
完整代碼地址git
對於常用 react
開發的同窗來講, antd
應該都不會陌生。開發中常常遇到的表單大多會使用 antd
中的 Form
系列組件完成,而 rc-field-form
又是 antd Form
的重要組成部分,或者說 antd Form
是對 rc-field-form
的進一步的封裝。
想要學習它的源碼,首先仍是得知道如何使用它,否則難以理解源碼的一些深層次的含義。
github
首先來實現以下圖所示的表單,相似於咱們寫過的登陸註冊頁面。
代碼示例:npm
import React, { Component, useEffect} from 'react'
import Form, { Field } from 'rc-field-form'
import Input from './Input'
// name 字段校驗規則
const nameRules = {required: true, message: '請輸入姓名!'}
// password 字段校驗規則
const passwordRules = {required: true, message: '請輸入密碼!'}
export default function FieldForm(props) {
// 獲取 form 實例
const [form] = Form.useForm()
// 提交表單時觸發
const onFinish = (val) => {
console.log('onFinish', val)
}
// 提交表單失敗時觸發
const onFinishFailed = (val) => {
console.log('onFinishFailed', val)
}
// 組件初始化時觸發,它是React原生Hook
useEffect(() => {
form.setFieldsValue({username: 'lion'})
}, [])
return (
<div> <h3>FieldForm</h3> <Form form={form} onFinish={onFinish} onFinishFailed={onFinishFailed}> <Field name='username' rules={[nameRules]}> <Input placeholder='請輸入姓名' /> </Field> <Field name='password' rules={[passwordRules]}> <Input placeholder='請輸入密碼' /> </Field> <button>Submit</button> </Form> </div>
)
}
// input簡單封裝
const Input = (props) => {
const { value,...restProps } = props;
return <input {...restProps} value={value} />;
};
複製代碼
這種寫法仍是很是便捷的,再也不須要像 antd3
同樣使用高階函數包裹一層。而是直接經過 Form.useForm()
獲取到 formInstance
實例, formInstance
實例身上承載了表單須要的全部數據及方法。
經過 form.setFieldsValue({username: 'lion'})
這段代碼就不難發現,能夠經過 form
去手動設置 username
的初始值。也能夠理解成全部的表單項都被 formInstance
實例接管了,可使用 formInstance
實例作到任何操做表單項的事情。 formInstance
實例也是整個庫的核心。json
經過對 rc-field-form
源碼的學習,咱們先來搭建一個基礎框架。
Form.useForm()
獲取 formInstance
實例;formInstance
實例對外提供了全局的方法如 setFieldsValue
、 getFieldsValue
;context
讓全局能夠共享 formInstance
實例。src/useForm.tsx
import React , {useRef} from "react";
class FormStore {
// stroe 用來存儲表單數據,它的格式:{"username": "lion"}
private store: any = {};
// 用來存儲每一個 Field 的實例數據,所以在store中能夠經過 fieldEntities 來訪問到每一個表單項
private fieldEntities: any = [];
// 表單項註冊到 fieldEntities
registerField = (entity:any)=>{
this.fieldEntities.push(entity)
return () => {
this.fieldEntities = this.fieldEntities.filter((item:any) => item !== entity)
delete this.store[entity.props.name]
}
}
// 獲取單個字段值
getFieldValue = (name:string) => {
return this.store[name]
}
// 獲取全部字段值
getFieldsValue = () => {
return this.store
}
// 設置字段的值
setFieldsValue = (newStore:any) => {
// 更新store的值
this.store = {
...this.store,
...newStore,
}
// 經過 fieldEntities 獲取到全部表單項,而後遍歷去調用表單項的 onStoreChange 方法更新表單項
this.fieldEntities.forEach((entity:any) => {
const { name } = entity.props
Object.keys(newStore).forEach(key => {
if (key === name) {
entity.onStoreChange()
}
})
})
}
// 提交數據,這裏只簡單的打印了store中的數據。
submit = ()=>{
console.log(this.getFieldsValue());
}
// 提供FormStore實例方法
getForm = (): any => ({
getFieldValue: this.getFieldValue,
getFieldsValue: this.getFieldsValue,
setFieldsValue: this.setFieldsValue,
registerField: this.registerField,
submit: this.submit,
});
}
// 建立單例formStore
export default function useForm(form:any) {
const formRef = useRef();
if (!formRef.current) {
if (form) {
formRef.current = form;
} else {
const formStore = new FormStore();
formRef.current = formStore.getForm() as any;
}
}
return [formRef.current]
}
複製代碼
其中 FormStore
是用來存儲全局數據和方法的。 useForm
是對外暴露 FormStore
實例的。從 useForm
的實現能夠看出,藉助 useRef
實現了 FormStore
實例的單例模式。
定義了全局 context
。
import * as React from 'react';
const warningFunc: any = () => {
console.log("warning");
};
const Context = React.createContext<any>({
getFieldValue: warningFunc,
getFieldsValue: warningFunc,
setFieldsValue: warningFunc,
registerField: warningFunc,
submit: warningFunc,
});
export default Context;
複製代碼
FieldContext
;submit
事件;src/Form.tsx
import React from "react";
import useForm from "./useForm";
import FieldContext from './FieldContext';
export default function Form(props:any) {
const {form, children, ...restProps} = props;
const [formInstance] = useForm(form) as any;
return <form {...restProps} onSubmit={(event: React.FormEvent<HTMLFormElement>) => { event.preventDefault(); event.stopPropagation(); // 調用了formInstance 提供的submit方法 formInstance.submit(); }} > {/* formInstance 當作全局的 context 傳遞下去 */} <FieldContext.Provider value={formInstance}>{children}</FieldContext.Provider> </form> } 複製代碼
FormStore
中;value
以及 onChange
屬性。src/Field.tsx
import React,{Component} from "react";
import FieldContext from "./FieldContext";
export default class Field extends Component {
// Filed 組件獲取 FieldContext
static contextType = FieldContext;
private cancelRegisterFunc:any;
// Field 掛載時,把本身註冊到FieldContext中,也就是上面說起的 fieldEntities 數組中。
componentDidMount() {
const { registerField } = this.context;
this.cancelRegisterFunc = registerField(this);
}
// Field 組件卸載時,調用取消註冊,就是從 fieldEntities 中刪除。
componentWillUnmount() {
if (this.cancelRegisterFunc) {
this.cancelRegisterFunc()
}
}
// 每一個 Field 組件都應該包含 onStoreChange 方法,用來更新本身
onStoreChange = () => {
this.forceUpdate()
}
// Field 中傳進來的子元素變爲受控組件,也就是主動添加上 value 和 onChange 屬性方法
getControlled = () => {
const { name } = this.props as any;
const { getFieldValue, setFieldsValue } = this.context
return {
value: getFieldValue(name),
onChange: (event:any) => {
const newValue = event.target.value
setFieldsValue({[name]: newValue})
},
}
}
render() {
const {children} = this.props as any;
return React.cloneElement(children, this.getControlled())
}
}
複製代碼
Form
組件的基礎框架就此搭建完成了,它已經能夠實現一些簡單的效果,下面咱們在 docs
目錄寫個例子。
docs/examples/basic.tsx
...省略了部分代碼
export default function BasicForm(props) {
const [form] = Form.useForm()
useEffect(() => {
form.setFieldsValue({username: 'lion'})
}, [])
return (
<Form form={form}> <Field name='username'> <Input placeholder='請輸入姓名' /> </Field> <Field name='password'> <Input placeholder='請輸入密碼' /> </Field> <button>提交</button> </Form>
)
}
複製代碼
解析:
form.setFieldsValue({username: 'lion'})
方法;setFieldsValue
根據傳入的參數,更新了 store
值,並經過 name
找到相應的 Field
實例;Field
實例的 onStoreChange
方法,更新組件;antd
文檔上有這麼一句話:「咱們推薦使用 Form.useForm
建立表單數據域進行控制。若是是在 class component
下,你也能夠經過 ref
獲取數據域」。
使用方式以下:
export default class extends React.Component {
formRef = React.createRef()
componentDidMount() {
this.formRef.current.setFieldsValue({username: 'lion'})
}
render() {
return (
<Form ref={this.formRef}> <Field name='username'> <Input /> </Field> <Field name='password'> <Input /> </Field> <button>Submit</button> </Form>
)
}
}
複製代碼
經過傳遞 formRef
給 Form
組件。獲取 Form
的 ref
實例,可是咱們知道 Form
是經過函數組件建立的,函數組件沒有實例,沒法像類組件同樣能夠接收 ref
。所以須要藉助 React.forwardRef
與 useImperativeHandle
。
src/Form.tsx
export default React.forwardRef((props: any, ref) => {
... 省略
const [formInstance] = useForm(form) as any;
React.useImperativeHandle(ref, () => formInstance);
... 省略
})
複製代碼
React.forwardRef
解決了,函數組件沒有實例,沒法像類組件同樣能夠接收 ref
屬性的問題;useImperativeHandle
可讓你在使用 ref
時,決定暴露什麼給父組件,這裏咱們將 formInstance
暴露出去,這樣父組件就可使用 formInstance
了。
關於 React Hooks
不熟悉的同窗能夠閱讀做者的這篇文章:React Hook 從入門應用到編寫自定義 Hook。
點擊查看本小節代碼
以前咱們都是這樣去初始化表單的值:
useEffect(() => {
form.setFieldsValue({username: 'lion'})
}, [])
複製代碼
顯然這樣初始化是不夠優雅的,官方提供了 initialValues
屬性讓咱們去初始化表單項的,下面讓咱們來支持它吧。
src/useForm.ts
class FormStore {
// 定義初始值變量
private initialValues = {};
setInitialValues = (initialValues:any,init:boolean)=>{
// 初始值賦給initialValues變量,這樣 formInstance 就一直會保存一份初始值
this.initialValues = initialValues;
// 同步給store
if(init){
// setValues 是rc-field-form提供的工具類,做者這裏所有copy過來了,不用具體關注工具類的實現
// 這裏知道 setValues 會遞歸遍歷 initialValues 返回一個新的對象。
this.store = setValues({}, initialValues, this.store);
}
}
getForm = (): any => ({
... 這裏省略了外部使用方法
// 建立一個方法,返回內部使用的一些方法
getInternalHooks:()=>{
return {
setInitialValues: this.setInitialValues,
}
}
});
}
複製代碼
src/Form.tsx
export default React.forwardRef((props: any, ref) => {
const [formInstance] = useForm(form) as any;
const {
setInitialValues,
} = formInstance.getInternalHooks();
// 第一次渲染時 setInitialValues 第二個參數是true,表示初始化。之後每次渲染第二個參數都爲false
const mountRef = useRef(null) as any;
setInitialValues(initialValues, !mountRef.current);
if (!mountRef.current) {
mountRef.current = true;
}
...
}
複製代碼
useRef
返回一個可變的 ref
對象,其 current
屬性被初始化爲傳入的參數( initialValue
)。返回的 ref
對象在組件的整個生命週期內保持不變。
在此以前,提交 submit
只能打印 store
裏面的值,這並不能知足咱們的需求,咱們須要它能夠回調指定函數。
src/useForm.ts
class FormStore {
private callbacks = {} as any; //用於存放回調方法
// 設置callbases
setCallbacks = (callbacks:any) => {
this.callbacks = callbacks;
}
// 暴露setCallbacks方法到全局
getForm = (): any => ({
...
getInternalHooks: () => {
return {
setInitialValues: this.setInitialValues,
setCallbacks: this.setCallbacks
};
},
});
// submit 時,去callbacks中取出須要回調方法執行
submit = () => {
const { onFinish } = this.callbacks;
onFinish(this.getFieldsValue())
};
}
複製代碼
src/Form.tsx
export default React.forwardRef((props: any, ref) => {
const { ..., onFinish, ...restProps } = props;
const [formInstance] = useForm(form) as any;
const {
setCallbacks,
} = formInstance.getInternalHooks();
// 獲取外部傳入的onFinish函數,註冊到callbacks中,這樣submit的時候就會執行它
setCallbacks({
onFinish
})
...
}
複製代碼
經過 shouldUpdate
屬性控制 Field
的更新邏輯。當 shouldUpdate
爲方法時,表單的每次數值更新都會調用該方法,提供原先的值與當前的值以供你比較是否須要更新。
src/Field.tsx
export default class Field extends Component {
// 只改造這一個函數,根據傳入的 shouldUpdate 函數的返回值來判斷是否須要更新。
onStoreChange = (prevStore:any,curStore:any) => {
const { shouldUpdate } = this.props as any;
if (typeof shouldUpdate === 'function') {
if(shouldUpdate(prevStore,curStore)){
this.forceUpdate();
}
}else{
this.forceUpdate();
}
}
}
複製代碼
src/useForm.js
class FormStore {
// 以前寫了一個registerField是用來設置Field實例的存儲,再添加一個獲取的方法
getFieldEntities = ()=>{
return this.fieldEntities;
}
// 新增一個方法,用來通知Field組件更新
notifyObservers = (prevStore:any) => {
this.getFieldEntities().forEach((entity: any) => {
const { onStoreChange } = entity;
onStoreChange(prevStore,this.getFieldsValue());
});
}
// 如今設置字段值以後直接調用 notifyObservers 方法進行更新組件
setFieldsValue = (curStore: any) => {
const prevStore = this.store;
if (curStore) {
this.store = setValues(this.store, curStore);
}
this.notifyObservers(prevStore);
};
}
複製代碼
好了更新的邏輯也差很少寫完了,雖然並不是跟原庫保持一致(原庫考慮了更多的邊界條件),可是足矣幫助咱們理解其思想。
點擊查看本小節代碼
根據用戶設置的校驗規則,在提交表單時或者任何其餘時候對錶單進行校驗並反饋錯誤。
讀源碼的時候發現,底層作校驗使用的是 async-validator 作的。
它是一個能夠對數據進行異步校驗的庫, ant.design
與 Element ui
的 Form
組件都使用了它作底層校驗。
npm i async-validator
複製代碼
import AsyncValidator from 'async-validator'
// 校驗規則
const descriptor = {
username: [
{
required: true,
message: '請填寫用戶名'
},
{
pattern: /^\w{6}$/
message: '用戶名長度爲6'
}
]
}
// 根據校驗規則構造一個 validator
const validator = new AsyncValidator(descriptor)
const data = {
username: 'username'
}
validator.validate(data).then(() => {
// 校驗經過
}).catch(({ errors, fields }) => {
// 校驗失敗
});
複製代碼
關於 async-validator
詳細使用方式能夠查閱它的 github 文檔。
<Field
label="Username"
name="username"
rules={[
{ required: true, message: 'Please input your username!' },
{ pattern: /^\w{6}$/ }
]}
>
<Input />
</Form.Item>
複製代碼
若是校驗不經過,則執行 onFinishFailed
回調函數。
[注意] 原庫還支持在 rules
中設置自定義校驗函數,本組件中已省略。
src/useForm.ts
class FormStore {
// 字段驗證
validateFields = ()=>{
// 用來存放字段驗證結果的promise
const promiseList:any = [];
// 遍歷字段實例,調用Field組件的驗證方法,獲取返回的promise,同時push到promiseList中
this.getFieldEntities().forEach((field:any)=>{
const {name, rules} = field.props
if (!rules || !rules.length) {
return;
}
const promise = field.validateRules();
promiseList.push(
promise
.then(() => ({ name: name, errors: [] }))
.catch((errors:any) =>
Promise.reject({
name: name,
errors,
}),
),
);
})
// allPromiseFinish 是一個工具方法,處理 promiseList 列表爲一個 promise
// 大體邏輯:promiseList 中只要有一個是 rejected 狀態,那麼輸出的promise 就應該是 reject 狀態
const summaryPromise = allPromiseFinish(promiseList);
const returnPromise = summaryPromise
.then(
() => {
return Promise.resolve(this.getFieldsValue());
},
)
.catch((results) => {
// 合併後的promise若是是reject狀態就返回錯誤結果
const errorList = results.filter((result:any) => result && result.errors.length);
return Promise.reject({
values: this.getFieldsValue(),
errorFields: errorList
});
});
// 捕獲錯誤
returnPromise.catch(e => e);
return returnPromise;
}
// 提交表單的時候進行調用字段驗證方法,驗證經過回調onFinish,驗證失敗回調onFinishFailed
submit = () => {
this.validateFields()
.then(values => {
const { onFinish } = this.callbacks;
if (onFinish) {
try {
onFinish(values);
} catch (err) {
console.error(err);
}
}
})
.catch(e => {
const { onFinishFailed } = this.callbacks;
if (onFinishFailed) {
onFinishFailed(e);
}
});
};
}
複製代碼
如今的核心問題就是 Field
組件如何根據 value
和 rules
去獲取校驗結果。
src/Field.tsx
export default class Field extends Component {
private validatePromise: Promise<string[]> | null = null
private errors: string[] = [];
// Field組件根據rules校驗的函數
validateRules = ()=>{
const { getFieldValue } = this.context;
const { name } = this.props as any;
const currentValue = getFieldValue(name); // 獲取到當前的value值
// async-validator 庫的校驗結果是 promise
const rootPromise = Promise.resolve().then(() => {
// 獲取全部rules規則
let filteredRules = this.getRules();
// 獲取執行校驗的結果promise
const promise = this.executeValidate(name,currentValue,filteredRules);
promise
.catch(e => e)
.then((errors: string[] = []) => {
if (this.validatePromise === rootPromise) {
this.validatePromise = null;
this.errors = errors; // 存儲校驗結果信息
this.forceUpdate(); // 更新組件
}
});
return promise;
});
this.validatePromise = rootPromise;
return rootPromise;
}
// 獲取 rules 校驗結果
public getRules = () => {
const { rules = [] } = this.props as any;
return rules.map(
(rule:any) => {
if (typeof rule === 'function') {
return rule(this.context);
}
return rule;
},
);
};
// 執行規則校驗
executeValidate = (namePath:any,value:any,rules:any)=>{
let summaryPromise: Promise<string[]>;
summaryPromise = new Promise(async (resolve, reject) => {
// 多個規則遍歷校驗,只要有其中一條規則校驗失敗,就直接不須要往下進行了。返回錯誤結果便可。
for (let i = 0; i < rules.length; i += 1) {
const errors = await this.validateRule(namePath, value, rules[i]);
if (errors.length) {
reject(errors);
return;
}
}
resolve([]);
});
return summaryPromise;
}
// 對單挑規則進行校驗的方法
validateRule = async (name:any,value:any,rule:any)=>{
const cloneRule = { ...rule };
// 根據name以及校驗規則生成一個校驗對象
const validator = new RawAsyncValidator({
[name]: [cloneRule],
});
let result = [];
try {
// 把value值傳入校驗對象,進行校驗,返回校驗結果
await Promise.resolve(validator.validate({ [name]: value }));
}catch (e) {
if(e.errors){
result = e.errors.map((c:any)=>c.message)
}
}
return result;
}
}
複製代碼
到此爲止咱們就完成了一個簡單的 Form
表單邏輯模塊的編寫。本文每小節的代碼均可以在 github
上查看,並且在 dosc
目錄下有相應的使用案例能夠查看。
點擊查看本小節代碼
前面介紹過了,這個項目採用的是 dumi + father-builder
工具,所以在發佈到 npm
這塊是特別方便的,在登陸 npm
以後,只須要執行 npm run release
便可。
線上包地址:lion-form
本地項目經過執行命令 npm i lion-form
便可使用。
一、配置 .umirc.ts
import { defineConfig } from 'dumi';
let BaseUrl = '/lion-form'; // 倉庫的路徑
export default defineConfig({
// 網站描述配置
mode: 'site',
title: 'lion form',
description: '前端組件開發。',
// 打包路徑配置
base: BaseUrl,
publicPath: BaseUrl + '/', // 打包文件時,引入地址生成 BaseUrl/xxx.js
outputPath: 'docs-dist',
exportStatic: {}, // 對每隔路由輸出html
dynamicImport: {}, // 動態導入
hash: true, //加hash配置,清除緩存
manifest: {
// 內部發布系統規定必須配置
fileName: 'manifest.json',
},
// 多國語順序
locales: [
['en-US', 'English'],
['zh-CN', '中文'],
],
// 主題
theme: {
'@c-primary': '#16c35f',
},
});
複製代碼
配置完成後,執行 npm run deploy
命令。
二、設置 github pages
設置完成後,再次執行 npm run deploy
,便可訪問線上組件庫文檔地址。
本文從工程搭建,源碼編寫以及線上發佈這幾個步驟去描述如何完整的編寫一個 React
通用組件庫。
經過 Form
組件庫的編寫也讓咱們學習到:
Form
組件, Field
組件是經過一個全局的 context
做爲紐帶關聯起來的,它們共享 FormStore
中的數據方法,很是相似 redux
工做原理。Field
組件實例註冊到全局的 FormStore
中,實現了在任意位置調用 Field
組件實例的屬性和方法,這也是爲何 Field
使用 class
組件編寫的緣由(由於函數組件沒有實例)。async-validator
實現了表單驗證的功能。
學習優秀開源庫的源碼過程是不開心的,可是收穫會是很是大的, Dont Worry Be Happy
。