🥳 手写一个 Antd4 Form 吧(下篇):细节完善
写在前面
大家好,我是早晚会起风。终于,这个系列到了最后一篇文章。首先,恭喜你 🎉 看到了下篇,在有上篇和中篇的基础后,这篇文章可以说是信手拈来,只需要在之前的基础上加入一捏捏的改动。
当然,如果你还没有阅读前两篇文章,墙裂建议阅读。
在上一篇文章中我们手动实现了 Form 的主要逻辑,包括状态管理、组件更新、表单校验和依赖更新四个部分。但是还有一些额外的 API 我们没有实现,其实就是上篇文章末尾提出的那三个问题,我再贴一下,
- 表单提交不了
- onFinish、onFinishFailed 等钩子也还没实现
- Field 之间有链式依赖的情况我们也还没处理
这篇文章我们就来解决这些问题,完善 Form 组件。
表单提交
一个表单的最终使命就是被提交,显然我们还没做到这一点(这要放到豆瓣上都得被评 1 分差评,天理难容 👹)。有了上篇文章实现的基础后,表单的提交就变得十分简单了。一个表单提交的流程如下图所示,
注册表单提交的回调
首先,我们需要注册表单的回调函数 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,
});
...
}
完善表单提交流程
好,鸡已经下蛋了,但是还没完。我们还得检验这鸡下的蛋合不合格,包不包熟。(你要不要吧~)
所以我们得邀请质检员,就是之前我们实现的 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
表单字段,表单提交却失败了,提示我们输入密码。这就很离谱了,哪里来的密码。
农场主得到质检员的回复非常生气,指着质检员的鼻子破口大骂:“放屁,你这明明只有一个鸡蛋,却告诉我有俩。一个鸡蛋想要俩的钱,是不是想钱想疯了!”。
组件销毁之前在 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;
};
源码分为两个步骤,
- 创建一个以
依赖名称
为 key 的 Map。用于后续递归时快速找到都有哪些 Field 依赖了该名称。 那么 A ← B ← C 生存的 Map 就长这样,
{
A: [Field B],
B: [Field C]
}
- 调用
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>
这还是我们这个系列从头用到尾的例子。我们的 password
和 password2
Field 都被包裹在一个父级的 Field 下边,父级绑定了 dependencies
依赖,子 Field 没有直接绑定。这会带来什么问题呢?我们来演示一下,
首先,当我们为 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 自己更新。
面对这种情况,我们就要在 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 进行更新。我们再来测试一下,
芜湖~ 按照预期工作啦
写在最后
这是我第一次写源码系列文章,在每次下笔之前总能发现自己还有没有了解清楚的地方,但也是这个过程,让我不断地去追问自己搞清楚(不然怎么好意思输出文章)。同时在写系列文章的时候,也发现了自己内容产出的形式有些生硬,对读者来说不太友好,这也算是一个进步。下一次写文章可能会尝试用更加生动、简洁的方式来叙述(如果还有下篇 嘿嘿~)。
最重要的是感谢读者们能够阅读到这里,不妨点个 👍 ?
最后,我们的思维导图长这样,拿走拿走不客气。
文章的源码我也都放到 Github 仓库了,有需要自取~
转载自:https://juejin.cn/post/7124466999102537759