【转】使用React和HTML5表单验证API处理表单元素

(最近在折腾 React,表单这块想用原生校验,找资料时发现了这篇文章。原译文代码的部分有些乱,所以处理了一下并转到这里。)

【译者注:链接序号对应下面扩展阅读,另外可以点击阅读原文查看详细的链接文章】

简介:React 没有内置的表单验证逻辑,但是我们可以使用第三方解决方案。这种方法可能是开发包、表单生成器,还可能是 HOC 或者是包含校验逻辑的任意表单容器组件。那么选择哪种方法呢?我们将在本文中一 一介绍。

每当我们提及应用程序中的用户输入框时,首先映入脑海的就是 HTML 的表单元素。最早的 HTML 版本就已经支持 Web 表单。众所周知,这一特性于 1991 年提出,且在 1995 年作为 RFC(征求评议文件) 1866 号协议[1]标准化。与此同时表单元素也得到了广泛应用,几乎每一个代码库和框架中都在使用。那么在 React 中如何使用呢? Facebook 在如何处理表单上提供了受控的输入框[2]。该输入框指的是受控表单,主要是通过交互事件和通过 value 属性传递 state 值实现对输入框的控制。因此,你可以决定表单的校验和提交逻辑。拥有好的用户体验的 UI ,意味着你应该考虑到诸如“提交”、“校验”的逻辑以及内联的错误提示信息,根据有效性、原始状态和提交状态来切换元素。难道我们不能提取这种逻辑,简单的插入到表单元素中吗?当然可以,唯一要考虑的问题是我们要采用何种方法和解决方案。

组件库中的表单

如果你习惯使用诸如 ReactBootstrap[3] 或者 AntDesign[4] 等 React 组件库,很可能已经对其中的表单组件感到满意。这两个组件库所提供的表单组件满足了多种需求。例如在 AntDesign 组件库中我们定义了一个表单元素 Form 以及带有表单域的 FormItem ,即包裹了任意输入控制的容器。你可以在 FormItem 设置校验规则:

<FormItem>
   {getFieldDecorator('select', {
     rules: [
       { required: true, message: 'Please select your country!' },
     ],
   })(
     <Select placeholder="Please select a country">
       <Option value="china">China</Option>
       <Option value="use">U.S.A</Option>
     </Select>
   )}
</FormItem>

举例来说,在处理表单提交时,你可以执行 this.props.form.validateFields() 来验证。这样看起来似乎所有事情都考虑到了。然而该解决方案是针对框架的。如果你没有使用这些组件库,则无法使用该方法。

基于schema构建表单

或者我们可以基于 JSON 细则构建单独的表单组件,例如我们可以引入 Winterfell 组件[5],构建如下所示的表单组件

<Winterfell schema={loginSchema} ></Winterfell>

然而这个方案是相当复杂的[6]。除此之外,我们还需要满足特定的语法。类似的另一种解决方案 react-jsonschema-form[7],依赖于 JSON schema[8]。JSON schema 是一种与项目无关的数据文档,用来注释和校验 JSON 文档。但是,它可以将我们定义的特有属性应用在项目中,且在文档中定义。

Formsy(译者注:一种 React 表单验证组件)

我更倾向于使用一种具有逻辑校验,适用于任意 HTML 表单的组件。其中最常用的方法是—— Formsy[9]。是什么样的结构呢?我构建了自己的表单组件,并用HOC(Higher-Order Components,高阶组件)把 Formsy 包裹起来:

import { withFormsy } from "formsy-react";
import React from "react";

class MyInput extends React.Component {

  changeValue = ( event ) => {
    this.props.setValue( event.currentTarget.value );
  }

  render() {
    return (
      <div>
        <input
          onChange={ this.changeValue }
          type="text"
          value={ this.props.getValue() || "" }
        />
        <span>{ this.props.getErrorMessage() }</span>
      </div>
    );
  }
}

export default withFormsy( MyInput );

如上所示,组件的 props 属性中接收了 getErrorMessage() 函数,该函数可以用来生成行内错误提示信息。
这样我们开发了一个输入域组件,将其放入表单中如下所示:

import Formsy from "formsy-react";
import React from "react";
import MyInput from "./MyInput";

export default class App extends React.Component {

  onValid = () => {
    this.setState({ valid: true });
  }

  onInvalid = () => {
    this.setState({ valid: false });
  }

  submit( model ) {
    //...
  }

  render() {
    return (
      <Formsy onValidSubmit={this.submit} onValid={this.onValid} onInvalid={this.onInvalid}>
        <MyInput
          name="email"
          validations="isEmail"
          validationError="This is not a valid email"
          required
        ></MyInput>
        <button type="submit" disabled={ !this.state.valid }>Submit</button>
      </Formsy>
    );
  }
}

我们指定所有需要验证的区域都会添加 validations 属性(详见 validations 列表属性[10])。使用 validationError 属性来设置所期望的校验信息,通过 onValid 和 onInvalid 接收表单校验状态。

这样看上去很简单、干净、灵活。但是我想知道的是为什么我们不依赖 HTML5 的表单验证[11],而是使用繁多的自定义实现呢?

HTML5表单验证

这项技术在很早之前就出现了,首次实现是在 2008 年的 Opera 9.5 中。现在所有的现代浏览器都支持它。表单(数据)验证[12]引入额外的 HTML 属性和 input 类型,这些可以用来设置表单的校验规则。这些校验还可以使用特有的 API [13]来控制和自定义表单JavaScript。

让我们看下面的代码:

<form>
  <label for="answer">What do you know, Jon Snow?</label>
  <input id="answer" name="answer" required>
  <button>Ask</button>
</form>

这是一个简单的表单,期望的功能 —— <input> 元素有required的属性。因此,如果我们快速按下提交按钮,表单内容不会提交到服务器。相反我们会看到在输入框旁出现提示信息,提示 value 值没有满足给出的限制条件(不能为空)。

现在我们给输入框增加约束条件:

<form>
  <label for="answer">What do you know, Jon Snow?</label>
  <input id="answer" name="answer" required pattern="nothing|nix">
  <button>Ask</button>
</form>

错误提示信息并没有给出我们期望的信息,不是吗?我们可以自定义它(例如为了解释所期望的用户输入值)或者仅仅对输入值进行转换。

<form>
  <label for="answer">What do you know, Jon Snow?</label>
  <input id="answer" name="answer" required pattern="nothing|nix">
  <button>Ask</button>
</form>
const answer = document.querySelector( "[name=answer]" );
answer.addEventListener( "input", ( event ) => {
  if ( answer.validity.patternMismatch ) {
    answer.setCustomValidity("Oh, it's not a right answer!");
  } else {
    answer.setCustomValidity( "" );
  }
});

以上代码只是检查 input 框的输入事件,其校验状态 patternMismatch 的变化。当输入的值与定义的 pattern 不匹配时,就会出现错误提示信息。如果我们设置了其他的限制条件[14],也会被我们所定义的事件处理函数所覆盖。

你对这个工具提示不太满意?是的,它在不同的浏览器中表现形式是不一样的。让我们给表单元素增加novalidate属性,并且自定义错误提示:

<form novalidate>
  <label for="answer">What do you know, Jon Snow?</label>
  <input id="answer" name="answer" required pattern="nothing|nix">
  <div data-bind="message"></div>
  <button>Ask</button>
</form>
const answer = document.querySelector( "[name=answer]" ),
      answerError = document.querySelector( "[name=answer] + [data-bind=message]" );

answer.addEventListener( "input", ( event ) => {
  answerError.innerHTML = answer.validationMessage;
});

尽管这个介绍比较简短,你也能体会到技术背后的力量和灵活性。最重要的是,它是原生的表单验证。所以我们为什么要依赖繁多的自定义库。而不去使用原生的验证呢?

满足表单校验的 React API

react-html5-form 将 React(还可以选择Redux) 和 HTML5 表单校验 API 联系起来了。它提供 From 组件和 InputGroup 组件(类似于 Formsy 组件库中的 input,或者 AntDesign 组件库中的 FormItem )。这样,Form 组件定义了表单及其作用区域。InputGroup 组件可以包含一个或多个输入框。我们简单地用这些组件包裹一个任意的表单内容(只是普通的 HTML 或 React 组件)。在用户事件上,我们可以请求表单验证,并根据有效的输入值,获得 FormInputGroup 组件的更新状态。

好了,让我们实践一下,首先我们定义表单区域:

import React from "react";
import { render } from "react-dom";
import { Form, InputGroup } from "Form";

const MyForm = props => (
  <Form>
  {({ error, valid, pristine, submitting, form }) => (
      <>
      Form content
      <button disabled={ ( pristine || submitting ) } type="submit">Submit</button>
      </>
    )}
  </Form>
);

render( <MyForm ></MyForm>, document.getElementById( "app" ) );

作用域接收到的状态对象具有以下属性:

  • error - 表单错误信息(通常指的是服务端的校验信息),可以通过 form.setError() 进行设置;
  • valid - 布尔值,表示所有的输入是否全部满足规定的约束;
  • pristine - 布尔值,表示用户是否和表单进行过交互;
  • submitting - 布尔值,表示是否正在提交表单(当用户按下提交按钮时该状态转化为 true,一旦用户定义的异步提交逻辑处理结束,该值变为 false)
  • form - 用于访问表单组件 API 的表单实例;

在这里我们使用 pristinesubmitting 属性将提交按钮切换为禁用状态。

为了在提交表单的同时校验表单输入信息,我们使用 InputGroup 包裹这些input表单。

<InputGroup validate={[ "email" ]}>
  {({ error, valid }) => (
    <div>
      <label htmlFor="emailInput">Email address</label>
      <input
        type="email"
        required
        name="email"
        id="emailInput" />
      { error && (<div className="invalid-feedback">{error}</div>)  }
    </div>
  )}
</InputGroup>

使用 validate 属性我们可以指定该组内应该使用什么样的 input。[“email”] 意味着我们只有一个名称为“email”的input。

在这个作用区域内我们接收到的状态对象具有下面的属性:

  • errors – 所有注册 input 的错误信息数组;
  • error – 最后显示的错误信息;
  • valid – 布尔值,表示是否所有的输入数据全部满足规定的约束条件;
  • inputGroup – 访问表单组件 API 的实例;

渲染之后我们可以得到一个 email 类型的输入框。如果该值为空,或在提交时包含无效的电子邮件地址,则在输入框旁边显示相应的验证消息。

还记得我们在使用原生表单校验 API 自定义错误提示信息时的焦头烂额吗?使用 InputGroup 这一情况将变得好转:

<InputGroup
    validate={[ "email" ]}
    translate={{
      email: {
        valueMissing: "C'mon! We need some value",
        typeMismatch: "Hey! We expect an email address here"
      }
    }}>
...

我们可以将每个 input 定义为 key-value 的哈希结构,其中 keys 表示有效性属性[15],values 表示自定义消息。

自定义消息很简单。那么自定义验证该如何实现呢?我们可以使用校验的 prop 来做到:

<InputGroup validate={{
    "email": ( input ) => {
      if ( !EMAIL_WHITELIST.includes( input.current.value ) ) {
        input.setCustomValidity( "Only whitelisted email allowed" );
        return false;
      }
      return true;
    }
  }}>
...

在这个例子中,我们提供的是包含输入框名字的哈希而不是数组,其中 key 是输入框的名字, value 是验证处理函数。处理函数校验输入值(可以异步完成)并返回一个布尔类型的有效状态。使用 input.setCustomValidity,我们可以自定义验证消息。

输入框并不总是提交时才进行校验。为了达到实时验证,首先我们需要给输入事件定义事件处理函数:

const onInput = ( e, inputGroup ) => {
  inputGroup.checkValidityAndUpdate();
};

事实上,我们可以在用户每次输入时重新验证。控制逻辑如下所示:

<input
  type="email"
  required
  name="email"
  onInput={( e ) => onInput( e, inputGroup, form ) }
  id="emailInput" />

这样,每当输入值改变时都会进行验证。如果输入值是无效的,我们就会马上收到错误的提示信息。

你可以通过这里找到以上示例的源代码[16]

顺便说一句,你是否考虑过将组件派生的表单状态树和 Redux 存储值联系起来?我们也可以这样做。

Redux 提供了包含所有注册表单状态树的 reducer:html5form。我们可以按照如下所示将 html5form 和 store 结合起来:

import React from "react";
import { render } from "react-dom";
import { createStore, combineReducers } from "redux";
import { Provider } from "react-redux";

import { App } from "./Containers/App.jsx";
import { html5form } from "react-html5-form";

const appReducer = combineReducers({
  html5form
});

// Store creation
const store = createStore( appReducer );

render( <Provider store={store}>
  <App ></App>
 </Provider>, document.getElementById( "app" ) );

当我们运行该应用程序时,就能在 store 中找到所有与表单相关的状态。

点击这里[17]查看上述 demo 的源代码。

总结

React 没有内置的表单验证逻辑,但是我们可以使用第三方解决方案。这种方法可能是开发包、表单生成器,还可能是 HOC 或者是包含校验逻辑的任意表单容器组件。我个人倾向于使用容器组件,该组件依赖于 HTML 内置表单验证 API ,并在表单和表单域的范围中显示有效性状态。

  • react-html5-form 源代码[18]
  • 演示例子链接[19]

扩展阅读

  1. https://tools.ietf.org/html/rfc1866
  2. https://reactjs.org/docs/forms.html
  3. https://react-bootstrap.github.io/components/forms/
  4. https://ant.design/components/form/
  5. https://github.com/andrewhathaway/Winterfell
  6. https://github.com/andrewhathaway/Winterfell/blob/master/examples/schema.js
  7. https://github.com/mozilla-services/react-jsonschema-form
  8. http://json-schema.org/
  9. https://github.com/formsy/formsy-react
  10. https://github.com/formsy/formsy-react/blob/master/API.md#validators
  11. https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Form_validation
  12. https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Form_validation
  13. https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5/Constraint_validation
  14. https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5/Constraint_validation
  15. https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
  16. https://github.com/dsheiko/react-html5-form/blob/master/demo/bootstrap/src/index.jsx
  17. https://github.com/dsheiko/react-html5-form/blob/master/demo/bootstrap-redux/src/index.jsx
  18. https://github.com/dsheiko/react-html5-form
  19. https://dsheiko.github.io/react-html5-form