iT邦幫忙

2017 iT 邦幫忙鐵人賽
DAY 15
0

表單是網頁中非常重要的元素,但同時也非常複雜,複雜之處在於,對每一個表單欄位而言,都可能有屬於自己的資料驗證流程、驗證錯誤處理、可能會需要呼叫 API、可能需要 State 來控制 UI、可能會有巢狀的動態欄位;對一張表單而言,可能會需要拆開成數張小表單,寫成 Wizard 的形式。

簡單來說表單的毛病特別多,大家寫出來的邏輯和 UI 通通不一致,UI 和邏輯很難脫鉤,程式碼難以維護,不過在我們的 Boilerplate 中使用了 Redux-Form 將邏輯抽取至 Flux 中,UI 的部分則是筆者自己提出了 WrapperAdapter 的寫法,我認為這樣的組合和結構極好,容易維護、容易擴充、高度複用,同時還很容易調整 UI,這是我經過幾番重構程式碼嘔心瀝血得到的最佳作法,希望讀者們喜歡。

Redux-Form

這裡先舉個登入表單當作例子,傳統寫法長這樣:

<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 中提供了 initializechange 等各種 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));

完整程式碼:src/common/components/forms/user/LoginForm.js

這個範例除了展示如何使用 Redux-Form 建構登入表單元件之外,裡面還透露了幾個重點:

  • 欄位驗證是 Program-Based 而不是 Configuration-Based,所以彈性非常高
  • _handleSubmit 是有回傳值的,當回傳值是 Promise 時可以用來控制 props.submitting
  • reduxForm 和 connect 兩個 HOC 並存時是可以運作的,而且順序關係必須是 reduxForm(connect(SomeComponent))

以上只是帶各位走過 Redux-Form 的冰山一角,其實它是個太強大的 Library,所以這邊的舉例只是給讀者們一個方向,目的不是要教會各位使用 Redux-Form,它本身的文件寫得非常詳盡,範例也很多元,所以各位千萬不要只看本篇文章粗略的示範,一定要抽空讀過官方文件,才能把這套 Library 活用。

Field Wrapper & Field Adapter

前面講的是整張表單的建構,接著我們來看每一個欄位是怎麼撰寫的。就舉 Email 欄位為例,按照 Redux-Form 最基本的寫法,只需要給定 namecomponent 兩個屬性就可以使用:

<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"
  />
);

其中 inputmeta 兩個 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}
  />
);

完整程式碼:src/common/components/forms/DemoForm.js

我在 Boilerplate 寫了一個 Playground 可以試玩所有內建的欄位元件,歡迎各位到 Demo Site 的 Form Elements 玩玩

Isomorphic Form Validation

前面已經解決了撰寫表單可能遇到的大部分疑難雜症,現在只剩下表單驗證這個雞八的東西了。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();
  },
};

完整程式碼:src/server/middlewares/validate.js

如此一來,原本 Redux-Form 的寫法不必做太多調整,只需要把 validate Export 出去即可;同樣地,Server 也只需要補上一個 Middleware 就能進行 Server Side 的表單驗證,最重要的是,整個驗證邏輯只寫了一次,可說是完美落實了 Isomorphism。


上一篇
Day 14 - Infrastructure - Isomorphic Routing
下一篇
Day 16 - Infrastructure - Authentication
系列文
30 天打造 MERN Stack Boilerplate30

尚未有邦友留言

立即登入留言