騷操作: 基於 Antd Form 的高階元件 AutoBindForm
1. 前言
很久沒更新部落格了, 皮的嘛,就不談了,不過問題不大,今天就結合 專案中寫的一個 React 高階元件 的例項 再來講一講,結合上一篇文章,加深一下印象
2. Ant Design 的 Form 元件
國民元件庫 Ant-Design
的 Form
庫 想必大家都用過, 比較強大, 基於 rc-form
封裝, 功能比較齊全
最近專案中遇到了一個需求, 普通的一個表單, 表單欄位沒有 填完的時候, 提交按鈕 是 disabled
狀態的, 聽起來很簡單, 由於用的是 antd
翻了翻文件, copy 了一下程式碼 , 發現需要些不少的程式碼
import { Form, Icon, Input, Button } from 'antd';
const FormItem = Form.Item;
function hasErrors(fieldsError) {
return Object.keys(fieldsError).some(field => fieldsError[field]);
}
@Form.create();
class Page extends React.Component<{},{}> {
componentDidMount() {
this.props.form.validateFields();
}
handleSubmit = (e: React.FormEvent<HTMLButtonElement> ) => {
e.preventDefault();
this.props.form.validateFields((err:any, values:any) => {
if (!err) {
...
}
});
}
render() {
const { getFieldDecorator, getFieldsError, getFieldError, isFieldTouched } = this.props.form;
const userNameError = isFieldTouched('userName' ) && getFieldError('userName');
const passwordError = isFieldTouched('password') && getFieldError('password');
return (
<Form layout="inline" onSubmit={this.handleSubmit}>
<FormItem
validateStatus={userNameError ? 'error' : ''}
help={userNameError || ''}
>
{getFieldDecorator('userName', {
rules: [{ required: true, message: 'Please input your username!' }],
})(
<Input prefix={<Icon type="user" style={{ color: 'rgba(0,0,0,.25)' }} />} placeholder="Username" />
)}
</FormItem>
<FormItem
validateStatus={passwordError ? 'error' : ''}
help={passwordError || ''}
>
{getFieldDecorator('password', {
rules: [{ required: true, message: 'Please input your Password!' }],
})(
<Input prefix={<Icon type="lock" style={{ color: 'rgba(0,0,0,.25)' }} />} type="password" placeholder="Password" />
)}
</FormItem>
<FormItem>
<Button
type="primary"
htmlType="submit"
disabled={hasErrors(getFieldsError())}
>
登入
</Button>
</FormItem>
</Form>
);
}
}
複製程式碼
3. 那麼問題來了
上面的程式碼咋一看沒什麼毛病, 給每個欄位繫結一個 validateStatus
去看當前欄位 有沒有觸碰過 並且沒有錯, 並在 元件渲染的時候 觸發一次驗證, 通過這種方式 來達到 disabled
按鈕的目的, 但是要命的 只是 實現一個 disabled
的效果, 多寫了這麼多的程式碼, 實際遇到的場景是 有10多個這種需求的表單,有沒有什麼辦法不寫這麼多的模板程式碼呢? 於是我想到了 高階元件
4. 開始幹活
由於 Form.create()
後 會給 this.props
新增 form
屬性 ,從而使用它提供的 api, 經過觀察 我們預期想要的效果有以下幾點
// 使用效果
@autoBindForm //需要實現的元件
export default class FormPage extends React.PureComponent {
}
複製程式碼
要達到如下效果
- 1.
componentDidMount
的時候 觸發一次 欄位驗證 - 2.這時候會出現錯誤資訊, 這時候需要幹掉錯誤資訊
- 3.然後遍歷當前元件所有的欄位, 判斷 是否有錯
- 4.提供一個
this.props.hasError
類似的欄位給當前元件.控制 按鈕的disabled
狀態 - 5.支援非必填欄位, (igonre)
- 6.支援編輯模式 (有預設值)
5. 實現 autoBindForm
import * as React from 'react'
import { Form } from 'antd'
const getDisplayName = (component: React.ComponentClass) => {
return component.displayName || component.name || 'Component'
}
export default (WrappedComponent: React.ComponentClass<any>) => {
class AutoBindForm extends WrappedComponent {
static displayName = `HOC(${getDisplayName(WrappedComponent)})`
autoBindFormHelp: React.Component<{}, {}> = null
getFormRef = (formRef: React.Component) => {
this.autoBindFormHelp = formRef
}
render() {
return (
<WrappedComponent
wrappedComponentRef={this.getFormRef}
/>
)
}
return Form.create()(AutoBindForm)
}
複製程式碼
首先 Form.create
一下我們需要包裹的元件, 這樣就不用每一個頁面都要 create
一次
然後我們通過 antd
提供的 wrappedComponentRef
拿到了 form
的引用
根據 antd
的文件 ,我們要實現想要的效果,需要用到 如下 api
validateFields
驗證欄位getFieldsValue
獲取欄位的值setFields
設定欄位的值getFieldsError
獲取欄位的錯誤資訊isFieldTouched
獲取欄位是否觸碰過
class AutoBindForm extends WrappedComponent
複製程式碼
繼承我們需要包裹的元件(也就是所謂的反向繼承), 我們可以 在初始化的時候 驗證欄位
componentDidMount(){
const {
form: {
validateFields,
getFieldsValue,
setFields,
},
} = this.props
validateFields()
}
}
複製程式碼
由於進入頁面時 使用者並沒有輸入, 所以需要手動清空 錯誤資訊
componentDidMount() {
const {
form: {
validateFields,
getFieldsValue,
setFields,
},
} = this.props
validateFields()
Object.keys(getFieldsValue())
.forEach((field) => {
setFields({
[field]: {
errors: null,
status: null,
},
})
})
}
}
複製程式碼
通過 getFieldsValue()
我們可以動態的拿到當前 表單 所有的欄位, 然後再使用 setFields
遍歷一下 把所有欄位的 錯誤狀態設為 null
, 這樣我們就實現了 1,2 的效果,
6. 實現實時的錯誤判斷 hasError
由於子元件 需要一個 狀態 來知道 當前的表單是否有錯誤, 所以我們定義一個 hasError
的值 來實現, 由於要是實時的,所以不難想到用 getter
來實現,
熟悉Vue
的同學 可能會想到 Object.definedPropty
實現的 計算屬性,
本質上 Antd
提供的 表單欄位收集也是通過 setState
, 回觸發頁面渲染, 在當前場景下, 直接使用 es6
支援的get
屬性即可實現同樣的效果 程式碼如下
get hasError() {
const {
form: { getFieldsError, isFieldTouched }
} = this.props
let fieldsError = getFieldsError() as any
return Object
.keys(fieldsError)
.some((field) => !isFieldTouched(field) || fieldsError[field]))
}
複製程式碼
程式碼很簡單 ,在每次 getter
觸發的時候, 我們用 some
函式 去判斷一下 當前的表單是否觸碰過 或者有錯誤, 在建立表單這個場景下, 如果沒有觸碰過,一定是沒輸入,所以不必驗證是否有錯
最後 在 render
的時候 將 hasError
傳給 子元件
render() {
return (
<WrappedComponent
wrappedComponentRef={this.getFormRef}
{...this.props}
hasError={this.hasError}
/>
)
}
//父元件
console.log(this.prop.hasError)
<Button disabled={this.props.hasError}>提交</Button>
複製程式碼
同時我們定義下 type
export interface IAutoBindFormHelpProps {
hasError: boolean,
}
複製程式碼
寫到這裡, 建立表單的場景, 基本上可以用這個高階元件輕鬆搞定, 但是有一些表單有一些非必填項, 這時就會出現,非必填項但是認為有錯誤的清空, 接下來, 改進一下程式碼
7. 優化元件, 支援 非必填欄位
非必填欄位, 即認為是一個配置項, 由呼叫者告訴我哪些是 非必填項, 當時我本來想搞成 自動去查詢 當前元件哪些欄位不是 requried
的, 但是 antd
的文件貌似 莫得, 就放棄了
首先修改函式, 增加一層柯里化
export default (filterFields: string[] = []) =>
(WrappedComponent: React.ComponentClass<any>) => {
}
複製程式碼
@autoBindForm(['fieldA','fieldB']) //需要實現的元件
export default class FormPage extends React.PureComponent {
}
複製程式碼
修改 hasError
的邏輯
get hasError() {
const {
form: { getFieldsError, isFieldTouched, getFieldValue },
defaultFieldsValue,
} = this.props
const { filterFields } = this.state
const isEdit = !!defaultFieldsValue
let fieldsError = getFieldsError()
const needOmitFields = filterFields.filter((field) => !isFieldTouched(field)).concat(needIgnoreFields)
if(!isEmpty(needOmitFields)) {
fieldsError = omit(fieldsError, needOmitFields)
}
return Object
.keys(fieldsError)
.some((field) => {
const isCheckFieldTouched = !isEdit || isEmpty(getFieldValue(field))
return isCheckFieldTouched ? (!isFieldTouched(field) || fieldsError[field]) : fieldsError[field]
})
}
複製程式碼
邏輯很簡單粗暴, 遍歷一下需要過濾的欄位,看它有沒有觸碰過,如果觸碰過,就不加入錯誤驗證
同理, 在 初始化的時候也過濾一下,
首先通過 Object.keys(getFieldsValue)
拿到當前表單 的所有欄位, 由於 這時候不知道哪些欄位 是 requierd
的, 機智的我
validateFields
驗證一下當前表單, 這個函式 返回當前表單的錯誤值, 非必填的欄位 此時不會有錯誤, 所以 只需要拿到當前錯誤資訊, 和 所有欄位 比較 兩者 不同的值, 使用 loadsh
的 xor
函式 完成
const filterFields = xor(fields, Object.keys(err || []))
this.setState({
filterFields,
})
複製程式碼
最後清空 所有錯誤資訊
完整程式碼:
componentDidMount() {
const {
form: {
validateFields,
getFieldsValue,
getFieldValue,
setFields,
},
} = this.props
const fields = Object.keys(getFieldsValue())
validateFields((err: object) => {
const filterFields = xor(fields, Object.keys(err || []))
this.setState({
filterFields,
})
const allFields: { [key: string]: any } = {}
fields
.filter((field) => !filterFields.includes(field))
.forEach((field) => {
allFields[field] = {
value: getFieldValue(field),
errors: null,
status: null,
}
})
setFields(allFields)
})
}
複製程式碼
經過這樣一波修改, 支援非必填欄位的需求就算完成了
8. 最後一波, 支援預設欄位
其實這個很簡單, 就是看子元件是否有預設值 , 如果有 setFieldsValue
一下就搞定了, 子元件和父元件約定一個 defaultFieldsValue
完整程式碼如下
import * as React from 'react'
import { Form } from 'antd'
import { xor, isEmpty, omit } from 'lodash'
const getDisplayName = (component: React.ComponentClass) => {
return component.displayName || component.name || 'Component'
}
export interface IAutoBindFormHelpProps {
hasError: boolean,
}
interface IAutoBindFormHelpState {
filterFields: string[]
}
/**
* @name AutoBindForm
* @param needIgnoreFields string[] 需要忽略驗證的欄位
* @param {WrappedComponent.defaultFieldsValue} object 表單初始值
*/
const autoBindForm = (needIgnoreFields: string[] = [] ) => (WrappedComponent: React.ComponentClass<any>) => {
class AutoBindForm extends WrappedComponent {
get hasError() {
const {
form: { getFieldsError, isFieldTouched, getFieldValue },
defaultFieldsValue,
} = this.props
const { filterFields } = this.state
const isEdit = !!defaultFieldsValue
let fieldsError = getFieldsError()
const needOmitFields = filterFields.filter((field) => !isFieldTouched(field)).concat(needIgnoreFields)
if(!isEmpty(needOmitFields)) {
fieldsError = omit(fieldsError, needOmitFields)
}
return Object
.keys(fieldsError)
.some((field) => {
const isCheckFieldTouched = !isEdit || isEmpty(getFieldValue(field))
return isCheckFieldTouched ? (!isFieldTouched(field) || fieldsError[field]) : fieldsError[field]
})
}
static displayName = `HOC(${getDisplayName(WrappedComponent)})`
state: IAutoBindFormHelpState = {
filterFields: [],
}
autoBindFormHelp: React.Component<{}, {}> = null
getFormRef = (formRef: React.Component) => {
this.autoBindFormHelp = formRef
}
render() {
return (
<WrappedComponent
wrappedComponentRef={this.getFormRef}
{...this.props}
hasError={this.hasError}
/>
)
}
componentDidMount() {
const {
form: {
validateFields,
getFieldsValue,
getFieldValue,
setFields,
},
} = this.props
const fields = Object.keys(getFieldsValue())
validateFields((err: object) => {
const filterFields = xor(fields, Object.keys(err || []))
this.setState({
filterFields,
})
const allFields: { [key: string]: any } = {}
fields
.filter((field) => !filterFields.includes(field))
.forEach((field) => {
allFields[field] = {
value: getFieldValue(field),
errors: null,
status: null,
}
})
setFields(allFields)
// 由於繼承了 WrappedComponent 所以可以拿到 WrappedComponent 的 props
if (this.props.defaultFieldsValue) {
this.props.form.setFieldsValue(this.props.defaultFieldsValue)
}
})
}
}
return Form.create()(AutoBindForm)
}
export default autoBindForm
複製程式碼
這樣一來, 如果子元件 有 defaultFieldsValue
這個 props, 頁面載入完就會設定好這些值,並且不會觸發錯誤
10. 使用
import autoBindForm from './autoBindForm'
# 基本使用
@autoBindForm()
class MyFormPage extends React.PureComponent {
...沒有靈魂的表單程式碼
}
# 忽略欄位
@autoBindForm(['filedsA','fieldsB'])
class MyFormPage extends React.PureComponent {
...沒有靈魂的表單程式碼
}
# 預設值
// MyFormPage.js
@autoBindForm()
class MyFormPage extends React.PureComponent {
...沒有靈魂的表單程式碼
}
// xx.js
const defaultFieldsValue = {
name: 'xx',
age: 'xx',
rangePicker: [moment(),moment()]
}
<MyformPage defaultFieldsValue={defaultFieldsValue} />
複製程式碼
這裡需要注意的是, 如果使用 autoBindForm
包裝過的元件 也就是
<MyformPage defaultFieldsValue={defaultFieldsValue}/>
複製程式碼
這時候 想拿到 ref
, 不要忘了 forwardRef
this.ref = React.createRef()
<MyformPage defaultFieldsValue={defaultFieldsValue} ref={this.ref}/>
複製程式碼
同理修改 'autoBindForm.js'
render() {
const { forwardedRef, props } = this.props
return (
<WrappedComponent
wrappedComponentRef={this.getFormRef}
{...props}
hasError={this.hasError}
ref={forwardedRef}
/>
)
}
return Form.create()(
React.forwardRef((props, ref) => <AutoBindForm {...props} forwardedRef={ref} />),
)
複製程式碼
11. 最終程式碼
import * as React from 'react'
import { Form } from 'antd'
import { xor, isEmpty, omit } from 'lodash'
const getDisplayName = (component: React.ComponentClass) => {
return component.displayName || component.name || 'Component'
}
export interface IAutoBindFormHelpProps {
hasError: boolean,
}
interface IAutoBindFormHelpState {
filterFields: string[]
}
/**
* @name AutoBindForm
* @param needIgnoreFields string[] 需要忽略驗證的欄位
* @param {WrappedComponent.defaultFieldsValue} object 表單初始值
*/
const autoBindForm = (needIgnoreFields: string[] = []) => (WrappedComponent: React.ComponentClass<any>) => {
class AutoBindForm extends WrappedComponent {
get hasError() {
const {
form: { getFieldsError, isFieldTouched, getFieldValue },
defaultFieldsValue,
} = this.props
const { filterFields } = this.state
const isEdit = !!defaultFieldsValue
let fieldsError = getFieldsError()
const needOmitFields = filterFields.filter((field) => !isFieldTouched(field)).concat(needIgnoreFields)
if (!isEmpty(needOmitFields)) {
fieldsError = omit(fieldsError, needOmitFields)
}
return Object
.keys(fieldsError)
.some((field) => {
const isCheckFieldTouched = !isEdit || isEmpty(getFieldValue(field))
return isCheckFieldTouched ? (!isFieldTouched(field) || fieldsError[field]) : fieldsError[field]
})
}
static displayName = `HOC(${getDisplayName(WrappedComponent)})`
state: IAutoBindFormHelpState = {
filterFields: [],
}
autoBindFormHelp: React.Component<{}, {}> = null
getFormRef = (formRef: React.Component) => {
this.autoBindFormHelp = formRef
}
render() {
const { forwardedRef, props } = this.props
return (
<WrappedComponent
wrappedComponentRef={this.getFormRef}
{...props}
hasError={this.hasError}
ref={forwardedRef}
/>
)
}
componentDidMount() {
const {
form: {
validateFields,
getFieldsValue,
getFieldValue,
setFields,
},
} = this.props
const fields = Object.keys(getFieldsValue())
validateFields((err: object) => {
const filterFields = xor(fields, Object.keys(err || []))
this.setState({
filterFields,
})
const allFields: { [key: string]: any } = {}
fields
.filter((field) => !filterFields.includes(field))
.forEach((field) => {
allFields[field] = {
value: getFieldValue(field),
errors: null,
status: null,
}
})
setFields(allFields)
// 屬性劫持 初始化預設值
if (this.props.defaultFieldsValue) {
this.props.form.setFieldsValue(this.props.defaultFieldsValue)
}
})
}
}
return Form.create()(
React.forwardRef((props, ref) => <AutoBindForm {...props} forwardedRef={ref} />),
)
}
export default autoBindForm
複製程式碼
12. 結語
這樣一個 對 Form.create
再次包裝的 高階元件, 解決了一定的痛點, 少寫了很多模板程式碼, 雖然封裝的時候遇到了各種各樣奇奇怪怪的問題,但是都解決了, 沒毛病, 也加強了我對高階元件的認知,溜了溜了 :)