V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
simonguo
V2EX  ›  前端开发

小而美的 React Form 组件

  •  1
     
  •   simonguo · 2017-09-09 19:43:51 +08:00 · 2627 次点击
    这是一个创建于 2413 天前的主题,其中的信息可能已经有所发展或是发生改变。

    背景

    之间在一篇介绍过 Table 组件《 React 实现一个漂亮的 Table 》 的文章中讲到过,在企业级后台产品中,用的最多且复杂的组件主要包括 Table、Form、Chart,在处理 Table 的时候我们遇到了很多问题。今天我们这篇文章主要是分享一下 Form 组件,在业务开发中, 相对 Table 来说,Form 处理起来更麻烦,不是所有表单都像注册页面那样简单,它往往需要处理非常多的逻辑,比如:

    • 需要处理数据验证逻辑。
    • 在一个项目中的很少有可以复用的表单,每个表单的逻辑都需要单独处理。
    • 在表单中往往存在需要自定义的组件,比如:Toggle、Tree、Picker 等等。

    Form 作为一个功能型组件,它需要解决的问题无非就是两个:

    • 把一个数据对象扔给它,它能够在让 Form 内的交互型组件获取到数据并展示出来,同时这些组件上的数据也能反过来让 Form 组件获取到。
    • 对数据的有效性进行验证。

    在 React 项目开发中没有用过其他 Form 相关的组件,但是这里我又忍不住想和 Ant Design 的 Form 对比一下,以下是我在 antd 官网上的一个截图:

    antd form

    大家可以通过以上图片看到,我想输入自己的中文名字都不能正常输入,这里主要存在两个问题:

    • onChange 被触发了多次,在一次中文未完整的输入前,不应该触发 onChange 事件。
    • 另外,中文在文本框内就不能正常显示。

    我在想这两个问题不在组件上处理,在哪里处理的呢?访问地址: https://ant.design/components/form-cn/ 可以试一下,也希望他们可以解决掉这个问题。这个问题应该怎么解决,我之前做过记录: [React 中, 在 Controlled(受控制)的文本框中输入中文 onChange 会触发多次]( https://github.com/hypers/blog/blob/master/source/_posts/react-issue.md#react-中-在-controlled 受控制的文本框中输入中文-onchange-会触发多次)。

    我们在设计的时候自然是解决了这些问题,预览效果: https://rsuitejs.com/form-lib/ ,接下来看一下具体的设计。

    设计

    针对前面提到 Form 需要解决的两个问题(数据校验和数据获取),在设计的时候,我们把这个两个问题作为两个功能,独立在不同的库处理。

    • form-lib 处理表单数据初始化,数据获取。
    • rsuite-schema 定义数据模型,做数据有效性验证。

    这两个库可以独立使用,如果你有自己一套自己的 Form 组件,只是缺少一个数据验证的工具,那你可以单独把 rsuite-schema 拿过来使用。

    Form 定义一个表单

    分别看一下它们是怎么工作的,form-lib 用起来比较简单,提供了两个组件:

    • <Form> 处理整个表单服务的逻辑
    • <Field> 处理表单中各个交互型组件的逻辑,比如 input,select

    看一个示例:

    安装

    首先需要安装 form-lib

    npm i form-lib --save
    

    示例代码

    import { Form, Field, createFormControl } from 'form-lib';
    const SelectField = createFormControl('select');
    
    const user = {
      name:'root',
      status:1
    };
    
    
    <Form data={user}>
      <Field name="name"   />
      <Field name="status" accepter={SelectField} >
        <option value={1}>启用</option>
        <option value={0}>禁用</option>
      </Field>
    </Form>
    

    在默认情况下 Field 是一个文本输入组件,如果你需要使用 HTML 表单中其他的标签,你可以像上面示例一样 通过 createFormControl(标签名称) 方法创建一个组件, 在把这个组件通过 accepter 属性赋给Field 组件,这样你就能像操作原生 HTML 一样设置 Field。 通过这种方式,后面在功能介绍的时候会讲到怎么把自定义组件放在 Field 中。

    自定义布局

    这里存在一个疑问,<Field> 必须放在 <Form> 下面第一层吗? 如果对布局没有要求,设计成这样是处理起来最方便的,因为在 <Form> 中可以直接通过 props.children 获取到所有的 <Field>,想怎么处理都可以。 刚开始我们是这样的,后来在实际应用中发现,表单的布局是有很多种,如果要设计成这样,那肯定就带来一个问题,不好自定义布局。 所以这里 <Form><Field> 之间的通信我们用的是 React 的 context。 这样的话你就可以任意布局:

    <Form data={user}>
      <div className="row">
        <Field name="name"   />
      </div>
      <div className="row">
        <Field name="status" accepter={SelectField} >
          <option value={1}>启用</option>
          <option value={0}>禁用</option>
        </Field>
      </div>
    </Form>
    

    Schema 定义一个数据模型

    安装

    npm i rsuite-schema --save
    

    rsuite-schema 主要有两个对象

    • SchemaModel 用于定义数据模型。
    • Type 用于定义数据类型,包括:
      • StringType
      • NumbserType
      • ArrayType
      • DateType
      • ObjectType
      • BooleanType

    这里的 Type 有点像 React 中 PropTypes 的定义。

    示例代码

    一个示例:

    const userModel = SchemaModel(
        username: StringType().isRequired('用户名不能为空'),
        email: StringType().isEmail('请输入正确的邮箱'),
        age: NumberType('年龄应该是一个数字').range(18, 30, '年应该在 18 到 30 岁')
    });
    

    这里定义了一个 userModel, 包含 usernameemailage 3 个字段, userModel 拥有了一个 check 方法, 当把数据扔进去后会返回验证结果:

    const checkResult = userModel.check({
        username: 'foobar',
        email: '[email protected]',
        age: 40
    })
    
    // checkResult 结果:
    /**
      {
          username: { hasError: false },
          email: { hasError: false },
          age: { hasError: true, errorMessage: '年应该在 18 到 30 岁' }
      }
    **/
    

    多重验证

    StringType()
      .minLength(6,'不能少于 6 个字符')
      .maxLength(30,'不能大于 30 个字符')
      .isRequired('该字段不能为空');
    

    自定义验证

    通过 addRule 函数自定义一个规则。 如果是对一个字符串类型的数据进行验证,可以通过 pattern 方法设置一个正则表达式进行自定义验证。

    const myModel = SchemaModel({
        field1: StringType().addRule((value) => {
            return /^[1-9][0-9]{3}\s?[a-zA-Z]{2}$/.test(value);
        }, '请输入合法字符'),
        field2: StringType().pattern(/^[1-9][0-9]{3}\s?[a-zA-Z]{2}$/, '请输入合法字符')
    });
    

    自定义动态错误信息

    例如,要通过值的不同情况,返回不同的错误信息,参考以下

    const myModel = SchemaModel({
        field1: StringType().addRule((value) => {
            if(value==='root'){
              return {
                hasError: true,
                errorMessage:'不能是关键字 root'
              }
            }else if(!/^[a-zA-Z]+$/.test(value)){
              return {
                hasError: true,
                errorMessage:'只能是英文字符'
              }
            }
    
            return {
                hasError: false
            }
        })
    });
    

    复杂结构数据验证

    const userModel = SchemaModel({
      username:StringType().isEmail('正确的邮箱地址').isRequired('该字段不能为空'),
      tag: ArrayType().of(StringType().rangeLength(6, 30, '字符个数只能在 6 - 30 之间')),
      profile: ObjectType().shape({
        email: StringType().isEmail('应该是一个 email'),
        age: NumberType().min(18, '年龄应该大于 18 岁')
      })
    })
    

    更多设置,可以查看 rsuite-schema API 文档

    Form 与 Schema 的结合

    const userModel = SchemaModel({
        username: StringType().isRequired('用户名不能为空'),
        email: StringType().isEmail('请输入正确的邮箱'),
        age: NumberType('年龄应该是一个数字').range(18, 30, '年应该在 18 到 30 岁')
    });
    
    <Form model={userModel}>
      <Field name="username" />
      <Field name="email" />
      <Field name="age" />
    </Form>
    

    把定义的的SchemaModel对象赋给,<Form>model 属性,就把它们绑定起来了,<Field>name 对应 SchemaModel 对象中的 key

    以上的示例代码是不完整的,没有处理错误信息和获取数据,只是为了方便大家理解。完整的示例,可以参考接下来的实践与解决方案。

    实践与解决方案

    一个完整的示例

    import React from 'react';
    import { Form, Field, createFormControl } from 'form-lib';
    import { SchemaModel, StringType } from 'rsuite-schema';
    
    const TextareaField = createFormControl('textarea');
    const SelectField = createFormControl('select');
    
    const model = SchemaModel({
      name: StringType().isEmail('请输入正确的邮箱')
    });
    
    class DefaultForm extends React.Component {
      constructor(props) {
        super(props);
        this.state = {
          values: {
            name: 'abc',
            status: 0
          },
          errors: {}
        };
        this.handleSubmit = this.handleSubmit.bind(this);
      }
      handleSubmit() {
        const { values } = this.state;
        if (!this.form.check()) {
          console.error('数据格式有错误');
          return;
        }
        console.log(values, '提交数据');
      }
      render() {
        const { errors, values } = this.state;
        return (
          <div>
            <Form
              ref={ref => this.form = ref}
              onChange={(values) => {
                console.log(values);
                this.setState({ values });
                // 清除表单所有的错误信息
                this.form.cleanErrors();
              }}
              onCheck={(errors) => {
                this.setState({ errors });
              }}
              values={values}
              model={model}
            >
              <div className="form-group">
                <label>邮箱: </label>
                <Field name="name" className="form-control" />
                <span className="help-block error" style={{ color: '#ff0000' }}>
                  {errors.name}
                </span>
              </div>
    
              <div className="form-group">
                <label>状态: </label>
                <Field name="status" className="form-control" accepter={SelectField} >
                  <option value={1}>启用</option>
                  <option value={0}>禁用</option>
                </Field>
              </div>
    
              <div className="form-group">
                <label>描述 </label>
                <Field name="description" className="form-control" accepter={TextareaField} />
              </div>
              <button onClick={this.handleSubmit}> 提交 </button>
            </Form>
          </div>
        );
      }
    }
    
    export default DefaultForm;
    

    在 rsuite 中的应用

    rsuite 提供了很多 Form 相关的组件,比如 FormGroup,FormControl,ControlLabel,HelpBlock 等等, 我们通过一个例子看一下怎么结合使用。

    通过上一个例子中我们可以看到,没有个 Field 中有很多公共部分,所以我们可以自定义一个无状态组件 CustomField,把 ControlLabel,Field,HelpBlock 这些表单元素都放在一起。

    
    import React from 'react';
    import { Form, Field, createFormControl } from 'form-lib';
    import { SchemaModel, StringType, ArrayType } from 'rsuite-schema';
    import {
      FormControl,
      Button,
      FormGroup,
      ControlLabel,
      HelpBlock,
      CheckboxGroup,
      Checkbox
    } from 'rsuite';
    
    const model = SchemaModel({
      name: StringType().isEmail('请输入正确的邮箱'),
      skills: ArrayType().minLength(1, '至少应该会一个技能')
    });
    
    const CustomField = ({ name, label, accepter, error, ...props }) => (
      <FormGroup className={error ? 'has-error' : ''}>
        <ControlLabel>{label} </ControlLabel>
        <Field name={name} accepter={accepter} {...props} />
        <HelpBlock className={error ? 'error' : ''}>{error}</HelpBlock>
      </FormGroup>
    );
    
    class DefaultForm extends React.Component {
      constructor(props) {
        super(props);
        this.state = {
          values: {
            name: 'abc',
            skills: [2, 3],
            gender: 0,
            status: 0
          },
          errors: {}
        };
        this.handleSubmit = this.handleSubmit.bind(this);
      }
    
      handleSubmit() {
        const { values } = this.state;
        if (!this.form.check()) {
          console.error('数据格式有错误');
          return;
        }
        console.log(values, '提交数据');
      }
    
      render() {
        const { errors, values } = this.state;
        return (
          <div>
            <Form
              ref={ref => this.form = ref}
              onChange={(values) => {
                this.setState({ values });
                console.log(values);
              }}
              onCheck={errors => this.setState({ errors })}
              defaultValues={values}
              model={model} >
    
              <CustomField
                name="name"
                label="邮箱"
                accepter={FormControl}
                error={errors.name}
              />
    
              <CustomField
                name="status"
                label="状态"
                accepter={FormControl}
                error={errors.status}
                componentClass="select"
              >
                <option value={1}>启用</option>
                <option value={0}>禁用</option>
              </CustomField>
    
              <CustomField
                name="skills"
                label="技能"
                accepter={CheckboxGroup}
                error={errors.skills}
              >
                <Checkbox value={1}>Node.js</Checkbox>
                <Checkbox value={2}>Javascript</Checkbox>
                <Checkbox value={3}>CSS 3</Checkbox>
              </CustomField>
    
              <CustomField
                name="gender"
                label="性别"
                accepter={RadioGroup}
                error={errors.gender}
              >
                <Radio value={0}>男</Radio>
                <Radio value={1}>女</Radio>
                <Radio value={2}>未知</Radio>
              </CustomField>
    
              <CustomField
                name="bio"
                label="简介"
                accepter={FormControl}
                componentClass="textarea"
                error={errors.bio}
              />
              <Button shape="primary" onClick={this.handleSubmit}> 提交 </Button>
            </Form>
          </div>
        );
      }
    }
    export default DefaultForm;
    

    自定义 Field

    如果一个组件不是原生表单控件,也不是 RSuite 库中提供的基础组件,要在 form-lib 中使用,应该怎么处理呢? 只需要在写组件的时候实现以下对应的 API:

    • value : 受控时候设置的值
    • defalutValue: 默认值,非受控情况先设置的值
    • onChange: 组件数据发生改变的回调函数
    • onBlur: 在失去焦点的回调函数(可选)

    接下来我们使用 rsuite-selectpicker 作为示例, 在 rsuite-selectpicker 内部已经实现了这些 API。

    import React from 'react';
    import { SchemaModel, NumberType } from 'rsuite-schema';
    import { Button, FormGroup, ControlLabel, HelpBlock } from 'rsuite';
    import Selectpicker from 'rsuite-selectpicker';
    import { Form, Field } from 'form-lib';
    
    const model = SchemaModel({
      skill: NumberType().isRequired('该字段不能为空')
    });
    
    const CustomField = ({ name, label, accepter, error, ...props }) => (
      <FormGroup className={error ? 'has-error' : ''}>
        <ControlLabel>{label} </ControlLabel>
        <Field name={name} accepter={accepter} {...props} />
        <HelpBlock className={error ? 'error' : ''}>{error}</HelpBlock>
      </FormGroup>
    );
    
    class CustomFieldForm extends React.Component {
      constructor(props) {
        super(props);
        this.state = {
          values: {
            skill: 3,
          },
          errors: {}
        };
        this.handleSubmit = this.handleSubmit.bind(this);
      }
      handleSubmit() {
        const { values } = this.state;
        if (!this.form.check()) {
          console.error('数据格式有错误');
          return;
        }
        console.log(values, '提交数据');
      }
      render() {
        const { errors, values } = this.state;
        return (
          <div>
            <Form
              ref={ref => this.form = ref}
              onChange={(values) => {
                this.setState({ values });
                console.log(values);
              }}
              onCheck={errors => this.setState({ errors })}
              defaultValues={values}
              model={model}
            >
    
    
              <CustomField
                name="skill"
                label="技能"
                accepter={Selectpicker}
                error={errors.skill}
                data={[
                  { label: 'Node.js', value: 1 },
                  { label: 'CSS3', value: 2 },
                  { label: 'Javascript', value: 3 },
                  { label: 'HTML5', value: 4 }
                ]}
              />
              <Button shape="primary" onClick={this.handleSubmit}> 提交 </Button>
            </Form>
          </div>
        );
      }
    }
    
    export default CustomFieldForm;
    

    更多示例:参考

    如果你在使用中存在任何问题,可以提交 issues,如果你有什么好的想法欢迎你 pull request,GitHub 地址:

    4 条回复    2017-09-17 23:44:23 +08:00
    yuchenyang1994
        1
    yuchenyang1994  
       2017-09-11 09:59:04 +08:00 via Android
    支持小铭大大
    SkYouth
        2
    SkYouth  
       2017-09-15 10:00:35 +08:00
    厉害
    gotounix
        3
    gotounix  
       2017-09-15 10:56:34 +08:00
    贴几个动图比贴代码实用,关注度高,我看了下,非常不错啊。
    pacino
        4
    pacino  
       2017-09-17 23:44:23 +08:00
    我也趟过 antd 的这个,楼主坑厉害了。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   3104 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 37ms · UTC 14:30 · PVG 22:30 · LAX 07:30 · JFK 10:30
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.