表單是網頁中非常重要的元素,但同時也非常複雜,複雜之處在於,對每一個表單欄位而言,都可能有屬於自己的資料驗證流程、驗證錯誤處理、可能會需要呼叫 API、可能需要 State 來控制 UI、可能會有巢狀的動態欄位;對一張表單而言,可能會需要拆開成數張小表單,寫成 Wizard 的形式。
簡單來說表單的毛病特別多,大家寫出來的邏輯和 UI 通通不一致,UI 和邏輯很難脫鉤,程式碼難以維護,不過在我們的 Boilerplate 中使用了 Redux-Form 將邏輯抽取至 Flux 中,UI 的部分則是筆者自己提出了 Wrapper
與 Adapter
的寫法,我認為這樣的組合和結構極好,容易維護、容易擴充、高度複用,同時還很容易調整 UI,這是我經過幾番重構程式碼嘔心瀝血得到的最佳作法,希望讀者們喜歡。
這裡先舉個登入表單當作例子,傳統寫法長這樣:
<form name="USER_LOGIN">
信箱:<input name="email" type="text" />
密碼:<input name="password" type="password" />
</form>
先來看看邏輯層面,如果改用 Redux-Form 幫我們把所有欄位的資料統整在 Store 裡面,便可以得到下方形式的 State:
// store.getState()
{
// ...
form: {
USER_LOGIN: {
email: 'xxx@gmail.com',
password: 'somepassword',
},
},
// ...
}
而 Redux-Form 也在 Props 中提供了 initialize
、change
等各種 Function 可以操作表單或是某個特定欄位。
再來是 UI 部分,必須使用 Redux-Form 提供的 Field
元件包裝每一個欄位,最後透過 reduxForm
這個 HOC 來給定表單名稱、表單初始值、欄位驗證流程等表單相關的設定。
直接看看 Boilerplate 中的登入表單是怎麼寫的吧:
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Field, reduxForm } from 'redux-form';
import userAPI from '../../../api/user';
import { pushErrors } from '../../../actions/errorActions';
import { BsInput as Input } from '../../fields/adapters';
import {
BsForm as Form,
BsField as FormField,
} from '../../fields/widgets';
const validate = (values) => {
const errors = {};
if (!values.email) {
errors.email = 'Required';
}
if (!values.password) {
errors.password = 'Required';
}
return errors;
};
class LoginForm extends Component {
constructor(props) {
super(props);
this.handleSubmit = this._handleSubmit.bind(this);
}
_handleSubmit(formData) {
let { dispatch, apiEngine, change } = this.props;
return userAPI(apiEngine)
.login(formData)
.catch((err) => {
dispatch(pushErrors(err));
throw err;
})
.then((json) => {
if (json.isAuth) {
// ...登入成功
} else {
change('password', '');
}
});
}
render() {
const { handleSubmit, pristine, submitting, invalid } = this.props;
return (
<Form onSubmit={handleSubmit(this.handleSubmit)}>
<Field
name="email"
component={FormField}
label="Email"
adapter={Input}
type="text"
placeholder="Email"
/>
<Field
name="password"
component={FormField}
label="Password"
adapter={Input}
type="password"
placeholder="Password"
/>
<button type="submit" disabled={pristine || submitting || invalid}>
Login
</button>
</Form>
);
}
};
export default reduxForm({
form: 'USER_LOGIN',
validate,
})(connect(state => ({
apiEngine: state.apiEngine,
routing: state.routing,
}))(LoginForm));
這個範例除了展示如何使用 Redux-Form 建構登入表單元件之外,裡面還透露了幾個重點:
_handleSubmit
是有回傳值的,當回傳值是 Promise 時可以用來控制 props.submitting以上只是帶各位走過 Redux-Form 的冰山一角,其實它是個太強大的 Library,所以這邊的舉例只是給讀者們一個方向,目的不是要教會各位使用 Redux-Form,它本身的文件寫得非常詳盡,範例也很多元,所以各位千萬不要只看本篇文章粗略的示範,一定要抽空讀過官方文件,才能把這套 Library 活用。
前面講的是整張表單的建構,接著我們來看每一個欄位是怎麼撰寫的。就舉 Email 欄位為例,按照 Redux-Form 最基本的寫法,只需要給定 name
與 component
兩個屬性就可以使用:
<Field name="email" component="input" type="text" />
而多餘的 props 都會被傳進 component 中,所以上例其實可以假想展開成以下 Pseudo Code:
<Field name="email" component="input" type="text">
<props.component name={props.name} {...props.rest} />
</Field>
但這樣的欄位實在太陽春了,沒有欄位標籤、沒有錯誤提示、沒有好看的 UI,不可能運用在實際的 App 中,所以我們重新設計一下欄位的 Pseudo Code,使用名為 FormField
的 Field Wrapper 包裝起來,並且原本的 component="input"
改寫為 adapter="input"
:
<Field name="email" component={FormField} adapter="input" type="text">
<props.component label="Email">
<label>{props.label}</label>
<props.adapter name={props.name} {...props.rest} />
{props.error && (
<HelpBlock>{props.error}</HelpBlock>
)}
</props.component>
</Field>
目前為止我們考慮的都是 input
形式的欄位,實際上還會有 checkbox、radio、select 等各式各樣的欄位形式,甚至還應該要能保留彈性擴充自訂的欄位,例如 reCaptcha、slider。我把這些自訂的欄位元件稱作 Field Adapter,也就是上面例子中的 props.adapter
屬性。
了解整體概念後,就可以建構實際的程式碼了:
let FormField = ({ label, adapter, meta, ...rest }) => {
let Adapter = adapter;
let isShowError = meta && meta.touched && meta.error;
return (
<div>
<label>{label}</label>
<Adapter {...rest} />
{isShowError && (
<HelpBlock>{meta.error}</HelpBlock>
)}
</div>
)
};
let Input = ({ input, type, ...rest }) => (
<input
{...input}
type={type}
{...rest}
/>
);
let LoginForm = () => (
// ...
<Field
name="email"
component={FormField}
label="Email"
adapter={Input}
type="text"
placeholder="Email"
/>
);
其中 input
和 meta
兩個 props 是 Redux-Form 產生的,所以請各位參考官方文件。
這樣的設計把 UI 的彈性保留在 Field Wrapper(例子中的 FormField
元件)與 Field Adapter(例子中的 Input
元件)上,例如你可以實作 Material Design 版本的 Wrapper 和 Adapter:
import FormField from './MaterialFormField';
import Input from './MaterialInput';
let LoginForm = () => (
// ...
<Field
name="email"
component={FormField}
label="Email"
adapter={Input}
type="text"
placeholder="Email"
/>
);
除了 Input 之外,還可以擴充各式各樣的欄位,像是 reCaptcha:
let DemoForm = () => (
// ...
<Field
name="someRecaptcha"
component={FormField}
label="Recaptcha"
adapter={Recaptcha}
/>
);
我在 Boilerplate 寫了一個 Playground 可以試玩所有內建的欄位元件,歡迎各位到 Demo Site 的 Form Elements 玩玩
前面已經解決了撰寫表單可能遇到的大部分疑難雜症,現在只剩下表單驗證這個雞八的東西了。Redux-Form 已經內建了表單驗證的機制了,只要撰寫 validate
Function 就可以控管各個欄位的 valid/invalid 狀態:
const validate = (values) => {
const errors = {};
if (!values.email) {
errors.email = 'Required';
}
if (!values.password) {
errors.password = 'Required';
}
return errors;
};
class LoginForm extends Component {
// ...
};
export default reduxForm({
form: 'USER_LOGIN',
validate,
})(LoginForm);
但是這一切的驗證機制都只發生在前端啊!!!後端怎麼辦呢?如果有使用者故意繞過 UI 上的防護機制,直接對 API Server 發送惡意 Request,那豈不是還要在 Server 上重寫一份欄位驗證的機制嗎?
請別忘了我們一直以來強調的 Isomorphism,表單的驗證是可以寫成 Isomorphic 版本的!請見以下 Server 端的表單驗證處理是如何延用 validate
Function 的:
export const validate = (values) => {
// ...
}
class ExampleForm extends Component {
// ...
};
export default reduxForm({
form: 'EXAMPLE_FORM',
validate,
})(ExampleForm);
首先我們把 validate
Function export
出來。
// src/server/routes/api.js
app.post('/api/example/path',
bodyParser.json,
validate.form('EXAMPLE_FORM'),
exampleController.create
);
接著在需要驗證的 API Path 加上 validate.form()
這個 Middleware,並且傳入表單名稱,讓 Middleware 知道要 Import 哪一張表單的 validate
Function。
該 Middleware 寫法如下,把 Server 收到的欄位資料送入指定的 validate
Function,接著再檢驗回傳值中是否夾帶錯誤訊息:
import validateErrorObject from '../utils/validateErrorObject';
export default {
form: (formPath) => (req, res, next) => {
let { validate } = require(`../../common/components/forms/${formPath}`);
let errors = validate(req.body);
if (!validateErrorObject(errors)) {
res.pushError(Errors.INVALID_DATA);
return res.errors();
}
next();
},
};
如此一來,原本 Redux-Form 的寫法不必做太多調整,只需要把 validate
Export 出去即可;同樣地,Server 也只需要補上一個 Middleware 就能進行 Server Side 的表單驗證,最重要的是,整個驗證邏輯只寫了一次,可說是完美落實了 Isomorphism。