前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Rc-form: 消失的“Ta”

Rc-form: 消失的“Ta”

作者头像
政采云前端团队
发布2023-09-01 19:55:38
2110
发布2023-09-01 19:55:38
举报
文章被收录于专栏:采云轩

Rc-form: 消失的“Ta” http://zoo.zhengcaiyun.cn/blog/article/rc-form

前情提要

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

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

bug场景.png

小 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 在提交完代码后便心满自足地走去了餐厅。

1.png

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

2.png

3.png

“Ta”为什么不会消失

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

代码语言:javascript
复制
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 支持。(示例)

    来源:https://3x.ant.design/components/form-cn/#components-form-demo-customized-form-controls

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

“Ta”如何消失

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

代码语言:javascript
复制
// 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 的返回值从何而来:

代码语言:javascript
复制
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,用于注销字段及其元数据:

代码语言:javascript
复制
clearField(name) {
  delete this.fields[name];
  delete this.fieldsMeta[name];
}

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

代码语言:javascript
复制
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. https://legacy.reactjs.org/docs/refs-and-the-dom.html#callback-refs

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

代码语言:javascript
复制
 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 方法也会受到影响导致定位失败,不妨自己去探索一下定位失败的原因吧!

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2023-08-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 政采云技术 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前情提要
  • “Ta”为什么不会消失
  • “Ta”如何消失
相关产品与服务
云开发 CLI 工具
云开发 CLI 工具(Cloudbase CLI Devtools,CCLID)是云开发官方指定的 CLI 工具,可以帮助开发者快速构建 Serverless 应用。CLI 工具提供能力包括文件储存的管理、云函数的部署、模板项目的创建、HTTP Service、静态网站托管等,您可以专注于编码,无需在平台中切换各类配置。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档