抹桥的博客
2570 字
13 分钟
一个 React Form 组件的重构思路
2017-03-18

最近对团队内部 React 组件库(ne-rc)中的 Form 组件进行了重构,记录一下思考的过程。

一些前置定义:

名词定义
表单Form 组件
子表单嵌套在 Form 下面的类似 Input, Select 这样的子组件

首先我们看一下,我们的对 Form 组件的需求是什么。

  1. 获取当前变动表单的状态
    • 校验所有必填表单是否填写完成
    • 对外触发具体表单变化的方法 formFieldChange
  2. 暴露对外提供整个表单状态的方法
    • 提供整个表单最新状态的方法 $Form.data
  3. 提交方法
    • 校验表单是否通过校验
    • 对外触发 formSubmit 方法

接着我们从重构前和重构后,看如何来解决这个问题。

Before#

获取当前变动表单的状态#

如何获取变动的子表单#

React 父子通信需要通过 prop 传递方法,对于 Form 下面的类似与 Input 之类的子表单的变化想要通知到父级,如果不借助第三方的事件传递方法,那么就只能通过由父级通过 props 向 Input 传递 formFieldChange(假设就叫这个名字)方法,然后当子组件变化时去调用 formFieldChange 来实现。

那么问题来了,什么时候去传递这个方法呢?

不能在具体页面里面使用的时候再去每条表单里面注册这个方法,那每个用到表单组件的时候就都需要给子表单进行这样的事件绑定,这样太累了。

所以一开始,我选择通过直接递归的遍历 Form 下面的 children,只要发现这个 children 是我想要的表单类型,那么就重新克隆一个带有 formFieldChange 的组件来替换掉原来的组件。

/**
 * 获取 form 下面每一个表单对象,注入属性,并收集起来
 * @param children
 * @returns {*}
 */
function getForms(children) {
  return React.Children.map(children, (el, i) => {
    if (!el) {
      return null
    }
    switch (el.type) {
      case Input:
        Forms.push(el)
        return React.cloneElement(el, {
          key: i,
          formFieldChange,
          emptyInput,
        })
      case Select:
        Forms.push(el)
        return React.cloneElement(el, {
          key: i,
          formFieldChange,
        })
      case CheckBox:
        Forms.push(el)
        return React.cloneElement(el, {
          key: i,
          formFieldChange,
        })
      default:
        if (el.props && el.props.children instanceof Array) {
          const children = getForms(el.props.children)
          return React.cloneElement(el, {
            key: i,
            children,
          })
        } else {
          return el
        }
    }
  })
}

这样,所有的特定子组件就都可以拿到被注册的方法。以 Input 为例,在 Input 的 onChange 方法里面去调用从父级 props 传入的 formFieldChange 就可以通知到 Form 组件了。

收集变动表单的数据。#

前一步完成后,这一步就比较简单了,Input 在调用 formFieldChange 的时候把想要传递的数据作为参数传进去,在 Form 里面去对这个参数做处理,就可以拿到当前变动的表单状态数据了。

校验表单是否填写完成#

前面我们收集了每一条变动表单的数据。但是要判断当前 Form 下面的表单是否填写完成,那么首先需要知道我们有多少个需要填写的表单,然后在 formFieldChange 的时候进行判断就可以了。如何来提前知道我们有多少需要填写的 Field 呢,之前我选择的是通过在使用 Form 的时候先初始化一个包含所有表单初始化状态的数据。

export default class Form extends React.Component {
  constructor (props) {
    super(props)
    this.Forms = []
    this.formState = Object.assign({}, {
      isComplete: false,
      isValidate: false,
      errorMsg: '',
      data: {}
    }, this.props.formState)
  }

  static propTypes = {
    onChange: PropTypes.func,
    onSubmit: PropTypes.func,
    formState: PropTypes.object
  }
  // 初始化一个类似这样的对象传递给 Form
  formState: {
    data: {
      realName: {},
      cityId: {},
      email: {},
      relativeName: {},
      relativePhone: {},
      companyName: {}
    }
  }
}

这样就很粗暴的解决了这个问题,但是这中间存在很多问题。

因为限定了特定的组件类型(Input,Select,CheckBox),导致不利于扩展,如果在开发过程遇到其他类型的比如自定义的子表单,那么 Form 就没法对这个自定义子表单进行数据收集,解决起来比较麻烦。

所以就在考虑另一个种实现方式, Form 只去收集一个特定条件下的组件,只要这个组件满足了这个条件,并实现了对应的接口,那么 Form 就都可以去收集处理。这样也就大大挺高了适用性。

暴露对外提供整个表单状态的方法#

通过在外监听每次 Form 触发的 onChange 事件来获取整个 Form 的状态。

提交方法#

检验表单是否通过校验#

已经有了整个 Form 的数据对象,做校验并不是什么困难。通过校验的时候调用 formSubmit 方法,没有通过校验的时候对外把错误信息添加到 Form 的 state 上去。

对外触发 formSubmit 方法#

当表单通过校验的时候,对外触发 formSubmit 方法,把要提交的数据作为 formSubmit 的参数传递给外面。

After#

前面是之前写的 Form 组件的一些思路,在实际使用中也基本能满足业务需求。

但是整个 Form 的可拓展性比较差,无法很好的接入其他自定义的组件。所以萌生了重写的想法。

对于重写的这个 Form,我的想法是:首先一定要方便使用,不需要一大堆的起始工作;其次就是可拓展性要强,除了自己已经提供的内在 Input,Select 等能够接入 Form 外,对于其他的业务中的特殊需求需要接入 Form 的时候,只要这个组件实现了特定的接口就可以了很方便的接入,而不需要大量的去修改组件内部的代码。

重构主要集中在上面需求 1 里面的内容,也就是:获取当前变动表单的状态

获取当前表单的状态分解下来有一下几点:

  • 获取所有需要收集的子表单 formFields
  • 初始化 Form state
  • 表单下面子表单数量或类型发生变化时更新 1 里面创建的 formFields
  • 子表单内部状态发生变化时通知到父表单

获取当前变动表单的状态#

获取所有需要的子表单#

同样通过递归遍历 children 来获取需要收集的子表单,通过子表单的 type.name 命名规则是否符合我们的定义来决定是否要进行收集。 直接来看代码:

collectFormField = (children) => {
  const handleFieldChange = this.handleFieldChange

  // 简单粗暴,在 Form 更新的时候直接清空上一次保存的 formFields,全量更新,
  // 避免 formFields 内容或者数量发生变化时 this.formFields 数据不正确的问题
  const FormFields = (this.formFields = [])

  function getChildList(children) {
    return React.Children.map(children, (el, i) => {
      // 只要 Name 以 _Field 开头,就认为是需要 From 管理的组件
      if (!el || el === null) return null

      const reg = /^_Field/
      const childName = el.type && el.type.name
      if (reg.test(childName)) {
        FormFields.push(el)
        return React.cloneElement(el, {
          key: i,
          handleFieldChange,
        })
      } else {
        if (el.props && el.props.children) {
          const children = getChildList(el.props.children)
          return React.cloneElement(el, {
            key: i,
            children,
          })
        } else {
          return el
        }
      }
    })
  }
}

只要组件的 class name 以 _Field 开头,就把它收集起来,并传入 handleFieldChange 方法,这样当一个自定义组件接入的时候,只需要在外面包一层,并把 class 的命名为以 _Field 开头的格式就可以被 Form 收集管理了。

接入组件里面需要做的就是,在合适的时机调用 handleFieldChange 方法,并把要传递的数据作为参数传递出来就可以了。

为什么一定要执迷不悟的使用遍历这种低效的方式去收集呢,其实都是为了组件上使用的方便。这样就不需要每次在引用的时候在对子表单做什么操作了。

初始化 Form state#

上一步拿到了所有的子表单,然后通过调用 initialFormDataStructure 拿来初始化 Form 的 state.data 的结构,同时通知到外面 Form 发生了变化。

子表单数量或类型发生变化时#

当 Form 下面子组件被添加或删除时,需要及时更新 Form Data 的结构。通过调用 updateFormDataStructure 把新增的或者修改的子表单更新到最新,并通知到外面 Form 发生了变化。

子表单内部状态发生变化时#

在第一步收集子表单的时候就已经把 handleFieldChange 注入到了子表单组件里面,所以子表单来决定调用的时机。当 handleFieldChange 被调用的时候,首先对 Form state 进行更新,然后外通知子表单发生了变化,同时通知外面 Form 发生了变化。

这样看起来整个流程就走通了,但实际上存在很多问题。

首先由于 setState 是一个异步的过程,只有在 render 后才能获取到最新的 state. 这就导致,在一个生命周期循环内如果我多次调用了 setState ,那么两次调用之间对 state 的读取很可能是不准确的。(有关生命周期的详细内容可以看这篇文章:https://www.w3ctech.com/topic/1596)

所以我创建了一个临时变量 currentState 来存放当前状态下最新的 state,每次 setState 的时候都对其进行更新。

另一个问题是当 Form 发生变化的时候,updateFormDataStructure 调用的过于频繁。其实只有在子表单的数量或者类型发生变化时才需要更新 Form state 的结构。而直接去对比子表单的类型是否发生变化也是意见开销很大操作,所以选择另一种折中方式。通过给 Form 当前的状态打标,将 Form 可能处于的状态都标识出来:

const STATUS = {
  Init: "Init",
  Normal: "Normal",
  FieldChange: "FieldChange",
  UpdateFormDataStructure: "UpdateFormDataStructure",
  Submit: "Submit",
}

这样,只有在 Form 的 STATUS 处于 Normal 的时候才对其进行 updateFormDataStructure 操作。这样就可以省去很多次渲染以及无效的对外触发的 FormChange 事件。

提交和对外暴露 Form 状态的方法和之前基本一致,这样整个对 Form 的重构就算完成了,具体项目中使用体验还不错 O(∩_∩)O

Form 组件地址: https://github.com/NE-LOAN-FED/NE-Component/tree/master/src/Form

最后,如果看文章的你有什么更好的想法,请告诉我 😛。

一个 React Form 组件的重构思路
https://blog.kisnows.com/2017/03/18/react-form-rework-thinking/
作者
Kisnows
发布于
2017-03-18
许可协议
CC BY-NC-ND 4.0