背景 之間在一篇介紹過 Table 組件《 React 實現一個漂亮的 Table 》 的文章中講到過,在企業級後臺產品中,用的最多且複雜的組件主要包括 Table、Form、Chart,在處理 Table 的時候我們遇到了很多問題。今天我們這篇文章主要是分享一下 Form 組件,在業務開發中, 相 ...
背景
之間在一篇介紹過 Table 組件《 React 實現一個漂亮的 Table 》 的文章中講到過,在企業級後臺產品中,用的最多且複雜的組件主要包括 Table、Form、Chart,在處理 Table 的時候我們遇到了很多問題。今天我們這篇文章主要是分享一下 Form 組件,在業務開發中, 相對 Table 來說,Form 處理起來更麻煩,不是所有表單都像註冊頁面那樣簡單,它往往需要處理非常多的邏輯,比如:
- 需要處理數據驗證邏輯。
- 在一個項目中的很少有可以復用的表單,每個表單的邏輯都需要單獨處理。
- 在表單中往往存在需要自定義的組件,比如:Toggle、Tree、Picker 等等。
Form 作為一個功能型組件,它需要解決的問題無非就是兩個:
- 把一個數據對象扔給它,它能夠在讓 Form 內的交互型組件獲取到數據並展示出來,同時這些組件上的數據也能反過來讓 Form 組件獲取到。
- 對數據的有效性進行驗證。
在 React 項目開發中沒有用過其他 Form 相關的組件,但是這裡我又忍不住想和 Ant Design 的 Form 對比一下,以下是我在 antd 官網上的一個截圖:
大家可以通過以上圖片看到,我想輸入自己的中文名字都不能正常輸入,這裡主要存在兩個問題:
- onChange 被觸發了多次,在一次中文未完整的輸入前,不應該觸發
onChange
事件。 - 另外,中文在文本框內就不能正常顯示。
我在想這兩個問題不在組件上處理,在哪裡處理的呢?訪問地址: https://ant.design/components/form-cn/ 可以試一下,也希望他們可以解決掉這個問題。這個問題應該怎麼解決,我之前做過記錄: [React 中, 在 Controlled(受控制)的文本框中輸入中文 onChange 會觸發多次]( https://github.com/hypers/blog/blob/master/source/_posts/react-issue.md#react-中-在-controlled 受控制的文本框中輸入中文-onchange-會觸發多次)。
我們在設計的時候自然是解決了這些問題,預覽效果: https://rsuitejs.com/form-lib/ ,接下來看一下具體的設計。
設計
針對前面提到 Form 需要解決的兩個問題(數據校驗和數據獲取),在設計的時候,我們把這個兩個問題作為兩個功能,獨立在不同的庫處理。
- form-lib 處理表單數據初始化,數據獲取。
- rsuite-schema 定義數據模型,做數據有效性驗證。
這兩個庫可以獨立使用,如果你有自己一套自己的 Form 組件,只是缺少一個數據驗證的工具,那你可以單獨把 rsuite-schema
拿過來使用。
Form 定義一個表單
分別看一下它們是怎麼工作的,form-lib
用起來比較簡單,提供了兩個組件:
<Form>
處理整個表單服務的邏輯<Field>
處理表單中各個交互型組件的邏輯,比如input
,select
。
看一個示例:
安裝
首先需要安裝 form-lib
npm i form-lib --save
示例代碼
import { Form, Field, createFormControl } from 'form-lib';
const SelectField = createFormControl('select');
const user = {
name:'root',
status:1
};
<Form data={user}>
<Field name="name" />
<Field name="status" accepter={SelectField} >
<option value={1}>啟用</option>
<option value={0}>禁用</option>
</Field>
</Form>
在預設情況下 Field 是一個文本輸入組件,如果你需要使用 HTML 表單中其他的標簽,你可以像上面示例一樣 通過 createFormControl(標簽名稱)
方法創建一個組件, 在把這個組件通過 accepter
屬性賦給Field
組件,這樣你就能像操作原生 HTML 一樣設置 Field
。 通過這種方式,後面在功能介紹的時候會講到怎麼把自定義組件放在 Field
中。
自定義佈局
這裡存在一個疑問,<Field>
必須放在 <Form>
下麵第一層嗎? 如果對佈局沒有要求,設計成這樣是處理起來最方便的,因為在 <Form>
中可以直接通過 props.children
獲取到所有的 <Field>
,想怎麼處理都可以。 剛開始我們是這樣的,後來在實際應用中發現,表單的佈局是有很多種,如果要設計成這樣,那肯定就帶來一個問題,不好自定義佈局。 所以這裡 <Form>
與 <Field>
之間的通信我們用的是 React 的 context。 這樣的話你就可以任意佈局:
<Form data={user}>
<div className="row">
<Field name="name" />
</div>
<div className="row">
<Field name="status" accepter={SelectField} >
<option value={1}>啟用</option>
<option value={0}>禁用</option>
</Field>
</div>
</Form>
Schema 定義一個數據模型
安裝
npm i rsuite-schema --save
在 rsuite-schema
主要有兩個對象
SchemaModel
用於定義數據模型。Type
用於定義數據類型,包括:- StringType
- NumbserType
- ArrayType
- DateType
- ObjectType
- BooleanType
這裡的 Type 有點像 React 中 PropTypes 的定義。
示例代碼
一個示例:
const userModel = SchemaModel(
username: StringType().isRequired('用戶名不能為空'),
email: StringType().isEmail('請輸入正確的郵箱'),
age: NumberType('年齡應該是一個數字').range(18, 30, '年應該在 18 到 30 歲')
});
這裡定義了一個 userModel
, 包含 username
、email
、age
3 個欄位, userModel
擁有了一個 check
方法, 當把數據扔進去後會返回驗證結果:
const checkResult = userModel.check({
username: 'foobar',
email: '[email protected]',
age: 40
})
// checkResult 結果:
/**
{
username: { hasError: false },
email: { hasError: false },
age: { hasError: true, errorMessage: '年應該在 18 到 30 歲' }
}
**/
多重驗證
StringType()
.minLength(6,'不能少於 6 個字元')
.maxLength(30,'不能大於 30 個字元')
.isRequired('該欄位不能為空');
自定義驗證
通過 addRule 函數自定義一個規則。 如果是對一個字元串類型的數據進行驗證,可以通過 pattern 方法設置一個正則表達式進行自定義驗證。
const myModel = SchemaModel({
field1: StringType().addRule((value) => {
return /^[1-9][0-9]{3}\s?[a-zA-Z]{2}$/.test(value);
}, '請輸入合法字元'),
field2: StringType().pattern(/^[1-9][0-9]{3}\s?[a-zA-Z]{2}$/, '請輸入合法字元')
});
自定義動態錯誤信息
例如,要通過值的不同情況,返回不同的錯誤信息,參考以下
const myModel = SchemaModel({
field1: StringType().addRule((value) => {
if(value==='root'){
return {
hasError: true,
errorMessage:'不能是關鍵字 root'
}
}else if(!/^[a-zA-Z]+$/.test(value)){
return {
hasError: true,
errorMessage:'只能是英文字元'
}
}
return {
hasError: false
}
})
});
複雜結構數據驗證
const userModel = SchemaModel({
username:StringType().isEmail('正確的郵箱地址').isRequired('該欄位不能為空'),
tag: ArrayType().of(StringType().rangeLength(6, 30, '字元個數只能在 6 - 30 之間')),
profile: ObjectType().shape({
email: StringType().isEmail('應該是一個 email'),
age: NumberType().min(18, '年齡應該大於 18 歲')
})
})
更多設置,可以查看 rsuite-schema API 文檔
Form 與 Schema 的結合
const userModel = SchemaModel({
username: StringType().isRequired('用戶名不能為空'),
email: StringType().isEmail('請輸入正確的郵箱'),
age: NumberType('年齡應該是一個數字').range(18, 30, '年應該在 18 到 30 歲')
});
<Form model={userModel}>
<Field name="username" />
<Field name="email" />
<Field name="age" />
</Form>
把定義的的SchemaModel
對象賦給,<Form>
的 model
屬性,就把它們綁定起來了,<Field>
的 name
對應 SchemaModel
對象中的 key
。
以上的示例代碼是不完整的,沒有處理錯誤信息和獲取數據,只是為了方便大家理解。完整的示例,可以參考接下來的實踐與解決方案。
實踐與解決方案
一個完整的示例
import React from 'react';
import { Form, Field, createFormControl } from 'form-lib';
import { SchemaModel, StringType } from 'rsuite-schema';
const TextareaField = createFormControl('textarea');
const SelectField = createFormControl('select');
const model = SchemaModel({
name: StringType().isEmail('請輸入正確的郵箱')
});
class DefaultForm extends React.Component {
constructor(props) {
super(props);
this.state = {
values: {
name: 'abc',
status: 0
},
errors: {}
};
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit() {
const { values } = this.state;
if (!this.form.check()) {
console.error('數據格式有錯誤');
return;
}
console.log(values, '提交數據');
}
render() {
const { errors, values } = this.state;
return (
<div>
<Form
ref={ref => this.form = ref}
onChange={(values) => {
console.log(values);
this.setState({ values });
// 清除表單所有的錯誤信息
this.form.cleanErrors();
}}
onCheck={(errors) => {
this.setState({ errors });
}}
values={values}
model={model}
>
<div className="form-group">
<label>郵箱: </label>
<Field name="name" className="form-control" />
<span className="help-block error" style={{ color: '#ff0000' }}>
{errors.name}
</span>
</div>
<div className="form-group">
<label>狀態: </label>
<Field name="status" className="form-control" accepter={SelectField} >
<option value={1}>啟用</option>
<option value={0}>禁用</option>
</Field>
</div>
<div className="form-group">
<label>描述 </label>
<Field name="description" className="form-control" accepter={TextareaField} />
</div>
<button onClick={this.handleSubmit}> 提交 </button>
</Form>
</div>
);
}
}
export default DefaultForm;
在 rsuite 中的應用
在 rsuite
提供了很多 Form
相關的組件,比如 FormGroup
,FormControl
,ControlLabel
,HelpBlock
等等, 我們通過一個例子看一下怎麼結合使用。
通過上一個例子中我們可以看到,沒有個 Field
中有很多公共部分,所以我們可以自定義一個無狀態組件 CustomField
,把 ControlLabel
,Field
,HelpBlock
這些表單元素都放在一起。
import React from 'react';
import { Form, Field, createFormControl } from 'form-lib';
import { SchemaModel, StringType, ArrayType } from 'rsuite-schema';
import {
FormControl,
Button,
FormGroup,
ControlLabel,
HelpBlock,
CheckboxGroup,
Checkbox
} from 'rsuite';
const model = SchemaModel({
name: StringType().isEmail('請輸入正確的郵箱'),
skills: ArrayType().minLength(1, '至少應該會一個技能')
});
const CustomField = ({ name, label, accepter, error, ...props }) => (
<FormGroup className={error ? 'has-error' : ''}>
<ControlLabel>{label} </ControlLabel>
<Field name={name} accepter={accepter} {...props} />
<HelpBlock className={error ? 'error' : ''}>{error}</HelpBlock>
</FormGroup>
);
class DefaultForm extends React.Component {
constructor(props) {
super(props);
this.state = {
values: {
name: 'abc',
skills: [2, 3],
gender: 0,
status: 0
},
errors: {}
};
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit() {
const { values } = this.state;
if (!this.form.check()) {
console.error('數據格式有錯誤');
return;
}
console.log(values, '提交數據');
}
render() {
const { errors, values } = this.state;
return (
<div>
<Form
ref={ref => this.form = ref}
onChange={(values) => {
this.setState({ values });
console.log(values);
}}
onCheck={errors => this.setState({ errors })}
defaultValues={values}
model={model} >
<CustomField
name="name"
label="郵箱"
accepter={FormControl}
error={errors.name}
/>
<CustomField
name="status"
label="狀態"
accepter={FormControl}
error={errors.status}
componentClass="select"
>
<option value={1}>啟用</option>
<option value={0}>禁用</option>
</CustomField>
<CustomField
name="skills"
label="技能"
accepter={CheckboxGroup}
error={errors.skills}
>
<Checkbox value={1}>Node.js</Checkbox>
<Checkbox value={2}>Javascript</Checkbox>
<Checkbox value={3}>CSS 3</Checkbox>
</CustomField>
<CustomField
name="gender"
label="性別"
accepter={RadioGroup}
error={errors.gender}
>
<Radio value={0}>男</Radio>
<Radio value={1}>女</Radio>
<Radio value={2}>未知</Radio>
</CustomField>
<CustomField
name="bio"
label="簡介"
accepter={FormControl}
componentClass="textarea"
error={errors.bio}
/>
<Button shape="primary" onClick={this.handleSubmit}> 提交 </Button>
</Form>
</div>
);
}
}
export default DefaultForm;
自定義 Field
如果一個組件不是原生表單控制項,也不是 RSuite 庫中提供的基礎組件,要在 form-lib 中使用,應該怎麼處理呢? 只需要在寫組件的時候實現以下對應的 API:
- value : 受控時候設置的值
- defalutValue: 預設值,非受控情況先設置的值
- onChange: 組件數據發生改變的回調函數
- onBlur: 在失去焦點的回調函數(可選)
接下來我們使用 rsuite-selectpicker 作為示例, 在 rsuite-selectpicker 內部已經實現了這些 API。
import React from 'react';
import { SchemaModel, NumberType } from 'rsuite-schema';
import { Button, FormGroup, ControlLabel, HelpBlock } from 'rsuite';
import Selectpicker from 'rsuite-selectpicker';
import { Form, Field } from 'form-lib';
const model = SchemaModel({
skill: NumberType().isRequired('該欄位不能為空')
});
const CustomField = ({ name, label, accepter, error, ...props }) => (
<FormGroup className={error ? 'has-error' : ''}>
<ControlLabel>{label} </ControlLabel>
<Field name={name} accepter={accepter} {...props} />
<HelpBlock className={error ? 'error' : ''}>{error}</HelpBlock>
</FormGroup>
);
class CustomFieldForm extends React.Component {
constructor(props) {
super(props);
this.state = {
values: {
skill: 3,
},
errors: {}
};
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit() {
const { values } = this.state;
if (!this.form.check()) {
console.error('數據格式有錯誤');
return;
}
console.log(values, '提交數據');
}
render() {
const { errors, values } = this.state;
return (
<div>
<Form
ref={ref => this.form = ref}
onChange={(values) => {
this.setState({ values });
console.log(values);
}}
onCheck={errors => this.setState({ errors })}
defaultValues={values}
model={model}
>
<CustomField
name="skill"
label="技能"
accepter={Selectpicker}
error={errors.skill}
data={[
{ label: 'Node.js', value: 1 },
{ label: 'CSS3', value: 2 },
{ label: 'Javascript', value: 3 },
{ label: 'HTML5', value: 4 }
]}
/>
<Button shape="primary" onClick={this.handleSubmit}> 提交 </Button>
</Form>
</div>
);
}
}
export default CustomFieldForm;
更多示例:參考
如果你在使用中存在任何問題,可以提交 issues,如果你有什麼好的想法歡迎你 pull request,GitHub 地址:
- rsuite: https://github.com/rsuite/rsuite
- form-lib: https://github.com/rsuite/form-lib
- rsuite-schema: https://github.com/rsuite/rsuite-schema