表單是網頁中非常重要的元素,但同時也非常複雜,複雜之處在於,對每一個表單欄位而言,都可能有屬於自己的資料驗證流程、驗證錯誤處理、可能會需要呼叫 API、可能需要 State 來控制 UI、可能會有巢狀的動態欄位;對一張表單而言,可能會需要拆開成數張小表單,寫成 Wizard 的形式。
簡單來說表單的毛病特別多,大家寫出來的邏輯和 UI 通通不一致,UI 和邏輯很難脫鉤,程式碼難以維護,不過在我們的 Boilerplate 中使用了 Redux-Form 將邏輯抽取至 Flux 中,UI 的部分則是筆者自己提出了 與 的寫法,我認為這樣的組合和結構極好,容易維護、容易擴充、高度複用,同時還很容易調整 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 中提供了 、 等各種 Function 可以操作表單或是某個特定欄位。
再來是 UI 部分,必須使用 Redux-Form 提供的 元件包裝每一個欄位,最後透過 這個 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));
完整程式碼:src/common/components/forms/user/LoginForm.jsopen_in_new
這個範例除了展示如何使用 Redux-Form 建構登入表單元件之外,裡面還透露了幾個重點:
以上只是帶各位走過 Redux-Form 的冰山一角,其實它是個太強大的 Library,所以這邊的舉例只是給讀者們一個方向,目的不是要教會各位使用 Redux-Form,它本身的文件寫得非常詳盡,範例也很多元,所以各位千萬不要只看本篇文章粗略的示範,一定要抽空讀過官方文件,才能把這套 Library 活用。
前面講的是整張表單的建構,接著我們來看每一個欄位是怎麼撰寫的。就舉 Email 欄位為例,按照 Redux-Form 最基本的寫法,只需要給定 與 兩個屬性就可以使用:
<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,使用名為 的 Field Wrapper 包裝起來,並且原本的 改寫為 :
<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>
目前為止我們考慮的都是 形式的欄位,實際上還會有 checkbox、radio、select 等各式各樣的欄位形式,甚至還應該要能保留彈性擴充自訂的欄位,例如 reCaptcha、slider。我把這些自訂的欄位元件稱作 Field 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" />);
其中 和 兩個 props 是 Redux-Form 產生的,所以請各位參考官方文件。
這樣的設計把 UI 的彈性保留在 Field Wrapper(例子中的 元件)與 Field Adapter(例子中的 元件)上,例如你可以實作 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 Elementsopen_in_new 玩玩
前面已經解決了撰寫表單可能遇到的大部分疑難雜症,現在只剩下表單驗證這個雞八的東西了。Redux-Form 已經內建了表單驗證的機制了,只要撰寫 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 端的表單驗證處理是如何延用 Function 的:
export const validate = (values) => { // ...}
class ExampleForm extends Component { // ...};
export default reduxForm({ form: 'EXAMPLE_FORM', validate,})(ExampleForm);
首先我們把 Function 出來。
// src/server/routes/api.jsapp.post('/api/example/path', bodyParser.json, validate.form('EXAMPLE_FORM'), exampleController.create);
接著在需要驗證的 API Path 加上 這個 Middleware,並且傳入表單名稱,讓 Middleware 知道要 Import 哪一張表單的 Function。
該 Middleware 寫法如下,把 Server 收到的欄位資料送入指定的 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 的寫法不必做太多調整,只需要把 Export 出去即可;同樣地,Server 也只需要補上一個 Middleware 就能進行 Server Side 的表單驗證,最重要的是,整個驗證邏輯只寫了一次,可說是完美落實了 Isomorphism。