likes
comments
collection
share

Rc-form: 消失的“Ta”

作者站长头像
站长
· 阅读数 11

Rc-form: 消失的“Ta”

Rc-form: 消失的“Ta”

想获取更多原创好文,请搜索公众号关注我们吧~ 本文首发于政采云前端博客:# Rc-form: 消失的“Ta”

前情提要

那是一个艳阳高照的早上,临近中休时间,小 H 正准备动身去吃午餐,突然,钉钉弹出了一条新消息:(登登登~)“您有一个新的bug:表单点击提交按钮没反应”。自信的小 H 心想:这期的需求我不就给表单多加了几个字段嘛,怎么会影响到表单的提交功能呢?应该是提错 bug 了吧。于是,小 H 按照bug的描述复现起了场景:

字段A是一个下拉选择框,其枚举值为A1, A2。值为A1时展示字段BCD;为A2时展示字段BEF。首先,下拉选择框A选中A1并填写字段CD,将A切换到A2后填充表单数据,点击提交。咔咔咔咔咔~无论小 H 用鼠标如何点击着提交按钮,页面硬是没有任何反应,开发者工具中也没有一条由提交触发的请求。

Rc-form: 消失的“Ta”

小 H 发现表单确实无法提交,于是便在提交按钮的点击回调函数中打了断点想一探究竟,这一调试可把小 H 愁坏了:validateFields 的回调函数中存在 D 字段的必填校验错误。AA1 切换到 A2 后,之前展示的 C, D 字段应该注销了呀?为什么 D 字段在表单提交的时候还会执行自己的校验规则呢? 而且,为什么同样存在必填校验的 C 字段却不存在校验错误信息?

一时丈二和尚摸不着头脑的小 H 着急着去吃午饭,心想着既然是表单提交不了的原因出在 D 字段的校验上,那给 D 字段的校验函数中加一个判断不就行了 。于是小 H 一不做二不休,给 D 字段的校验函数加上了这么一条判断 逻辑:if (getFieldValue('A') === 'A1') &&AA1 切换到 A2 并提交时,虽然执行了 D 的校验函数,但是由于此时 A 字段的值为 A2 那么自然就不会去执行 D 剩余的校验代码了,这样就饶过了 D 字段校验的问题。改完代码后,小 H 再次按照 bug 触发的链路操作了一番。不出所料,这次表单可以正常提交了,于是小 H 在提交完代码后便心满自足的走去了餐厅。

Rc-form: 消失的“Ta”

“滴滴滴滴滴~~滴滴滴滴滴~~~”,随着午休闹铃的响起,小 H 睁开了惺忪的睡眼,刚打开电脑屏幕,迎面而来的是一条钉钉通知:“您有一个新的bug:D字段被携带到下游导致下游页面展示异常”。小 H 十分不解,便又在提交按钮的点击回调函数中打起了断点,原来,当 AA1 切换到 A2 提交后,不仅执行了 D 字段的校验函数,同时 D 字段的值也被保留了下来,并随着提交接口保存到了后端。虽然,对于小 H 的页面来说,这个多余的 D 字段并不会对页面功能和展示造成任何影响。但是,业务线下游的页面并没有针对业务场景作字段的校验和过滤,而是完全依赖于上游接口的返回值,导致下游页面展示出错。

Rc-form: 消失的“Ta”

Rc-form: 消失的“Ta”

“Ta”为什么不会消失

为了从根源上解决字段值不消失及校验函数依旧执行的问题,小 H 打算分析一波其中的奥秘。首先,从提交按钮点击回调的调试中我们发现,C 字段的值在我们从 A1 切换到 A2 后会正常消失,而且 C 的校验函数在提交时也并不会被执行。为什么 C 会消失,而 D 不会?难道是 D 这个字段的名称太特殊,rc-form 不愿意去注销她?作为新时代的好青年,小 H 自然不会相信这种玄学解决,问题肯定就出在 CD 上,我们首先要看看她们有什么不同:

import { Button, Form, Input, Select } from 'doraemon';
import { createForm } from './form/src';
import MyInput from './MyInput';

const FormItem = Form.Item;
const Option = Select.Option;
const formItemLayout = {
  labelCol: { span: 4 },
  wrapperCol: { span: 20 },
};

const Demo = props => {
  const { form } = props;
  const { getFieldDecorator } = form;
  const showA1 = form.getFieldValue('A') === 'A1';
  const showA2 = form.getFieldValue('A') === 'A2';

  return (
    <div>
      <Button
        onClick={() =>
          form.validateFields((err, value) => {
            debugger;
          })
        }
      >
        校验
      </Button>
      <FormItem label="A" {...formItemLayout}>
        {getFieldDecorator('A', {})(<Select style={{ width: 200 }}>
          <Option value={"A1"}>A1</Option>
          <Option value={"A2"}>A2</Option>
        </Select>)}
      </FormItem>
      <FormItem label="B" {...formItemLayout}>
        {getFieldDecorator('B', {})(<Input />)}
      </FormItem>
      {
        showA2
        ? (<>
            <FormItem label="E" {...formItemLayout}>
              {getFieldDecorator('E', {})(<Input />)}
            </FormItem>
            <FormItem label="F" {...formItemLayout}>
              {getFieldDecorator('F', {})(<Input />)}
            </FormItem>
          </>)
        : showA1 && (<>
            <FormItem label="C" {...formItemLayout}>
              {getFieldDecorator('C', {
                rules: [{
                  required: true,
                  message: '请输入C',
                }]
              })(<Input />)}
            </FormItem>
            <FormItem label="D" {...formItemLayout}>
              {getFieldDecorator('D', {
                rules: [{
                  required: true,
                  message: '请输入D',
                }]
              })(<MyInput />)}
            </FormItem>
        </>)
      }
    </div>
  );
};

export default createForm({})(Fengwo);

我们可以看到 CD 字段在注册的时候基本上没有什么不同,唯一的区别在于,C 注册时使用的是官网提供的组件,而 D 注册时使用的是自定义组件。小 H 心想:难道是官方提供的组件中做了一些特殊处理,让 rc-form 知道当组件卸载的时候要去注销相应的字段?可是,我记得官方本身就支持自定义组件作为表单控件的呀。不信邪的小 H 打开了官网,查到:

自定义或第三方的表单控件,也可以与 Form 组件一起使用。只要该组件遵循以下的约定:

  • 提供受控属性 value 或其它与 valuePropName 的值同名的属性。

  • 提供 onChange 事件或 trigger 的值同名的事件。

  • 支持 ref:

    • React@16.3.0 之前只有 Class 组件支持。
    • React@16.3.0 及之后可以通过 forwardRef 添加 ref 支持。(示例

来源:3x.ant.design/components/…

自定义组件使用的时候需要支持 ref,而且 Class 组件支持但是函数式组件需要通过 forwardRef 来添加 ref 支持。小 H 这才发现了问题,因为在注册字段 D 时,使用的是函数式自定义组件,而且并没有通过 forwardRef 去添加 ref,而官方提供的组件都是 Class 写法。果然,在添加 ref 支持后字段值被正常销毁且校验函数也不再被调用。但是,小 H 发现虽然不支持 ref ,自定义的组件依然可以正常的接收 valueonChange 参数,只是在某些特定的场景下,需要注销字段时,字段不能被正常的销毁。好奇的 小 H 通过源码来探究一下 rc-form 字段消失的秘密。

“Ta”如何消失

为了探究为什么没有添加 ref 的函数式自定义表单控件无法正常的注销字段而且会触发校验函数。首先我们需要了解取值时调用的 getFieldsValue 方法以及校验时使用的 validateFields 方法:

// getFieldsValue
getFieldsValue = names => {
  return this.getNestedFields(names, this.getFieldValue);
};

getNestedFields(names, getter) {
  const fields = names || this.getValidFieldsName();
  return fields.reduce((acc, f) => set(acc, f, getter(f)), {});
}

getValidFieldsName() {
  const { fieldsMeta } = this;
  return fieldsMeta
    ? Object.keys(fieldsMeta).filter(name => !this.getFieldMeta(name).hidden)
  : [];
}

// validateFields
validateFields(ns, opt, cb) {
  const { names, callback, options } = getParams(ns, opt, cb);
  const fieldNames = names
  ? this.fieldsStore.getValidFieldsFullName(names)
  : this.fieldsStore.getValidFieldsName();
  const fields = fieldNames
  .filter(name => {
    const fieldMeta = this.fieldsStore.getFieldMeta(name);
    return hasRules(fieldMeta.validate);
  })
  .map(name => {
    const field = this.fieldsStore.getField(name);
    field.value = this.fieldsStore.getFieldValue(name);
    return field;
  });
  ...
  this.validateFieldsInternal(
    fields,
    {
      fieldNames,
      options,
    },
    callback,
  );
}

我们发现无论是 getFieldsValue 还是 validateFields,都离不开一个方法 getFieldMeta,通过这个方法去获取字段名称对应的字段元数据。如果对应的元数据不存在,那么自然就不会返回对应字段名称的值或者校验对应字段名称的规则。通过这条线索,需要只要 ge tFieldMeta 的返回值从何而来:

constructor(fields) {
  this.fields = this.flattenFields(fields);
  this.fieldsMeta = {};
}

getFieldMeta(name) {
  this.fieldsMeta[name] = this.fieldsMeta[name] || {};
  return this.fieldsMeta[name];
}

因为 FieldsStore 是一个 Class,所以这些字段的元数据全都存在其对应的 store 实例上。既然我们知道了数据从何而来,并且正常情况下表单控件卸载时字段会被销毁,那么一定有一个方法来清除这些不再需要的字段。经过一番探索,小 H 发现在 FieldsStore 中确实存在这么一个方法 clearField,用于注销字段及其元数据:

clearField(name) {
  delete this.fields[name];
  delete this.fieldsMeta[name];
}

本着存在即合理的原则,小 H 寻根溯源,找到了调用 clearField 方法的函数 saveRef 及其依赖函数:

saveRef(name, _, component) {
  if (!component) {
    // after destroy, delete data
    this.clearedFieldMetaCache[name] = {
      field: this.fieldsStore.getField(name),
      meta: this.fieldsStore.getFieldMeta(name),
    };
    this.fieldsStore.clearField(name);
    delete this.instances[name];
    delete this.cachedBind[name];
    return;
  }
  this.recoverClearedField(name);
  const fieldMeta = this.fieldsStore.getFieldMeta(name);
  if (fieldMeta) {
    const ref = fieldMeta.ref;
    if (ref) {
      if (typeof ref === 'string') {
        throw new Error(`can not set ref string for ${name}`);
      }
      ref(component);
    }
  }
  this.instances[name] = component;
}

getFieldProps(name, usersFieldOption = {}) {
  // ...
  
  const { rules, trigger, validateTrigger = trigger, validate } = fieldOption;

  const inputProps = {
    ...this.fieldsStore.getFieldValuePropValue(fieldOption),
    ref: this.getCacheBind(name, `${name}__ref`, this.saveRef),
  };

  // ...
  
  return inputProps;
}

getFieldDecorator(name, fieldOption) {
  const props = this.getFieldProps(name, fieldOption);
  return fieldElem => {
    const fieldMeta = this.fieldsStore.getFieldMeta(name);
    const originalProps = fieldElem.props;
    fieldMeta.originalProps = originalProps;
    fieldMeta.ref = fieldElem.ref;
    return React.cloneElement(fieldElem, {
      ...props,
      ...this.fieldsStore.getFieldValuePropValue(fieldMeta),
    });
  };
}

小 H 长叹一口气,rc-form 字段消失的秘密终于真相大白。在注册字段时,我们通过 getFieldDecorator 方法将 props 传入自定义表单控件上,其中有就有一个属性 ref,而且入参是一个函数 saveRef。通过查阅 React 官方文档,我们知道,ref 回调会在DOM节点挂载或者卸载时调用:

Callback Refs

React also supports another way to set refs called “callback refs”, which gives more fine-grain control over when refs are set and unset.

Instead of passing a ref attribute created by createRef(), you pass a function. The function receives the React component instance or HTML DOM element as its argument, which can be stored and accessed elsewhere.

...

React will call the ref callback with the DOM element when the component mounts, and call it with null when it unmounts. Refs are guaranteed to be up-to-date before componentDidMount or componentDidUpdate fires.

legacy.reactjs.org/docs/refs-a…

那么这一切都解释的通了,当DOM卸载时,React 会调用 saveRef 方法,此时形参 component 为空,rc-form 就会调用 clearField 方法,清空字段。在字段清空后,我们通过 getFieldsValuevalidateFields 方法将不再能获取到对应字段名称的元数据,进而实现了字段销毁的目的。

saveRef(name, _, component) {
  if (!component) {
    // after destroy, delete data
    this.clearedFieldMetaCache[name] = {
      field: this.fieldsStore.getField(name),
      meta: this.fieldsStore.getFieldMeta(name),
    };
    this.fieldsStore.clearField(name);
    delete this.instances[name];
    delete this.cachedBind[name];
    return;
  }
  // ...
}

至此,原来的两个问题解决了。因为 React 函数式组件并没有实例,所以如果不通过 forwardRef 去支持 ref,那么就不会调用 saveRef 函数,rc-form 上的字段对应的元数据就得不到销毁,进而导致获取值时字段不会消失以及校验规则依旧执行的外部表现。回顾问题排查的历程,小 H 发现 rc-form 的设计是如此的巧妙,借助 Callback Refs 实现了 DOM 元素与对应字段销毁的联动。

以上所有的内容总结成一段话就是:在使用表单自定义控件时,如果使用的是函数式自定义组件,需要通过 forwardRef 支持 ref。其实除了 getFieldsValuevalidateFields 外,validateFieldsAndScroll 方法也会受到影响导致定位失败,不妨自己去探索一下定位失败的原因吧!

推荐阅读

开源作品

  • 政采云前端小报

开源地址 www.zoo.team/openweekly/ (小报官网首页有微信交流群)

  • 商品选择 sku 插件

开源地址 github.com/zcy-inc/sku…

招贤纳士

政采云技术团队(Zero),Base 杭州,一个富有激情和技术匠心精神的成长型团队。政采云前端团队(ZooTeam),一个年轻富有激情和创造力的前端团队。团队现有 80 余个前端小伙伴,平均年龄 27 岁,近 4 成是全栈工程师,妥妥的青年风暴团。成员构成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、智能化平台、性能体验、云端应用、数据分析、错误监控及可视化等方向进行技术探索和实战,推动并落地了一系列的内部技术产品,持续探索前端技术体系的新边界。

如果你想改变一直被事折腾,希望开始能折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊… 如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的前端团队的成长历程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 ZooTeam@cai-inc.com

Rc-form: 消失的“Ta”