React 實現高度簡潔的 Form 組件
為什麼要造輪子
在 React 中使用表單有個明顯的痛點,就是需要維護大量的value
和onChange
,比如一個簡單的登錄框:
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
username: "",
password: ""
};
}
onUsernameChange = e => {
this.setState({ username: e.target.value });
};
onPasswordChange = e => {
this.setState({ password: e.target.value });
};
onSubmit = () => {
const data = this.state;
// ...
};
render() {
const { username, password } = this.state;
return (
<form onSubmit={this.onSubmit}>
<input value={username} onChange={this.onUsernameChange} />
<input
type="password"
value={password}
onChange={this.onPasswordChange}
/>
<button>Submit</button>
</form>
);
}
}
這已經是比較簡單的登錄頁,一些涉及到詳情編輯的頁面,十多二十個組件也是常有的。一旦組件多起來就會有許多弊端:
- 不易於維護:佔據大量篇幅,阻礙視野。
- 可能影響性能:
setState
的使用,會導致重新渲染,如果子組件沒有相關優化,相當影響性能。 - 表單校驗:難以統一進行表單校驗。
- ...
總結起來,作為一個開發者,迫切希望能有一個表單組件能夠同時擁有這樣的特性:
- 簡單易用
- 父組件可通過代碼操作表單數據
- 避免不必要的組件重繪
- 支持自定義組件
- 支持表單校驗
表單組件社區上已經有不少方案,例如react-final-form、formik,ant-plus、noform等,許多組件庫也提供了不同方式的支持,如ant-design。
但這些方案都或多或少一些重量,又或者使用方法仍然不夠簡便,自然造輪子纔是最能複合要求的選擇。
怎麼造輪子
這個表單組件實現起來主要分為三部分:
Form
:用於傳遞表單上下文。Field
: 表單域組件,用於自動傳入value
和onChange
到表單組件。FormStore
: 存儲表單數據,封裝相關操作。
為了能減少使用ref
,同時又能操作表單數據(取值、修改值、手動校驗等),我將用於存儲數據的FormStore
,從Form
組件中分離出來,通過new FormStore()
創建並手動傳入Form
組件。
使用方式大概會長這樣子:
class App extends React.Component {
constructor(props) {
super(props);
this.store = new FormStore();
}
onSubmit = () => {
const data = this.store.get();
// ...
};
render() {
return (
<Form store={this.store} onSubmit={this.onSubmit}>
<Field name="username">
<input />
</Field>
<Field name="password">
<input type="password" />
</Field>
<button>Submit</button>
</Form>
);
}
}
FormStore
用於存放表單數據、接受表單初始值,以及封裝對錶單數據的操作。
class FormStore {
constructor(defaultValues = {}, rules = {}) {
// 表單值
this.values = defaultValues;
// 表單初始值,用於重置表單
this.defaultValues = deepCopy(defaultValues);
// 表單校驗規則
this.rules = rules;
// 事件回調
this.listeners = [];
}
}
為了讓表單數據變動時,能夠響應到對應的表單域組件,這裡使用了訂閱方式,在FormStore
中維護一個事件回調列表listeners
,每個Field
創建時,通過調用FormStore.subscribe(listener)
訂閱表單數據變動。
class FormStore {
// constructor ...
subscribe(listener) {
this.listeners.push(listener);
// 返回一個用於取消訂閱的函數
return () => {
const index = this.listeners.indexOf(listener);
if (index > -1) this.listeners.splice(index, 1);
};
}
// 通知表單變動,調用所有listener
notify(name) {
this.listeners.forEach(listener => listener(name));
}
}
再添加get
和set
函數,用於獲取和設置表單數據。其中,在set
函數中調用notify(name)
,以保證所有的表單變動都會觸發通知。
class FormStore {
// constructor ...
// subscribe ...
// notify ...
// 獲取表單值
get(name) {
// 如果傳入name,返回對應的表單值,否則返回整個表單的值
return name === undefined ? this.values : this.values[name];
}
// 設置表單值
set(name, value) {
//如果指定了name
if (typeof name === "string") {
// 設置name對應的值
this.values[name] = value;
// 執行表單校驗,見下
this.validate(name);
// 通知表單變動
this.notify(name);
}
// 批量設置表單值
else if (name) {
const values = name;
Object.keys(values).forEach(key => this.set(key, values[key]));
}
}
// 重置表單值
reset() {
// 清空錯誤信息
this.errors = {};
// 重置默認值
this.values = deepCopy(this.defaultValues);
// 執行通知
this.notify("*");
}
}
對於表單校驗部分,不想考慮得太複雜,只做一些規定
FormStore
構造函數中傳入的rules
是一個對象,該對象的鍵對應於表單域的name
,值是一個校驗函數
。校驗函數
參數接受表單域的值和整個表單值,返回boolean
或string
類型的結果。true
代表校驗通過。false
和string
代表校驗失敗,並且string
結果代表錯誤信息。
然後巧妙地通過||
符號判斷是否校驗通過,例如:
new FormStore({/* 初始值 */, {
username: (val) => !!val.trim() || 用戶名不能為空,
password: (val) => !!(val.length > 6 && val.length < 18) || 密碼長度必須大於6個字元,小於18個字元,
passwordAgain: (val, vals) => val === vals.password || 兩次輸入密碼不一致
}})
在FormStore
實現一個validate
函數:
class FormStore {
// constructor ...
// subscribe ...
// notify ...
// get ...
// set ...
// reset ...
// 用於設置和獲取錯誤信息
error(name, value) {
const args = arguments;
// 如果沒有傳入參數,則返回錯誤信息中的第一條
// const errors = store.error()
if (args.length === 0) return this.errors;
// 如果傳入的name是number類型,返回第i條錯誤信息
// const error = store.error(0)
if (typeof name === "number") {
name = Object.keys(this.errors)[name];
}
// 如果傳了value,則根據value值設置或刪除name對應的錯誤信息
if (args.length === 2) {
if (value === undefined) {
delete this.errors[name];
} else {
this.errors[name] = value;
}
}
// 返回錯誤信息
return this.errors[name];
}
// 用於表單校驗
validate(name) {
if (name === undefined) {
// 遍歷校驗整個表單
Object.keys(this.rules).forEach(n => this.validate(n));
// 並通知整個表單的變動
this.notify("*");
// 返回一個包含第一條錯誤信息和表單值的數組
return [this.error(0), this.get()];
}
// 根據name獲取校驗函數
const validator = this.rules[name];
// 根據name獲取表單值
const value = this.get(name);
// 執行校驗函數得到結果
const result = validator ? validator(name, this.values) : true;
// 獲取並設置結果中的錯誤信息
const message = this.error(
name,
result === true ? undefined : result || ""
);
// 返回Error對象或undefind,和表單值
const error = message === undefined ? undefined : new Error(message);
return [error, value];
}
}
至此,這個表單組件的核心部分FormStore
已經完成了,接下來就是這麼在Form
和Field
組件中使用它。
Form
Form
組件相當簡單,也只是為了提供一個入口和傳遞上下文。
props
接收一個FormStore
的實例,並通過Context
傳遞給子組件(即Field
)中。
const FormStoreContext = React.createContext(undefined);
function Form(props) {
const { store, children, onSubmit } = props;
return (
<FormStoreContext.Provider value={store}>
<form onSubmit={onSubmit}>{children}</form>
</FormStoreContext.Provider>
);
}
Field
Field
組件也並不複雜,核心目標是實現value
和onChange
自動傳入到表單組件中。
// 從onChange事件中獲取表單值,這裡主要應對checkbox的特殊情況
function getValueFromEvent(e) {
return e && e.target
? e.target.type === "checkbox"
? e.target.checked
: e.target.value
: e;
}
function Field(props) {
const { label, name, children } = props;
// 拿到Form傳下來的FormStore實例
const store = React.useContext(FormStoreContext);
// 組件內部狀態,用於觸發組件的重新渲染
const [value, setValue] = React.useState(
name && store ? store.get(name) : undefined
);
const [error, setError] = React.useState(
name && store ? store.error(name) : undefined
);
// 表單組件onChange事件,用於從事件中取得表單值
const onChange = React.useCallback(
(...args) => name && store && store.set(name, valueGetter(...args)),
[name, store]
);
// 訂閱表單數據變動
React.useEffect(() => {
if (!name || !store) return;
return store.subscribe(n => {
// 當前name的數據發生了變動,獲取數據並重新渲染
if (n === name || n === "*") {
setValue(store.get(name));
setError(store.error(name));
}
});
}, [name, store]);
let child = children;
// 如果children是一個合法的組件,傳入value和onChange
if (name && store && React.isValidElement(child)) {
const childProps = { value, onChange };
child = React.cloneElement(child, childProps);
}
// 表單結構,具體的樣式就不貼出來了
return (
<div className="form">
<label className="form__label">{label}</label>
<div className="form__content">
<div className="form__control">{child}</div>
<div className="form__message">{error}</div>
</div>
</div>
);
}
於是,這個表單組件就完成了,愉快地使用它吧:
class App extends React.Component {
constructor(props) {
super(props);
this.store = new FormStore();
}
onSubmit = () => {
const data = this.store.get();
// ...
};
render() {
return (
<Form store={this.store} onSubmit={this.onSubmit}>
<Field name="username">
<input />
</Field>
<Field name="password">
<input type="password" />
</Field>
<button>Submit</button>
</Form>
);
}
}
結語
這裡只是把最核心的代碼整理了出來,功能上當然比不上那些成百上千 star 的組件,但是用法上足夠簡單,並且已經能應對項目中的大多數情況。
我已在此基礎上完善了一些細節,並發布了一個 npm 包——@react-hero/form
,你可以通過npm安裝,或者在github上找到源碼。如果你有任何已經或建議,歡迎在評論或 issue 中討論。
推薦閱讀: