SunShinewyf / issue-blog

技术积累和沉淀
236 stars 11 forks source link

各大 Form 大揭秘 -- antd Form #51

Open SunShinewyf opened 4 years ago

SunShinewyf commented 4 years ago

各大 Form 大揭秘 -- antd Form

最近的项目涉及到很复杂的 form 交互,包括数据回填、联动等等,使用的是 antd 的 Form 组件,但是在使用过程中遇到了一系列奇怪的问题,所以趁机深入一下 antd Form 的实现,避免日后采坑。同时学习一下其他 Form 的处理方式,比较一下优劣。

Form

antd 的 Form 使用方式如下:

const Example = () => {
  return <div>this is a test</div>
}
const Demo = Form.create()(Example);

 Form.create() 是一个高阶函数,它的源码如下:

 static create = function create<TOwnProps extends FormComponentProps>(
    options: FormCreateOption<TOwnProps> = {},
  ): FormWrappedProps<TOwnProps> {
    return createDOMForm({
      fieldNameProp: 'id',
      ...options,
      fieldMetaProp: FIELD_META_PROP,
      fieldDataProp: FIELD_DATA_PROP,
    });
  };

create 函数直接调用的 rc-form 的 createDOMForm 方法,所以 antd 的 Form 的核心能力基本上都是在 rc-form 中实现的。
而 rc-form 中的 createDOMForm 中的 createDOMForm 直接调用了 createBaseForm,然后传递了一些 mixin 函数进去而已,所以我们直接看 createBaseForm 的源码,在 createBaseForm 中,直接是执行一个函数,也就是高阶函数实现的地方:

function createBaseForm(option = {}, mixins = []){
    ...
  return function decorate(WrappedComponent){
    // 实现了一个 Form 组件
    const Form = createReactClass({
      mixins,
      ...
      render(){
            ...
        // 将 form 对象作为 props 传递给外面的组件
        return <WrappedComponent {...props} />;
      }
    })
  }
}

通过上面的源码可以看出,createBaseForm 渲染了 Form.create()(Example) 中传递的外部组件,并且把含有 一系列 API 的 Form 作为 props 传递给了外部组件。

Form 本身的逻辑并不复杂,只是通过传递 layout、labelCol、wrapperCol、onSubmit 等数据来控制整个 Form 的样式渲染以及表单的提交事件等。

FormItem

FormItem 的逻辑也主要是在渲染上,它的使用主要如下所示:

 <Form.Item label="Radio.Button">
   {getFieldDecorator('button')(
     <Radio.Group>
       <Radio.Button value="a">item 1</Radio.Button>
       <Radio.Button value="b">item 2</Radio.Button>
       <Radio.Button value="c">item 3</Radio.Button>
     </Radio.Group>,
   )}
 </Form.Item>

接收一个 children,并渲染。同时执行 renderLabel 和 renderWrapper,也就是渲染 ”label“ 和 ”内容“,这里引入了 antd 自己的栅格布局,每一个 FormItem 都是一个 Row,label 和 wrapper 是 Col。主要代码如下:

 renderChildren(prefixCls: string) {
    const { children } = this.props;
    return [
      this.renderLabel(prefixCls),
      this.renderWrapper(
        prefixCls,
        this.renderValidateWrapper(
          prefixCls,
          children,
          this.renderHelp(prefixCls),
          this.renderExtra(prefixCls),
        ),
      ),
    ];
  }

在 renderWrapper 的时候,同时会去拿每一个 Item 的校验状态,然后根据不同的状态更改 wrapper 的 className,从而控制样式如下样式:
                                            image.png
为啥 FormItem 可以拿到状态呢?接下来就是 Form 核心功能的真正揭秘了

实例化 FieldsStore

前面提到执行 createBaseForm 的时候会返回一个经过 HOC 包装后的组件。这个组件在初始化的时候会执行一系列逻辑,从开始的 getInitialState 看起:

  getInitialState() {
    const fields = mapPropsToFields && mapPropsToFields(this.props);
    this.fieldsStore = createFieldsStore(fields || {});

    this.instances = {};
    this.cachedBind = {};
    this.clearedFieldMetaCache = {};

    this.renderFields = {};
    this.domFields = {};
    ....
    return {
      submitting: false,
    };
  },

从代码可以看出在组件初始化的时候会实例化一个 FieldsStore 的类,这个类主要用来存储表单项的数据和校验状态、文案等。其中,FieldsStore 的 fields 属性主要存储每个表单项的实时状态,结构如下:

this.fields = {
  // 某个表单项
  note: {
    dirty: true, //脏值标识,当字段的值已作变更、但未作校验时,那么脏值标识就为 true;已作校验则置为 false
    errors: undefined, //错误信息
    name: "note", // 字段名
    touched: true, //字段更新标识
    validating: true, //校验状态
    value: "text" // 表单项的值
  }
}

FieldsStore 中的 fieldsMeta 属性用来存储表单项的元数据信息,结构如下:

this.fieldsMeta = {
  // 某个表单项
  note: {
    name: "note", //字段名
    rules: [], // 校验规则
    initialValue: '', // 表单项初始值
    trigger: 'onChange', // 触发的事件名
    validate: [], // 校验规则
    valuePropName: "value" // 值的名称
  }
}

getFieldDecorator

当调用 form.getFieldDecorator 的时候,如下使用:

 <Form.Item label="Note">
        {getFieldDecorator('note', {
          rules: [{ required: true, message: 'Please input your note!' }],
        })(<Input />)}
 </Form.Item>

getFieldDecorator 是一个柯里化函数,用于装饰字段组件,首先传递一个表单项的配置,然后是传递一个组件。在执行 getFieldDecorator 的时候,会去执行 getFieldProps,这个函数主要用于装饰字段组件的 props,在这个函数中,会去设置 FieldsStore 的 fields 和 fieldsMeta 。需要注意的是,在获取 trigger(外部设置的收集子节点的事件,默认是 onChange) 事件的时候,会给事件绑定一个 onCollectValidate 或者 onCollect 回调。对应的代码请移步这里。这就实现了在触发组件的 onChange 的时候,就会去触发绑定的回调。

onCollectValidate 和 onCollect 都调用了 onCollectCommon,onCollectCommon 的代码如下:

onCollectCommon(name, action, args) {
  const fieldMeta = this.fieldsStore.getFieldMeta(name);
  if (fieldMeta[action]) {
    fieldMeta[action](...args);
  } else if (fieldMeta.originalProps && fieldMeta.originalProps[action]) {
    fieldMeta.originalProps[action](...args);
  }
  // 获取新值
  const value = fieldMeta.getValueFromEvent
  ? fieldMeta.getValueFromEvent(...args)
  : getValueFromEvent(...args);
  ...
  const field = this.fieldsStore.getField(name);
  return { name, field: { ...field, value, touched: true }, fieldMeta };
},

在这个函数中,会去重新获取组件的值,并更新 fields 和 fieldsMeta。更新完之后,onCollectValidate 会将该 field 的 dirty 属性置为 true,并调用 validateFieldsInternal 对表单项做校验,在 validateFieldsInternal 中,实例化了一个 async-validator,并拿到 error 信息,更新表单项的校验状态到 fields 中。getFieldDecorator 拿到最新 fields 数据之后,将它作为 props 传递给 FormItem 中包裹的组件,代码如下:

 getFieldDecorator(name, fieldOption) {
            // 拿到最新的 fields 数据
        const props = this.getFieldProps(name, fieldOption);
        return fieldElem => {
          // We should put field in record if it is rendered
          this.renderFields[name] = true;

          const fieldMeta = this.fieldsStore.getFieldMeta(name);
          const originalProps = fieldElem.props;
          ...
          fieldMeta.originalProps = originalProps;
          fieldMeta.ref = fieldElem.ref;
          // 将数据传给 FormItem 中包裹的组件
          return React.cloneElement(fieldElem, {
            ...props,
            ...this.fieldsStore.getFieldValuePropValue(fieldMeta),
          });
        };
      },

如代码所示,getFieldDecorator 会去拿每次 change 后最新的 fields 数据(包括校验信息),然后将这些数据整合传递给被 FormItem 包裹的组件,这就是上文 FormItem 可以拿到表单项的校验状态的原因了。

setFieldsValue

createBaseForm 中还有一个一个 Api setFieldsValue,它的作用就是用户可以手动设置表单项的值,它里面调用 createBaseForm 的 setFields 方法,setFields 的代码如下:

  setFields(maybeNestedFields, callback) {
    const fields = this.fieldsStore.flattenRegisteredFields(
      maybeNestedFields,
    );
    this.fieldsStore.setFields(fields);
    if (onFieldsChange) {
      const changedFields = Object.keys(fields).reduce(
        (acc, name) => set(acc, name, this.fieldsStore.getField(name)),
        {},
      );
      onFieldsChange(
        {
          [formPropName]: this.getForm(),
          ...this.props,
        },
        changedFields,
        this.fieldsStore.getNestedAllFields(),
      );
    }
    this.forceUpdate(callback);
 },

将新设置的数据更新到 fields 中,然后执行 forceUpdate,强制更新,渲染最新的值。

小结

将上面的流程用时序图表示如下:

image

Form 踩到的一些坑

给未 render 的表单项设置值报错

🌰场景还原

有三个选项,其中,选择选项 b 的时候,显示一个 input 表单项,并手动设置其值,代码如下:

const Example = props => {
  const [radioValue, setRadioValue] = useState('a');
  const { form } = props;
  const { getFieldDecorator } = form;

  const onRadioValueChange = e => {
    const value = e.target.value;
    setRadioValue(value);
    // 当选择选项 B 的时候手动设置 input 的值
    value === 'b' && form.setFieldsValue({ 'radio-value': '我是选项b' });
  };
  return (
    <Form>
      <Form.Item label="选项">
        {getFieldDecorator('radio-group', {
          initialValue: 'a',
        })(
          <Radio.Group onChange={onRadioValueChange}>
            <Radio value="a">选项A</Radio>
            <Radio value="b">选项B</Radio>
            <Radio value="c">选项C</Radio>
          </Radio.Group>,
        )}
      </Form.Item>
      {radioValue === 'b' ? (
        <Form.Item label="选项值">
          {getFieldDecorator('radio-value')(<Input />)}
        </Form.Item>
      ) : null}
    </Form>
  );
};

按照上述代码执行,会得到 Form 的 warning 提示:
Warning: You cannot set a form field before rendering a field associated with the value.
并且 Input 表单项正确设置值。

🔎原因诊断

在 setFieldsValue 的时候,会去调用 fieldsStore 的 flattenRegisteredFields 方法:

flattenRegisteredFields(fields) {
    const validFieldsName = this.getAllFieldsName();
    return flattenFields(
      fields,
      path => validFieldsName.indexOf(path) >= 0,
      'You cannot set a form field before rendering a field associated with the value.',
    );
  }

这个方法会拿当前已有的 fieldsName,当切换到选项 B 的时候,Input 表单项还没渲染,导致 fieldsName 并没有记录这个值,所以就会走到这里的校验提示逻辑。更不会成功执行 setFields 操作了。
 

💡如何解决

等 Input 组件渲染完成了再执行 setFieldsValue 操作,如下:

const onRadioValueChange = e => {
    const value = e.target.value;
    setRadioValue(value);
    value === 'b' &&
      Promise.resolve().then(() => {
        form.setFieldsValue({ 'radio-value': '我是选项b' });
      });
  };

对 Form 的感受

整体来说,antd 的 Form 可以让开发者更加便捷,因为里面封装了一些逻辑,使得我们减少重复的劳动。但是也有一些缺点,如下: