likes
comments
collection
share

🥳 手写一个 Antd4 Form 吧(下篇):细节完善

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

写在前面

大家好,我是早晚会起风。终于,这个系列到了最后一篇文章。首先,恭喜你 🎉 看到了下篇,在有上篇和中篇的基础后,这篇文章可以说是信手拈来,只需要在之前的基础上加入一捏捏的改动。

当然,如果你还没有阅读前两篇文章,墙裂建议阅读。

在上一篇文章中我们手动实现了 Form 的主要逻辑,包括状态管理、组件更新、表单校验和依赖更新四个部分。但是还有一些额外的 API 我们没有实现,其实就是上篇文章末尾提出的那三个问题,我再贴一下,

  1. 表单提交不了
  2. onFinish、onFinishFailed 等钩子也还没实现
  3. Field 之间有链式依赖的情况我们也还没处理

这篇文章我们就来解决这些问题,完善 Form 组件。

表单提交

一个表单的最终使命就是被提交,显然我们还没做到这一点(这要放到豆瓣上都得被评 1 分差评,天理难容 👹)。有了上篇文章实现的基础后,表单的提交就变得十分简单了。一个表单提交的流程如下图所示,

🥳 手写一个 Antd4 Form 吧(下篇):细节完善

注册表单提交的回调

首先,我们需要注册表单的回调函数 onFinish/onFinishFailed,用于拿到回调值进行后续处理。最简单的例子就比如说,触发表单提交,成功后通过 onFinish 回调获取到填写的表单值与后端进行通信。

我们再拿出 🐔 和 🥚 的故事。这次不是先有 🐔 还是现有 🥚 的哲学问题了,我们务实一点。这次我们讨论的主题是鸡下蛋和农场主的故事。鸡下蛋总得有个环境吧(不然狗都不干),所以第一步我们先来造个鸡窝,这里就是让 FormStore 支持传入回调。代码如下,

// file: ./src/rc-field-form/useForm.tsx

...
export interface Callbacks {
  onFinish?: (values: Values) => void;
  onFinishFailed?: (errorInfo: ValidateErrorEntity) => void;
}

class FormStrore {
    ...

    private callbacks: Callbacks = {};

    private setCallbacks = (callbacks: Callbacks) => {
        this.callbacks = callbacks;
      };
	
    ...
}

鸡窝有了,就得让鸡下蛋了,不下还不行(妥妥的资本家行径~)。我们在 Form 组件中进行表单回调的注册。代码也很简单,

// file: ./src/rc-filed-form/Form.tsx

import useForm, { Callbacks } from "./useForm";

export type FormProps = {
  form?: FormInstance;
  children?: React.ReactNode;
} & Callbacks;

export default function Form({
  form,
  children,
	// + 新增参数 onFinish
  onFinish,
	// + 新增采纳书 onFinishFailed
  onFinishFailed,
  ...restProps
}: FormProps) {
	...
	
	// 3. 注册提交表单后的回调
  const { setCallbacks } = formInstance;
  setCallbacks({
    onFinish,
    onFinishFailed,
  });
	
	...
}

完善表单提交流程

好,鸡已经下蛋了,但是还没完。我们还得检验这鸡下的蛋合不合格,包不包熟。(你要不要吧~)

🥳 手写一个 Antd4 Form 吧(下篇):细节完善

所以我们得邀请质检员,就是之前我们实现的 validateFields (如果没有印象可以翻看上一篇文章)。不过上次我们还没让 validateFields 方法中返回校验结果,这次我们来补充一下,

// file: ./src/rc-field-form/useForm.tsx

private validateFields = (name?: NamePath, options?: ValidateOptions) => {
	...
	
	// 6. 返回校验结果,供 submit 或者用户自行调用 this.validateFields() 时进行后续操作
  const returnPromise = summaryPromise
    .then(() => {
      return Promise.resolve(this.getFieldsValue());
    })
    .catch((results: { name: NamePath; errors: string[] }[]) => {
      const errorList = results.filter(
        (result) => result && result.errors.length
      );
      return Promise.reject({
        values: this.getFieldsValue(),
        errorFields: errorList,
      });
    });

  return returnPromise;

}

最后一步,就是当农场主问这一批🥚仔到底能不能孵出小🐤的时候,质检员得告诉他(打工仔嘛,干的不就是这个) 。这就其实就是 submit 方法。

// 表单提交
  private submit = () => {
    this.validateFields()
      .then((res) => {
        const { onFinish } = this.callbacks;
        onFinish && onFinish(res);
      })
      .catch((errors) => {
        const { onFinishFailed } = this.callbacks;
        onFinishFailed && onFinishFailed(errors);
      });
  };

检验流程是否正确

好,我们来测试一下,这个流程到底合不合格。例子如下,

// file: ./src/App.tsx

export default () => {
  const [form] = useForm();

  const onFinish = (res: any) => {
    console.log("表单提交: ", res);
  };

  const onFinishFailed = (errors: any) => {
    console.log("表单提交失败: ", errors);
  };

  return (
    <Form
      form={form}
      // preserve={false}
      onFinish={onFinish}
      onFinishFailed={onFinishFailed}
    >
      <Field name="name" rules={[nameRules]}>
        <Input placeholder="Username" />
      </Field>
      <Field dependencies={["name"]}>
        {() => {
          return form.getFieldValue("name") === "1" ? (
            <Field name="password" rules={[passwordRules]}>
              <Input placeholder="Password" />
            </Field>
          ) : null;
        }}
      </Field>
      <Field dependencies={["password"]}>
        {() => {
          const password = form.getFieldValue("password");
          console.log(">>>", password);
          return password ? (
            <Field name="password2">
              <Input placeholder="Password 2" />
            </Field>
          ) : null;
        }}
      </Field>
      <button type="submit">submit</button>
    </Form>
  );
};

果不其然,出问题了。我们这里只填写展示了 name 表单字段,表单提交却失败了,提示我们输入密码。这就很离谱了,哪里来的密码。

🥳 手写一个 Antd4 Form 吧(下篇):细节完善

农场主得到质检员的回复非常生气,指着质检员的鼻子破口大骂:“放屁,你这明明只有一个鸡蛋,却告诉我有俩。一个鸡蛋想要俩的钱,是不是想钱想疯了!”。

🥳 手写一个 Antd4 Form 吧(下篇):细节完善

组件销毁之前在 Store 中卸载

质检员一脸憋屈,只能默默地去找问题。找了半天才发现,鸡下的那颗蛋上批已经被拿走了,但是质检员的小册子上还一直记着这颗蛋,被老板臭骂一顿。其实就是在 Field 销毁时,需要从 store 中也销毁掉,我们并没有这么做。赶紧补充一下,

// file: ./src/rc-field-form/useForm.tsx

private registerField = (entity: FieldEntity) => {
    this.fieldEntities.push(entity);

    // 销毁 Field 回调函数
    return () => {
      this.fieldEntities = this.fieldEntities.filter((item) => item !== entity);
      const fieldName = entity.props.name;
      if (fieldName) {
        delete this.store[fieldName];
      }
    };
  };
// file: ./src/rc-field-form/Field.tsx

class Field extends React.Component<InternalFieldProps> {
	...
	private cancelRegisterFunc: (() => void) | null = null;

	componentDidMount() {
    this.mounted = true;
    const { fieldContext } = this.props;
    const { registerField } = fieldContext;
    // 在 store 中注册 filed 和销毁回调方法
    this.cancelRegisterFunc = registerField(this);
  }

  // 在组件销毁之前,在 store 中销毁
  componentWillUnmount() {
    this.cancelRegisterFunc && this.cancelRegisterFunc();
  }

	...
}

质检员再次找到农场主,证明了自己。终于,农场主和质检员过上了……(读者请自行想象)

链式依赖处理

想象一个简单链式依赖的场景, A ← B ← C。 B 依赖于 A,当 A 变化时 B 可能发生改变。而 C 又依赖于 B,B 的改变也可能导致 C 发生改变。所以,当 A 发生改变时,B 和 C 都应该触发更新和校验。

处理过程

所以我们需要在 A 改变时,遍历找出直接或者间接依赖于它的 Field。这个方法在 Form 源码中叫做 getDependencyChildrenFields ,我们来实现一波,代码如下,

private getDependencyChildrenFields = (rootPath: NamePath) => {
    const dependenciesToFields: {
      [k: string]: FieldEntity[];
    } = {};
    const childrenFields: NamePath[] = [];

    // 生成单个依赖字段到对应 Field 的表,即 { 依赖字段:对应的 Field }
    this.getFieldEntities().forEach((field, i) => {
      const { dependencies } = field.props;
      if (dependencies?.length) {
        dependencies.forEach((dep) => {
          if (!dependenciesToFields[dep]) {
            dependenciesToFields[dep] = [field];
          } else {
            dependenciesToFields[dep].push(field);
          }
        });
      }
    });

    // 遍历找到所有需要更新的 Field
    const fillChildren = (depName: NamePath) => {
      const fields = dependenciesToFields[depName];
      fields?.length &&
        fields.forEach((field) => {
          const name = field.props.name;
          if (name) {
            // 找到了依赖当前 depName 的 Field,添加
            childrenFields.push(name);
            // 查找还有没有子项依赖当前 Field
            fillChildren(name);
          }
        });
    };

    // 开始递归查找
    fillChildren(rootPath);

    return childrenFields;
  };

源码分为两个步骤,

  1. 创建一个以 依赖名称 为 key 的 Map。用于后续递归时快速找到都有哪些 Field 依赖了该名称。 那么 A ← B ← C 生存的 Map 就长这样,
{
    A: [Field B],
    B: [Field C]
}
  1. 调用 fillChildren 方法递归查找所有子依赖。代码逻辑比较简单,我这里不过多赘述。我们这里的例子最终返回的 childrenFields 就是 [B, C] ,即当 A 发生改变时,B 和 C 都要更新。

组件销毁时的依赖处理

到这里链式调用我们好像已经处理完毕了,但是实际上还有一些问题。来看下边的代码,

<Field name="name" rules={[nameRules]}>
  <Input placeholder="Username" />
</Field>
<Field dependencies={["name"]}>
  {() => {
    return form.getFieldValue("name") === "1" ? (
      <Field name="password" rules={[passwordRules]}>
        <Input placeholder="Password" />
      </Field>
    ) : null;
  }}
</Field>
<Field dependencies={["password"]}>
  {() => {
    const password = form.getFieldValue("password");
    console.log(">>>", password);
    return password ? (
      <Field name="password2">
        <Input placeholder="Password 2" />
      </Field>
    ) : null;
  }}
</Field>

这还是我们这个系列从头用到尾的例子。我们的 passwordpassword2 Field 都被包裹在一个父级的 Field 下边,父级绑定了 dependencies 依赖,子 Field 没有直接绑定。这会带来什么问题呢?我们来演示一下,

🥳 手写一个 Antd4 Form 吧(下篇):细节完善

首先,当我们为 name password password2 依次输入值的时候,三个输入框正常展示,说明三者之间的依赖关系是正常的。当我们修改 name Field 的值为 12 时, password Field 消失了,这也符合预期,因为这个 Field 是当 name 的值为 1 的时候才展示。然而 password2 Field 并没有消失。按理来说,password2 是依赖于 password 的,只有当 password 有值的时候才展示。

在给出答案之前,你可以试着想一想是什么原因导致的?

我们仔细看这一段代码,

<Field dependencies={["name"]}>
  {() => {
    return form.getFieldValue("name") === "1" ? (
      <Field name="password" rules={[passwordRules]}>
        <Input placeholder="Password" />
      </Field>
    ) : null;
  }}
</Field>

刚才我们也提到,最外层的 Field 并没有标识 name 属性,真正的 name="password" 在子 Field 中。这就导致,当 name Field 改变时我们实际上监听不到 password2 Field 也需要改变,最后就只有 password Field 自己更新。

🥳 手写一个 Antd4 Form 吧(下篇):细节完善

面对这种情况,我们就要在 Field 组件被卸载之前检查依赖于它的其他 Field 通知更新。我们来改造一下,

// file: ./src/rc-field-form/useForm.tsx

// 1. 抽象依赖更新函数
private triggerDependenciesUpdate = (prevStore: Store, name: NamePath[]) => {
  const childrenFields = this.getDependencyChildrenFields(name[0]);

  // 依赖项更新
  this.notifyObservers(prevStore, name.concat(childrenFields), {
    type: "dependenciesUpdate",
  });

  return childrenFields;
};

// 2. 依赖更新方法替换
private updateValue = (name: NamePath, value: StoreValue) => {
  const prevStore = this.store;
  ...
  // 3. 触发依赖项更新
  // -
      // this.notifyObservers(prevStore, [name], {
      //   type: "dependenciesUpdate",
      // });
      // + 
     this.triggerDependenciesUpdate(prevStore, [name]);
};

// 3. 销毁组件前触发依赖项校验
private registerField = (entity: FieldEntity) => {
  this.fieldEntities.push(entity);

  // 销毁 Field 回调函数
  return () => {
    this.fieldEntities = this.fieldEntities.filter((item) => item !== entity);
    const fieldName = entity.props.name;
    if (fieldName) {
      delete this.store[fieldName];
      // + 
      this.triggerDependenciesUpdate(this.store, [fieldName]);
    }
  };
};

这样,我们就能够监测到组件卸载,并通知相关的 Field 进行更新。我们再来测试一下,

🥳 手写一个 Antd4 Form 吧(下篇):细节完善

芜湖~ 按照预期工作啦

写在最后

这是我第一次写源码系列文章,在每次下笔之前总能发现自己还有没有了解清楚的地方,但也是这个过程,让我不断地去追问自己搞清楚(不然怎么好意思输出文章)。同时在写系列文章的时候,也发现了自己内容产出的形式有些生硬,对读者来说不太友好,这也算是一个进步。下一次写文章可能会尝试用更加生动、简洁的方式来叙述(如果还有下篇 嘿嘿~)。

最重要的是感谢读者们能够阅读到这里,不妨点个 👍 ?

🥳 手写一个 Antd4 Form 吧(下篇):细节完善

最后,我们的思维导图长这样,拿走拿走不客气。

🥳 手写一个 Antd4 Form 吧(下篇):细节完善

文章的源码我也都放到 Github 仓库了,有需要自取~