用React从零实现一个Antd4 Form表单 | 项目复盘

4,068 阅读9分钟

前言

​ 在cms后台管理系统中,大家一定绕不开对Form表单的使用,接下来我们就来详细解析下Antd4 Form的背后实现以及数据仓库的知识。其实Form表单就做了以下几件事情:

  • 数据收集
  • 数跨传递
  • 数据响应式
  • 表单校验
  • 表单提交

数据收集

​ 在一个Form表单里,有很多input、radio等数据项,而这些input、radio要做成受控组件就需要把他们各自的value存在状态(state)中,React组件的状态可以存在class组件的this.state中或者是利用React.useState。但是我们需要考虑的一点就是,如果这些input、radio组件等都各自管理自己的state,那么Form表单提交的时候,怎么做统一的收据收集呢,毕竟校验和提交Form表单的时候需要获取Form表单中全部的数据。

​ 其实这个时候我们已经想到了,把这些input、radio的状态存在一起就好了,比如存在Form的state中,然后子组件修改value的时候,执行Form的setState事件就好了。这是一种实现方式,也是antd3 Form的实现原理。当然这种实现方式有一定缺点,因为只要Form中有一个数据项发生了改变,那都要执行Form的setState,这就意味着整个Form表单都要更新。那如果Form表单特别大,对性能肯定是有一定损伤的。(对antd3 Form的实现原理感兴趣的,可以留言,我后期有时间再总结一篇文章。)

​ 还有一种统一管理Form中状态值的方式,就是自己再定义一个单独的数据管理仓库,然后规定这个数据仓库的get、set方法就好了,有点类似redux。初始化代码如下:

class FormStore {
  constructor() {
    this.store = {}; // 状态库
  }

  // get
  getFieldsValue = () => {
    return {...this.store};
  };
  getFieldValue = (name) => {
    return this.store[name];
  };
  // set
  setFieldsValue = (newStore) => {
    // name: value
    // 1. 修改状态库
    this.store = {
      ...this.store,
      ...newStore,
    };
    console.log("store", store); //sy-log
  };

  submit = () => {
    // 校验成功 执行onFinish
    // 校验失败 执行onFinishFailed
  };

  getForm = () => {
    return {
      getFieldsValue: this.getFieldsValue,
      getFieldValue: this.getFieldValue,
      setFieldsValue: this.setFieldsValue,
      submit: this.submit,
    };
  };
}

​ 数据仓库创建好之后,需要存储实例,需要注意的是组件会发生更新,我们要确保的是组件初次渲染和更新阶段用的都是同一个数据仓库实例,这个时候我们可以使用useRef,因为useRef 返回一个可变的 ref 对象,其 .current属性被初始化为传入的参数(initialValue),返回的 ref 对象在组件的整个生命周期内保持不变。

export default function useForm() {
  const formRef = useRef();

  if (!formRef.current) {
    const formStore = new FormStore();
    formRef.current = formStore.getForm();
  }

  return [formRef.current];
}

数据传递

​ 数据仓库已经创建好,接下来我们要解决的是各个组件对数据仓库的访问。考虑到Form组件、input、radio组件、button组件等都要访问数据仓库,并且他们有个共同特点就是,都是Form的子组件但并不确定是Form的第几代子孙组件,那这个时候使用props数据传递显然是不合适的。这个时候可以使用React中跨层级数据传递Context。

​ 使用跨层级传递数据的方式可以分成三步:

  1. 创建Context对象:

    import React from "react";
    
    const FieldContext = React.createContext();
    
    export default FieldContext;
    
  2. 使用Provider传递value:

    import FieldContext from "./Context";
    import useForm from "./useForm";
    
    export default function Form({children, onFinish, onFinishFailed}) {
      const [formInstance] = useForm();
      
      return (
        <form
          onSubmit={(e) => {
            e.preventDefault();
            formInstance.submit();
          }}>
          <FieldContext.Provider value={formInstance}>
            {children}
          </FieldContext.Provider>
        </form>
      );
    }
    
  3. 子组件消费value,Form中都用Field包裹子组件如input、value等:

    import React, {Component} from "react";
    import FieldContext from "./Context";
    
    export default class Field extends Component {
      static contextType = FieldContext;
    
      getControlled = () => {
        const {getFieldValue, setFieldsValue} = this.context;
        const {name} = this.props;
        return {
          value: getFieldValue(name), //"omg", // get
          onChange: (e) => {
            const newValue = e.target.value;
            // set
            setFieldsValue({
              [name]: newValue,
            });
          },
        };
      };
    
      render() {
        console.log("render"); //sy-log
        const {children} = this.props;
        const returnChildNode = React.cloneElement(children, this.getControlled());
        return returnChildNode;
      }
    }
    

数据响应式

​ 根据上面的代码,用如下例子测试:

import React, {Component, useEffect} from "react";
// import Form, {Field} from "rc-field-form";
import Form, {Field} from "../components/my-rc-field-form/";
import Input from "../components/Input";

const nameRules = {required: true, message: "请输入姓名!"};
const passworRules = {required: true, message: "请输入密码!"};

export default function MyRCFieldForm(props) {
  const [form] = Form.useForm();

  const onFinish = (val) => {
    console.log("onFinish", val); //sy-log
  };

  // 表单校验失败执行
  const onFinishFailed = (val) => {
    console.log("onFinishFailed", val); //sy-log
  };

  useEffect(() => {
    console.log("form", form); //sy-log
    form.setFieldsValue({username: "default"});
  }, []);

  return (
    <div>
      <h3>MyRCFieldForm</h3>
      <Form form={form} onFinish={onFinish} onFinishFailed={onFinishFailed}>
        <Field name="username" rules={[nameRules]}>
          <Input placeholder="input UR Username" />
        </Field>
        <Field name="password" rules={[passworRules]}>
          <Input placeholder="input UR Password" />
        </Field>
        <button>Submit</button>
      </Form>
    </div>
  );
}

​ 通过log我们会发现store中的数据已经发生改变了,但是组件并没有随之更新。React中更新组件有四种方式:ReactDOM.render、forceUpdate、setState或者因为父组件而更新。很明显,这里如果想要Form中某个子组件更新的话,我们应该使用forceUpdate。

​ 那么现在我们其实要做的就是加上注册组件更新,监听this.store,一旦this.store中的某个值改变,就更新对应的组件。那接下来首先在FormStore中加上记录Form中子组件的方法:

class FormStore {
  constructor() {
    this.store = {}; // 状态库
    // 组件实例
    this.fieldEntities = [];
  }
  //...省略上面已经粘贴的代码
  
	// 有注册,得有取消注册,
  // 订阅和取消订阅也是要成对出现的
  registerFieldEntities = (entity) => {
    this.fieldEntities.push(entity);

    return () => {
      this.fieldEntities = this.fieldEntities.filter(
        (_entity) => _entity != entity
      );
      delete this.store[entity.props.name];
    };
  };
  // set 更新store与组件
  setFieldsValue = (newStore) => {
    // name: value
    // 1. 修改状态库
    this.store = {
      ...this.store,
      ...newStore,
    };

    // 2. 更新组件
    this.fieldEntities.forEach((entity) => {
      Object.keys(newStore).forEach((k) => {
        if (k === entity.props.name) {
          entity.onStoreChange();
        }
      });
    });
  };
}

接下来我们在Field组件中执行注册与取消注册就可以啦:

export default class Field extends Component {
  static contextType = FieldContext;

  componentDidMount() {
    // 注册
    this.unregister = this.context.registerFieldEntities(this);
  }

  componentWillUnmount() {
    if (this.unregister) {
      this.unregister();
    }
  }

  onStoreChange = () => {
    this.forceUpdate();
  };
  //... 上面已经粘贴的代码省略
}

​ 接下来再用上面的测试例子,是不是发现组件已经可以更新啦。perfect~

表单校验

​ 到现在为止,我们还没有提交表单,提交前我们首先要做表单校验。表单校验通过,则执行onFinish,失败则执行onFinishFailed。

​ 而表单校验的依据就是Field的rules,接下来我们可以做个简单的校验,只要值不是null、undefined或者空字符串,就当做校验通过,否则的话就往err数组中push错误信息。

  //FormStore
  validate = () => {
    let err = [];
    // todo 校验
    const store = this.getFieldsValue();
    const fieldEntities = this.fieldEntities;
    fieldEntities.forEach((entity) => {
      let {name, rules} = entity.props;
      let value = this.getFieldValue(name);
      if (rules[0] && (value == null || value.replace(/\s*/, "") === "")) {
        err.push({name, err: rules[0].message});
      }
    });
    return err;
  };

表单提交

​ 完成表单校验之后,接下来我们要在FormStore中实现表单提交方法,即onFinish与onFinishFailed方法。由于这两个方法是Form组件的props参数,那么我们可以在FormStore中定义this.callbacks,然后再定义setCallbacks方法记录这两个方法:

// FormStore 
  constructor() {
    this.store = {}; // 状态库
    // 组件实例
    this.fieldEntities = [];

    // 记录回调
    this.callbacks = {};
  }

  setCallbacks = (newCallbacks) => {
    this.callbacks = {
      ...this.callbacks,
      ...newCallbacks,
    };
  };

接下来我们在Form中执行setCallbacks即可:

  formInstance.setCallbacks({
    onFinish,
    onFinishFailed,
  });

好了,到现在为止,我们已经基本上实现了一个Antd4 Form表单~

当然,如果你想到里结束也可以,如果还想再完美一点,请继续往下:

再完美一点~

实现给表单预设值

​ 如果你很认真地在敲这些代码,你可能会发现,测试例子里的预设值并没有执行:

  useEffect(() => {
    console.log("form", form); //sy-log
    form.setFieldsValue({username: "default"});
  }, []);

​ 这是为什么呢?

​ 还记不记得我刚刚强调过,一定要保证我们用的数据仓库在组件的任何生命时期都得是同一个,即只初始化一次,后续在这个基础上更新。在刚刚的代码中,我们有两个地方用到了useForm,一个是测试例子里,一个是Form组件里,怎么保证这两个组件用的是同一个数据仓库呢?很简单,测试例子里给Form传个form参数就好啦,然后Form组件里再调用useForm的时候记录这个form。修改后的Form如下:

export default function Form({form, children, onFinish, onFinishFailed}) {
  const [formInstance] = useForm(form);
}

修改后的useForm如下:

export default function useForm(form) {
  const formRef = useRef();

  if (!formRef.current) {
    if (form) {
      formRef.current = form;
    } else {
      const formStore = new FormStore();
      formRef.current = formStore.getForm();
    }
  }

  return [formRef.current];
}

再测试,是不是发现default的预设值已经给name的input加上咯~

让Form支持ref

​ 有没有发现,我们刚刚的测试例子里创建数据仓库用的是useForm这样的自定义hook,而且自定义hook只能用在函数组件中,因此我们的例子也是函数组件。那么问题来了,类组件怎么办呢?总不能不让类组件使用Form表单吧。

​ 这个问题其实很好解决,我们用useForm得到的就是个被记录的对象而已,这个对象地址在组件的任何生命周期都不变。实现这一的效果,在函数组件中可以使用useRef,类组件中也可以使用React.createRef。修改后的测试例子如下:

export default class MyRCFieldForm extends Component {
  formRef = React.createRef();
  componentDidMount() {
    console.log("form", this.formRef.current); //sy-log
    this.formRef.current.setFieldsValue({username: "default"});
  }

  onFinish = (val) => {
    console.log("onFinish", val); //sy-log
  };

  // 表单校验失败执行
  onFinishFailed = (val) => {
    console.log("onFinishFailed", val); //sy-log
  };
  render() {
    return (
      <div>
        <h3>MyRCFieldForm</h3>
        <Form
          ref={this.formRef}
          onFinish={this.onFinish}
          onFinishFailed={this.onFinishFailed}>
          <Field name="username" rules={[nameRules]}>
            <Input placeholder="Username" />
          </Field>
          <Field name="password" rules={[passworRules]}>
            <Input placeholder="Password" />
          </Field>
          <button>Submit</button>
        </Form>
      </div>
    );
  }
}

然而,一跑代码,发现还有个问题,函数组件不能接受ref属性:

image-20210318174600261

考虑到React.forwardRef能创建一个React组件,这个组件能够将其接受的ref属性转发到其组件树下的另一个组件中。因此修改我们自己写的这个组件库的index.js,如下:

import React from "react";
import _Form from "./Form";
import Field from "./Field";
import useForm from "./useForm";

const Form = React.forwardRef(_Form);
Form.useForm = useForm;

export {Field};
export default Form;

这个时候Form就能够接受转发来的ref属性了,完整的Form代码如下:

export default function Form({form, children, onFinish, onFinishFailed}, ref) {
  const [formInstance] = useForm(form);

  useImperativeHandle(ref, () => formInstance);
  formInstance.setCallbacks({
    onFinish,
    onFinishFailed,
  });
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        formInstance.submit();
      }}>
      <FieldContext.Provider value={formInstance}>
        {children}
      </FieldContext.Provider>
    </form>
  );
}

​ 可能你已经发现,除了ref,我还加了一行代码 useImperativeHandle(ref, () => formInstance);,如果不加这行代码,测试例子里的componentDidMount里的this.formRef.current就是null。这是因为我们刚刚通过forwardRef转发ref给了Form组件,而如果Form的父组件想要获取formInstance的话,那就要使用useImperativeHandle这个hook函数了。

总结

​ 上面介绍的Form表单是基于rc-field-form来写的,而Antd4 Form也是基于rc-field-form写的。

​ 虽然现在github上有很多近似完美的Form表单组件,我们再去实现一版有点像重复造轮子,但是我觉得如果你有不断学习的想法,其实重复造轮子的过程也是个学习的过程,学习别人的编程思维与思路,最不济也能学习到很多高级API用法,比如这里的Context、hooks、ref等。我个人的话,前不久写可视化编辑器的时候,就参考这个Form表单,然后没有用redux和mobx,而是自己实现了一个数据状态管理库,算是学以致用了。

最后,想看完整代码的,点这里

完结~ 撒花~

本文正在参与「掘金 2021 春招闯关活动」, 点击查看 活动详情