Featured image of post 手写一个 Form 组件(一)

手写一个 Form 组件(一)

如何实现数据绑定

前言

这段时间做了很多项目,大量涉及到了表单提交的逻辑,但是一直没有用到 Form 组件去封装逻辑,因此维护的时候非常痛苦;而且不同的表单还要求不一样的错误提示,所以做的非常憋屈,闲下来了好好研究了一下 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 组件本应该传入 valueonChange 字段来控制其状态,但实际上 Form.Item 接手了这一过程,只是控制的这个过程不需要我们关心,组件会用 name 字段区分不同的表单项,这样我们就不用为每一个字段都重复相同的操作了。

目前常见的组件库基本上都是这样的形式,在 npm 上我们可以可以找到单独的库,比如 rc-field-form 就是一个非常经典的无样式表单组件,Antd 也是使用的这个库。

rc-field-form 的源码并不复杂,十分推荐大家阅读,本文也会参考其源码进行实现(复刻)。

实现思路

首先我们需要实现的是表单的双向数据绑定,即通过 Form.Item 包裹表单元素后,可以同步元素 Value 的变化到 Form 组件内部维护的 store

1. 状态管理

先来看一张图:

组件层级

整个组件大致就只有五个文件:Form.jsxFieldContext.jsuseForm.jsField.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 ,同时需要暴露出一些表单方法,比如 submitresetFields 等,并且不同的 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 维护 表单数据,并提供了 getFieldValuesetFieldValue 等方法用于操作 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 时发生了什么:

  1. Form 组件创建了一个新的 FormStore 实例,通过 Context 将实例注入所有子组件中(特指 Form.Item

  2. Form 组件拿到传入的回调函数,并将其挂到 formInstance.callbacks 上,这样在别的方法上可以通过

    1
    2
    
    const { onValueChange } = this.callbacks;
    const { onFinish } = this.callbacks;
    

    拿到回调函数并执行了

  3. Form 组件拿到传入的初始值,并通过 setInitialValues 方法注册初始值,并将当前值(表单数据 store )修改为初始值

但是这时只是在 FormStore 内部完成了状态的管理,Form 组件的真正核心在于双向数据绑定,即在子组件中触发 onChange 方法时自动更新 store 中的数据,并在手动修改 store 后自动触发子组件的 re-render。在 rc-field-form 中使用了观察者模式实现,事实上这个组件的实现原理与 Vue.js 的实现原理非十分相似,不过是 Vue 追踪了数据的所有依赖,而这个组件的依赖来源只有 onChangesetFieldValue 等内部封装好的方法而已,事实上 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 后生成的新组件。那么我们就可以为非受控组件混入 valueonChange 方法来将其转为受控组件了。

这里会使用到一个 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());
};

如此一来,我们就实现了双向数据绑定,不过还有一个问题:

通过 setStateforceUpdate 方式触发 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

Built with Hugo
主题 StackJimmy 设计