preface

In the CMS background management system, we must get around the use of the Form Form, next we will analyze the Antd4 Form behind the realization and data warehouse knowledge. The Form does the following:

  • The data collection
  • Number of cross transfer
  • Data response
  • Form validation
  • The form submission

The data collection

In a Form, there are many input, radio and other data items. To make these input and radio items into controlled components, their values need to be stored in the state. The React component state can be stored in this. State of the class component or used with React. UseState. However, we need to consider that if the input and radio components manage their own states, then how to do unified receipt collection when submitting the Form? After all, all the data in the Form need to be retrieved when verifying and submitting the Form.

In fact, we have already thought of storing the state of the input and radio together, such as the state of the Form, and then executing the setState event of the Form when the child component changes the value. This is one way to do it, and it’s how antD3 Forms are implemented. There are drawbacks to this approach, of course, because whenever a single item in the Form changes, the Form’s setState is executed, which means that the entire Form is updated. If the Form Form is too large, performance will suffer. (If you are interested in the implementation principle of ANTD3 Form, please leave a comment. I have time to summarize another article later.)

Another way to manage state values in a Form is to define a separate data management repository and specify the get and set methods for that repository, similar to Redux. The initialization code is as follows:

class FormStore {
  constructor() {
    this.store = {}; / / state library
  }

  // get
  getFieldsValue = () = > {
    return{... this.store}; }; getFieldValue =(name) = > {
    return this.store[name];
  };
  // set
  setFieldsValue = (newStore) = > {
    // name: value
    // 1. Modify the status database
    this.store = { ... this.store, ... newStore, };console.log("store", store); //sy-log
  };

  submit = () = > {
    // onFinish is executed successfully
    // The verification fails. Execute onFinishFailed
  };

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

After the data warehouse is created, we need to store the instance. Note that the component will be updated. We want to make sure that the first rendering and update of the component will use the same data warehouse instance. Its.current property is initialized as the passed parameter (initialValue), and the returned REF object remains unchanged throughout the life of the component.

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

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

  return [formRef.current];
}
Copy the code

The data transfer

Now that the data warehouse has been created, we need to address the various components’ access to the data warehouse. Considering that the Form component, input component, Radio component, button component, and so on all need to access the data warehouse, and they all have one thing in common: they are all children of the Form but do not know how many descendants of the Form, using props data passing is obviously not appropriate. You can use React to pass the Context across hierarchies.

There are three steps to passing data across hierarchies:

  1. Create a Context object:

    import React from "react";
    
    const FieldContext = React.createContext();
    
    export default FieldContext;
    Copy the code
  2. Using a Provider to pass a 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>
      );
    }
    Copy the code
  3. Subcomponents consume values, and all forms wrap fields around subcomponents such as input and 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;
            // setsetFieldsValue({ [name]: newValue, }); }}; };render() {
        console.log("render"); //sy-log
        const {children} = this.props;
        const returnChildNode = React.cloneElement(children, this.getControlled());
        returnreturnChildNode; }}Copy the code

Data response

Based on the code above, use the following example to test:

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: "Please enter your name!"};
const passworRules = {required: true.message: "Please enter your password!"};

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

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

  // Form verification failed
  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>
  );
}
Copy the code

The log shows that the data in the Store has changed, but the component has not been updated with it. There are four ways to update components in React: reactdom. render, forceUpdate, setState, or update because of the parent component. Obviously, if we want to update a child component of the Form, we should use forceUpdate.

So now all we really need to do is register component updates, listen for this.store, and update the corresponding component whenever a value in this.store changes. Add a Form neutron component to FormStore:

class FormStore {
  constructor() {
    this.store = {}; / / state library
    // Component instance
    this.fieldEntities = [];
  }
  / /... Omit the pasted code above
  
	// If there is registration, there must be unregistration.
  // Subscribe and unsubscribe should also come in pairs
  registerFieldEntities = (entity) = > {
    this.fieldEntities.push(entity);

    return () = > {
      this.fieldEntities = this.fieldEntities.filter(
        (_entity) = >_entity ! = entity );delete this.store[entity.props.name];
    };
  };
  // set updates store and component
  setFieldsValue = (newStore) = > {
    // name: value
    // 1. Modify the status database
    this.store = { ... this.store, ... newStore, };// 2. Update components
    this.fieldEntities.forEach((entity) = > {
      Object.keys(newStore).forEach((k) = > {
        if(k === entity.props.name) { entity.onStoreChange(); }}); }); }; }Copy the code

Now we can register and unregister the Field component:

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

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

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

  onStoreChange = () = > {
    this.forceUpdate();
  };
  / /... The code already pasted above is omitted
}
Copy the code

Then use the above test example to see if the component is ready to update. perfect~

Form validation

Up to now, we haven’t submitted the form, we need to verify the form before submitting it. If the form is verified, onFinish is executed; if the form fails, onFinishFailed is executed.

As long as the value is not null, undefined or an empty string, it is regarded as passed. Otherwise, error information will be pushed into the err array.

  //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;
  };
Copy the code

The form submission

After verifying the form, we will implement the form submission methods onFinish and onFinishFailed in FormStore. Since these two methods are props arguments to the Form component, we can define this.callbacks in the FormStore, and then define the setCallbacks method to record both methods:

// FormStore 
  constructor() {
    this.store = {}; / / state library
    // Component instance
    this.fieldEntities = [];

    // Record the callback
    this.callbacks = {};
  }

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

We can then execute setCallbacks in the Form:

  formInstance.setCallbacks({
    onFinish,
    onFinishFailed,
  });
Copy the code

Ok, so far we have basically implemented an Antd4 Form ~

Of course, if you want to finish, you can also, if you want to perfect, please continue:

A little more perfect

Implement default values for the form

If you are typing carefully, you may notice that the default values in the test example are not executed:

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

Why is that?

Remember that I emphasized that the data warehouse we use must be the same throughout the lifetime of the component, that is, only initialized once and then updated on that basis. In the previous code, we used useForm in two places, one in the test example and one in the Form component. How can we ensure that the two components use the same data warehouse? It is easy to pass a Form parameter to the Form in the test example, and then record the Form when useForm is called in the Form component. The modified Form is as follows:

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

UseForm after modification is as follows:

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

  if(! formRef.current) {if (form) {
      formRef.current = form;
    } else {
      const formStore = newFormStore(); formRef.current = formStore.getForm(); }}return [formRef.current];
}
Copy the code

The default value of default has been added to the input of name

Make the Form support ref

Have you noticed that we used useForm as a custom hook to create the data warehouse in the test example? Custom hooks can only be used in function components, so our example is also a function component. So the question is, what about class components? You can’t have a class component that doesn’t use Form forms.

This problem can be solved easily, as all we get with useForm is a recorded object whose address remains unchanged for any lifetime of the component. To achieve this, you can use useRef in function components and React. CreateRef in class components. The modified test example is as follows:

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
  };

  // Form verification failed
  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>); }}Copy the code

However, a run through the code shows that there is still a problem. The function component cannot accept the ref attribute:

Considering that the React. ForwardRef can create a React component, this component can forward the ref property it receives to another component in its component tree. So modify the index.js component library we wrote ourselves as follows:

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;
Copy the code

The Form can now accept the forwarded ref attribute. The complete Form code looks like this:

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>
  );
}
Copy the code

As you may have noticed, useImperativeHandle(ref, () => formInstance); This.formref.current is null in the test example componentDidMount if this line is not added. This is because the parent of the Form needs to useImperativeHandle to execute a formInstance to the forwardRef.

conclusion

The Form described above is written based on rC-field-form, and Antd4 forms are also written based on RC-field-form.

Although now there are a lot of approximation on making perfect Form Form components, we’ll implement a version is a bit like repeat the wheel, but I think if you have the idea of continuous learning, in fact, repeat of rolling process is a learning process, learning programming thinking and ideas of others, the weakest can also learn a lot about advanced API usage, Context, hooks, ref, etc. Personally, WHEN I was writing a visual editor recently, I used this Form as a reference, and instead of using Redux and Mobx, I implemented a data state management library.

And finally, if you want to see the full code, click here

End ~ scatter flowers ~

This article is participating in the “Nuggets 2021 Spring Recruitment Campaign”, click to see the details of the campaign