前言
这段时间做了很多项目,大量涉及到了表单提交的逻辑,但是一直没有用到 Form 组件去封装逻辑,因此维护的时候非常痛苦;而且不同的表单还要求不一样的错误提示,所以做的非常憋屈,闲下来了好好研究了一下 Form 组件的实现原理
我们在各大组件库中都可以看到 Form
组件,它是一个非常好用的组件,集成了数据获取、数据校验、数据赋值等多种功能,它可以为我们节省非常多的重复代码,比如校验输入框是否为空,格式是否满足规则,以及提交表单后自动重置字段等。
一个很常见的例子,如果你在写一个表单,他可能是这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
|
const Login = () => {
const [info, setInfo] = useState({
username: "",
password: "",
vcode: "",
});
const handleSubmit = async () => {
if (!info.username) {
// do something
}
if (!info.password) {
// do something
}
if (!info.vcode) {
// do something
}
if (info.password.length < 8) {
// do something
}
if (!info.password.match(/some regExp/)) {
// do something
}
const res = await login(info);
if (res.code !== 2000) {
// do something
}
setTimeout(() => {
Message.success("登陆成功");
setInfo({
username: "",
password: "",
vcode: "",
});
}, 2000);
};
return (
<form>
username:
<input
value={info.username}
onChange={e =>
setInfo({
...info,
username: e.target.value,
})
}
/>
password:
<input
value={info.password}
onChange={e =>
setInfo({
...info,
password: e.target.value,
})
}
/>
vcode:
<input
value={info.vcode}
onChange={e =>
setInfo({
...info,
vcode: e.target.value,
})
}
/>
<button onClick={handleSubmit}>Submit</button>
</form>
);
};
|
一个表单可能还行,如果是几十个表单呢?每一个字段都需要写一遍双向绑定不说,提交校验也存在着大量的重复代码,而且十分容易遗漏情况。这时,Form
组件就可以帮助我们节少很多心智负担。
组件 API 及需求分析
Form
组件的核心实际上在于将 非受控组件
转化为 受控组件
,从下面的代码片段就可以看出它的核心思想了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
function App() {
const onFinish = values => {
console.log("Submit", values);
};
const [form] = Form.useForm();
const reset = () => {
form.resetFields();
};
return (
<>
<Form
form={form}
onFinish={onFinish}
initialValues={{
username: "user1233",
isAdmin: false,
}}
>
<Form.Item name="username" label="username">
<input />
</Form.Item>
<Form.Item name="gender" label="gender">
<select>
<option value="male">male</option>
<option value="female">female</option>
<option value="secret">secret</option>
</select>
</Form.Item>
<Form.Item label="isAdmin" name="isAdmin" valuePropName="checked">
<input type="checkbox" />
</Form.Item>
<Form.Item>
<button htmltype="submit" style={{ marginRight: "10px" }}>
Submit
</button>
<button type="button" onClick={reset}>
Reset
</button>
</Form.Item>
</Form>
</>
);
}
|
在上面的示例中,我们可以看出,Form
组件不需要我们指定 State
,所有的状态都在其内部维护。而通过 Form.Item
包裹的组件会自动进行双向数据绑定。
这里的 Input
组件本应该传入 value
和 onChange
字段来控制其状态,但实际上 Form.Item
接手了这一过程,只是控制的这个过程不需要我们关心,组件会用 name
字段区分不同的表单项,这样我们就不用为每一个字段都重复相同的操作了。
目前常见的组件库基本上都是这样的形式,在 npm 上我们可以可以找到单独的库,比如 rc-field-form 就是一个非常经典的无样式表单组件,Antd
也是使用的这个库。

rc-field-form
的源码并不复杂,十分推荐大家阅读,本文也会参考其源码进行实现(复刻)。
实现思路
首先我们需要实现的是表单的双向数据绑定,即通过 Form.Item
包裹表单元素后,可以同步元素 Value 的变化到 Form
组件内部维护的 store
中
1. 状态管理
先来看一张图:

整个组件大致就只有五个文件:Form.jsx
、FieldContext.js
、useForm.js
、Field.jsx
和最终汇总的 index.js
。这里的思路是,从根组件 Form
组件出发,维护一个 FormStore
,并使用一个 Context
向所有的 Field
子组件提供数据,为了便于展示逻辑,这里先使用 js 实现。
在Antd Form V3
中,数据状态放在Form
组件上,使用setState
对其进行更新。但这有一个缺陷:每当数据状态中的一个值发生变化时,由于setState
的调用,会导致整个Form
组件及其子组件都会进行更新,这对于一些庞大的表单而言会产生性能上的负担。
由此,Antd Form V4
对该缺陷进行了改善。把数据状态放到一个formStore
中(管理公共状态的对象,类似于Redux
中的store
)。而该formStore
都通过React Context
注入到Form
的子组件上。每当需要更新数据状态时,则调用formStore
中指定的方法(此称updateValue
)进行更新。
参考文章:一次手写 Antd Form 的经历,让我受益匪浅#实现数据管理
首先先把 Form.jsx
搭出一个雏形
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
// Form.jsx
const Form = props => {
const {
form, // 在外部使用 `useForm` 创建的实例
initialValues, // 表单初始值
onFinish,
onReset,
onValueChange,
children,
} = props;
const [formInstance] = useForm(form);
// 将回调函数注册到 `FormStore` 实例上
const { setCallbacks, setInitialValues } = formInstance.getInternalHooks();
setCallbacks({ onValueChange, onFinish });
// 使用 ref 确保只执行一次
const mounted = useRef(false);
// 初次渲染时注册表单初始值
if (!mounted.current) {
initialValues && setInitialValues(initialValues);
mounted.current = true;
}
// 使用 useMemo 防止重复创建
const fieldContextValue = useMemo(
() => ({ ...formInstance }),
[formInstance]
);
return (
<form
onSubmit={e => {
e.preventDefault();
e.stopPropagation();
formInstance.submit();
}}
onReset={e => {
e.preventDefault();
formInstance.resetFields();
onReset && onReset(e);
}}
>
<FieldContext.Provider value={fieldContextValue}>
{children}
</FieldContext.Provider>
</form>
);
};
export default Form;
|
而 FieldContext
的逻辑就非常常规了,就是一个 createContext
而已:
1
2
3
4
|
import { createContext } from "react";
const FieldContext = createContext({});
export default FieldContext;
|
这里我们的核心就是将 formInstance
这个值注入到根组件下的所有组件中,随后我们便可以在任何一个 Form.Item
组件中通过 useContext
获取到它的值了。这里我们使用一个 useForm
函数来返回这个 表单实例
,它的具体实现我们暂时不管,接下来我们先看 FormStore
的实现方式。
FormStore
上需要维护我们的 表单数据
store
,同时需要暴露出一些表单方法,比如 submit
、resetFields
等,并且不同的 Form
表单需要新建不同的实例,因此我们使用一个类来实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
// useForm.js
export class FormStore {
constructor() {
this.store = {};
this.callbacks = Object.create(null);
this.initialValues = {};
this.fieldEntities = [];
}
// 通过 `useForm` 方法暴露出去的字段
getForm = () => ({
getFieldValue: this.getFieldValue,
getFieldsValue: this.getFieldsValue,
setFieldsValue: this.setFieldsValue,
submit: this.submit,
resetFields: this.resetFields,
getInternalHooks: this.getInternalHooks,
});
// 封装一些内部 Hooks,挂到 `getForm` 下
getInternalHooks = () => ({
updateValue: this.updateValue,
setInitialValues: this.setInitialValues,
setCallbacks: this.setCallbacks,
initEntityValue: this.initEntityValue,
registerField: this.registerField,
});
// 注册回调
setCallbacks = callbacks => {};
// 注册表单初始值
setInitialValues = initialValues => {};
// 注册实例后,设置表单初始值
initEntityValue = entity => {};
// 注册实例
registerField = entity => {};
getFieldEntities = () => {};
// 通知更新
notifyObservers = (prevStore, nameList, info) => {};
updateValue = (name, newValue) => {};
// form actions,提交、校验、重置等方法
submit = () => {};
// 重置所有字段
resetFields = nameList => {};
}
|
这个类大致长这样,用 store
维护 表单数据
,并提供了 getFieldValue
和 setFieldValue
等方法用于操作 store
。上文中我们在 Form
组件中使用 useForm
方法新建了 表单实例
,因此我们需要封装这个方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// useForm.js
const useForm = form => {
// 使用 ref 防止重复创建
const formRef = useRef();
if (!formRef.current) {
if (form) {
// 传入初始值的时候直接使用这个创建好的示例
formRef.current = form;
} else {
// 否则新建一个示例并挂到 formRef 下
const formStore = new FormStore();
formRef.current = formStore.getForm();
}
}
return [formRef.current];
};
export default useForm;
|
这里我们需要使用 ref
来储存这个 表单实例
,这样可以防止组件每次 re-render
都重新创建一个实例。这个方法接收一个 form
作为参数,这样我们可以使用 useForm
方法创建一个实例后将其传给 Form
组件,随后就可以使用 form.getFieldsValue
或者 form.resetFields
等方法了,而不需要使用 ref
等获取组件实例。
到这里,我们回顾下调用 Form
时发生了什么:
-
Form
组件创建了一个新的 FormStore
实例,通过 Context
将实例注入所有子组件中(特指 Form.Item
)
-
Form
组件拿到传入的回调函数,并将其挂到 formInstance.callbacks
上,这样在别的方法上可以通过
1
2
|
const { onValueChange } = this.callbacks;
const { onFinish } = this.callbacks;
|
拿到回调函数并执行了
-
Form
组件拿到传入的初始值,并通过 setInitialValues
方法注册初始值,并将当前值(表单数据 store
)修改为初始值
但是这时只是在 FormStore
内部完成了状态的管理,Form
组件的真正核心在于双向数据绑定,即在子组件中触发 onChange
方法时自动更新 store
中的数据,并在手动修改 store
后自动触发子组件的 re-render
。在 rc-field-form
中使用了观察者模式实现,事实上这个组件的实现原理与 Vue.js
的实现原理非十分相似,不过是 Vue
追踪了数据的所有依赖,而这个组件的依赖来源只有 onChange
、setFieldValue
等内部封装好的方法而已,事实上 rc-field-form
也使用了追踪依赖自动更新的逻辑。
接下来我们实现最核心的双向数据绑定
2. 双向数据绑定
这里涉及到的是 Field.jsx
这个组件,先看看代码,其大致结构如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
|
class Field extends React.Component {
mounted = false;
// 仅用于触发 re-render
state = {
resetCount: 0,
};
constructor(props) {
super(props);
const { getInternalHooks } = props.fieldContext;
const { initEntityValue } = getInternalHooks();
// 组件创建时需要挂载实例初始值
// 比如传入 name="username" 时
// 需要修改 store["username"] 为初始值或空值
initEntityValue(this);
}
componentDidMount() {
const { getInternalHooks } = this.props.fieldContext;
const { registerField } = getInternalHooks();
// 组件挂载后将自身注册到 FormStore 中
// 这样在 FormStore 的 entities 字段中就可以获取到组件实例了
// 也就可以调用 entity.onStoreChange 方法触发更新
registerField(this);
this.mounted = true;
}
// refresh 和 reRender 是两种触发重渲染的方式
// 下文会具体阐述
refresh = () => {
this.setState(({ resetCount }) => {
return { resetCount: resetCount + 1 };
});
};
reRender = () => {
if (!this.mounted) return;
this.forceUpdate();
};
// 并不是自己调用,而是通过注册在 FormStore 上的示例调用
// 具体形式是通过 notifyObservers
// forEach 执行 entity.onStoreChange()
onStoreChange = (prevStore, namePathList, info) => {};
// 将子组件转为受控组件
getControlled = childProps => {};
render() {
const { children } = this.props;
const { resetCount } = this.state;
// 如果 children 不是合法 React 元素,不封装受控
if (!isValidElement(children))
return <React.Fragment key={resetCount}>{children}</React.Fragment>;
return (
<React.Fragment key={resetCount}>
{cloneElement(children, this.getControlled(children.props))}
</React.Fragment>
);
}
}
// 最终 export 的是这个组件,在这一层先取出 Context
const WrapperField = props => {
const fieldContext = React.useContext(FieldContext);
return (
// 简单实现一下表单布局
<div style={{ display: "flex", marginBottom: 12 }}>
{props.label && <div style={{ width: 100 }}>{props.label}</div>}
<Field {...props} fieldContext={fieldContext} />
</div>
);
};
export default WrapperField;
|
双向数据绑定的核心在于,将 Feild
组件实例注册到 store
中,这样可以在外部通过 forEach
一个一个通知调用组件内部用于更新状态的 re-render
方法,因此这里使用 Class 组件。函数组件是没有实例的,要实现这种效果需要依赖于闭包。
关于为什么要在 Feild
外部先取出 FieldContext
是因为类组件中的 Context
需要使用 ContextType
获取,当需要传入多个 Context
时就无能为力了,比如 Antd
中就还有 SizeContext
等,所以这里先取出来再通过 props 传递。
要实现双向数据绑定,首先我们要将输入组件转为受控组件,这里会用到 React.cloneElement
这个 API,调用方式如下:
1
|
const newElement = cloneElement(oldElement, newProps);
|
它接受两个参数,第一个参数是 ReactElement
,第二个参数是 React 组件的 props
。返回的是将 newProps
传入 oldElement
后生成的新组件。那么我们就可以为非受控组件混入 value
和 onChange
方法来将其转为受控组件了。
这里会使用到一个 getControlled
方法,其大致做了这些事:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
// Field.jsx
// 将子组件转为受控组件
getControlled = childProps => {
// 从 props 中取出参数
const {
// 这里的 fieldContext 是通过 props 传入的
// 原因下文会详细阐述
fieldContext,
// 用于区分表单字段的唯一键名
name,
// 值对应的键名
// 有些组件的键名不是 `value`
// 比如单选框组件就是 `check`
valuePropName = "value",
// 要监听的方法名,原因同上
trigger = "onChange",
} = this.props;
const { getFieldValue, getInternalHooks } = fieldContext;
const { updateValue } = getInternalHooks();
// 从 store 中取出该字段对应的值
const value = name ? getFieldValue(name) : undefined;
// 直接定义在子组件上的 trigger 函数
const originTriggerFunc = childProps[trigger];
return {
...childProps,
// 将 store 中的值同步到子组件中
[valuePropName]: value,
[trigger]: e => {
const newValue = e.target[valuePropName];
// 当值改变时调用方法同步修改 store 中的值
updateValue(name, newValue);
originTriggerFunc && originTriggerFunc(e);
},
};
};
|
这个方法会将非受控组件(如不传入参数的 Input
组件等)添加 [valuePropName]
和 [trigger]
后重新创建出一个受控组件。但是到这里并没有实现双向数据绑定,我们还有些工作要做。
观察者模式
和传统的单向观察者模式不同,Field
组件和 FormStore
组件之间互为观察者和被观察者,任何一方的变化都会通知另一方。
在 Field
组件下有 onStoreChange
方法;而在 FormStore
下则有 notifyObservers
方法,在其内部会调用每一个观察者的 onStoreChange
方法。在 store 发生变化时会取出所有已注册的实例,通知他们更新数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// FormStore.notifyObservers
// 通知更新
notifyObservers = (prevStore, nameList, info) => {
// 将 store 放到 info 中
// 下面再把 prevStore 一同传递给回调进行比较
info.store = this.getFieldsValue();
// 向每一个订阅的实例发布更新通知
const entities = this.getFieldEntities();
entities.forEach(entity => {
entity.onStoreChange(prevStore, nameList, info);
});
};
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// Field.onStoreChange
onStoreChange = (prevStore, namePathList, info) => {
// 从 info 中取出 store
const { store } = info;
// 取出新旧值
const prevValue = prevStore[this.props.name];
const curValue = store[this.props.name];
// 判断通知涉及的字段是否是当前字段
const nameMatch = namePathList && namePathList.includes(this.props.name);
switch (info.type) {
// RESET 事件,不需要判断字段是否匹配,直接触发更新
case "RESET":
this.refresh();
break;
// 默认情况下,只有字段匹配,或是数据发生变化时才触发 re-render
default:
if (nameMatch && prevValue !== curValue) {
this.reRender();
}
}
};
|
在 FormStore
下有 updateValue
方法,在 Field
中则通过 [trigger]
触发更新,通知 FormStore
更新 store,随后再次触发 Field
内部的 onStoreChange
方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// FormStore.updateValue
updateValue = (name, newValue) => {
// 实例没有 name 字段时不做无效操作
if (!name) return;
const prevStore = this.getFieldsValue();
// 更新 store 值
this.store = { ...this.store, [name]: newValue };
// 通知所有实例用最新的 store 值 re-render
// 由于指定了 namePathList,只有对应 name 的组件会重新渲染
this.notifyObservers(prevStore, [name], {
type: "INTERNAL",
});
const { onValueChange } = this.callbacks;
// 如果用户监听了 onValueChange 事件则在此触发
onValueChange &&
onValueChange({ [name]: this.store[name] }, this.getFieldsValue());
};
|
如此一来,我们就实现了双向数据绑定,不过还有一个问题:
通过 setState
和 forceUpdate
方式触发 re-render 有什么不同?为什么要分开
如果我们都使用 setState
更新会发生什么?
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// Field.onStoreChange
...
switch (info.type) {
// RESET 事件,不需要判断字段是否匹配
case "RESET":
this.refresh();
break;
// 默认情况下,只有字段匹配,或是数据发生变化时才触发 re-render
default:
if (nameMatch && prevValue !== curValue) {
this.refresh();
}
}
|

这个原因是因为我们为返回的 <React.Fragment key={resetCount}>{children}</React.Fragment>
分配了 key
值。在 React 中,每一次输入都会触发 onChange
事件都会重新调用 render
方法,所以每一个 item 都需要一个唯一的确定的 key
值,这个 key 的作用就是避免 diff 算法重新生成一个全新的 dom,所以每当绑定的 key 变化时 diff 算法就会生成一个全新的 dom。那么就会出现上面的失焦的情况。
而 forceUpdate
则会在不更新 state 的情况下触发 re-render
从而实现不失焦的刷新。
关于 resetFields
方法
其实只是一个小细节,重置字段时不能仅仅简单地写成:
1
|
this.store = { ...this.initialValues };
|
而需要考虑没有指定初始值的字段,另写一个方法 resetWithFieldInitialValue
,实际上执行的和 initEntityValue
差不多的功能。
展示
目前我们已经实现了 Form 组件的基本功能,还剩一个校验功能没有实现,我会在后续的文章中继续更新。
项目代码在这:rc-field-form-impl
你可以在这里查看在线演示:CodeSandBox