React 实现一个简单实用的 Form 组件

3,923 阅读7分钟

为什么要造轮子

在 React 中使用表单有个明显的痛点,就是需要维护大量的valueonChange,比如一个简单的登录框:

class App extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      username: "",
      password: ""
    };
  }

  onUsernameChange = e => {
    this.setState({ username: e.target.value });
  };

  onPasswordChange = e => {
    this.setState({ password: e.target.value });
  };

  onSubmit = () => {
    const data = this.state;
    // ...
  };

  render() {
    const { username, password } = this.state;

    return (
      <form onSubmit={this.onSubmit}>
        <input value={username} onChange={this.onUsernameChange} />
        <input
          type="password"
          value={password}
          onChange={this.onPasswordChange}
        />
        <button>Submit</button>
      </form>
    );
  }
}

这已经是比较简单的登录页,一些涉及到详情编辑的页面,十多二十个组件也是常有的。一旦组件多起来就会有许多弊端:

  • 不易于维护:占据大量篇幅,阻碍视野。
  • 可能影响性能:setState的使用,会导致重新渲染,如果子组件没有相关优化,相当影响性能。
  • 表单校验:难以统一进行表单校验。
  • ...

总结起来,作为一个开发者,迫切希望能有一个表单组件能够同时拥有这样的特性:

  • 简单易用
  • 父组件可通过代码操作表单数据
  • 避免不必要的组件重绘
  • 支持自定义组件
  • 支持表单校验

表单组件社区上已经有不少方案,例如react-final-formformikant-plusnoform等,许多组件库也提供了不同方式的支持,如ant-design

但这些方案都或多或少一些重量,又或者使用方法仍然不够简便,自然造轮子才是最能复合要求的选择。

怎么造轮子

这个表单组件实现起来主要分为三部分:

  • Form:用于传递表单上下文。
  • Field: 表单域组件,用于自动传入valueonChange到表单组件。
  • FormStore: 存储表单数据,封装相关操作。

为了能减少使用ref,同时又能操作表单数据(取值、修改值、手动校验等),我将用于存储数据的FormStore,从Form组件中分离出来,通过new FormStore()创建并手动传入Form组件。

使用方式大概会长这样子:

class App extends React.Component {
  constructor(props) {
    super(props);

    this.store = new FormStore();
  }

  onSubmit = () => {
    const data = this.store.get();
    // ...
  };

  render() {
    return (
      <Form store={this.store} onSubmit={this.onSubmit}>
        <Field name="username">
          <input />
        </Field>
        <Field name="password">
          <input type="password" />
        </Field>
        <button>Submit</button>
      </Form>
    );
  }
}

FormStore

用于存放表单数据、接受表单初始值,以及封装对表单数据的操作。

class FormStore {
  constructor(defaultValues = {}, rules = {}) {
    // 表单值
    this.values = defaultValues;

    // 表单初始值,用于重置表单
    this.defaultValues = deepCopy(defaultValues);

    // 表单校验规则
    this.rules = rules;

    // 事件回调
    this.listeners = [];
  }
}

为了让表单数据变动时,能够响应到对应的表单域组件,这里使用了订阅方式,在FormStore中维护一个事件回调列表listeners,每个Field创建时,通过调用FormStore.subscribe(listener)订阅表单数据变动。

class FormStore {
  // constructor ...

  subscribe(listener) {
    this.listeners.push(listener);

    // 返回一个用于取消订阅的函数
    return () => {
      const index = this.listeners.indexOf(listener);
      if (index > -1) this.listeners.splice(index, 1);
    };
  }

  // 通知表单变动,调用所有listener
  notify(name) {
    this.listeners.forEach(listener => listener(name));
  }
}

再添加getset函数,用于获取和设置表单数据。其中,在set函数中调用notify(name),以保证所有的表单变动都会触发通知。

class FormStore {
  // constructor ...

  // subscribe ...

  // notify ...

  // 获取表单值
  get(name) {
    // 如果传入name,返回对应的表单值,否则返回整个表单的值
    return name === undefined ? this.values : this.values[name];
  }

  // 设置表单值
  set(name, value) {
    //如果指定了name
    if (typeof name === "string") {
      // 设置name对应的值
      this.values[name] = value;
      // 执行表单校验,见下
      this.validate(name);
      // 通知表单变动
      this.notify(name);
    }

    // 批量设置表单值
    else if (name) {
      const values = name;
      Object.keys(values).forEach(key => this.set(key, values[key]));
    }
  }

  // 重置表单值
  reset() {
    // 清空错误信息
    this.errors = {};
    // 重置默认值
    this.values = deepCopy(this.defaultValues);
    // 执行通知
    this.notify("*");
  }
}

对于表单校验部分,不想考虑得太复杂,只做一些规定

  1. FormStore构造函数中传入的rules是一个对象,该对象的键对应于表单域的name,值是一个校验函数
  2. 校验函数参数接受表单域的值和整个表单值,返回booleanstring类型的结果。
  • true代表校验通过。
  • falsestring代表校验失败,并且string结果代表错误信息。

然后巧妙地通过||符号判断是否校验通过,例如:

new FormStore({/* 初始值 */, {
  username: (val) => !!val.trim() || '用户名不能为空',
  password: (val) => !!(val.length > 6 && val.length < 18) || '密码长度必须大于6个字符,小于18个字符',
  passwordAgain: (val, vals) => val === vals.password || '两次输入密码不一致'
}})

FormStore实现一个validate函数:

class FormStore {
  // constructor ...

  // subscribe ...

  // notify ...

  // get

  // set

  // reset

  // 用于设置和获取错误信息
  error(name, value) {
    const args = arguments;
    // 如果没有传入参数,则返回错误信息中的第一条
    // const errors = store.error()
    if (args.length === 0) return this.errors;

    // 如果传入的name是number类型,返回第i条错误信息
    // const error = store.error(0)
    if (typeof name === "number") {
      name = Object.keys(this.errors)[name];
    }

    // 如果传了value,则根据value值设置或删除name对应的错误信息
    if (args.length === 2) {
      if (value === undefined) {
        delete this.error[name];
      } else {
        this.errors[name] = value;
      }
    }

    // 返回错误信息
    return this.errors[name];
  }

  // 用于表单校验
  validate(name) {
    if (name === undefined) {
      // 遍历校验整个表单
      Object.keys(this.rules).forEach(n => this.validate(n));
      // 并通知整个表单的变动
      this.notify("*");
      // 返回一个包含第一条错误信息和表单值的数组
      return [this.error(0), this.get()];
    }

    // 根据name获取校验函数
    const validator = this.rules[name];
    // 根据name获取表单值
    const value = this.get(name);
    // 执行校验函数得到结果
    const result = validator ? validator(name, this.values) : true;
    // 获取并设置结果中的错误信息
    const message = this.error(
      name,
      result === true ? undefined : result || ""
    );

    // 返回Error对象或undefind,和表单值
    const error = message === undefined ? undefined : new Error(message);
    return [error, value];
  }
}

至此,这个表单组件的核心部分FormStore已经完成了,接下来就是这么在FormField组件中使用它。

Form

Form组件相当简单,也只是为了提供一个入口和传递上下文。

props接收一个FormStore的实例,并通过Context传递给子组件(即Field)中。

const FormStoreContext = React.createContext();

function Form(props) {
  const { store, children, onSubmit } = props;

  return (
    <FormStoreContext.Provider value={store}>
      <form onSubmit={onSubmit}>{children}</form>
    </FormStoreContext.Provider>
  );
}

Field

Field组件也并不复杂,核心目标是实现valueonChange自动传入到表单组件中。

// 从onChange事件中获取表单值,这里主要应对checkbox的特殊情况
function getValueFromEvent(e) {
  return e && e.target
    ? e.target.type === "checkbox"
      ? e.target.checked
      : e.target.value
    : e;
}

function Field(props) {
  const { label, name, children } = props;

  // 拿到Form传下来的FormStore实例
  const store = React.useContext(FormStoreContext);

  // 组件内部状态,用于触发组件的重新渲染
  const [value, setValue] = React.useState(
    name && store ? store.get(name) : undefined
  );
  const [error, setError] = React.useState(undefined);

  // 表单组件onChange事件,用于从事件中取得表单值
  const onChange = React.useCallback(
    (...args) => name && store && store.set(name, valueGetter(...args)),
    [name, store]
  );

  // 订阅表单数据变动
  React.useEffect(() => {
    if (!name || !store) return;

    return store.subscribe(n => {
      // 当前name的数据发生了变动,获取数据并重新渲染
      if (n === name || n === "*") {
        setValue(store.get(name));
        setError(store.error(name));
      }
    });
  }, [name, store]);

  let child = children;

  // 如果children是一个合法的组件,传入value和onChange
  if (name && store && React.isValidElement(child)) {
    const childProps = { value, onChange };
    child = React.cloneElement(child, childProps);
  }

  // 表单结构,具体的样式就不贴出来了
  return (
    <div className="form">
      <label className="form__label">{label}</label>
      <div className="form__content">
        <div className="form__control">{child}</div>
        <div className="form__message">{error}</div>
      </div>
    </div>
  );
}

于是,这个表单组件就完成了,愉快地使用它吧:

class App extends React.Component {
  constructor(props) {
    super(props);

    this.store = new FormStore();
  }

  onSubmit = () => {
    const data = this.store.get();
    // ...
  };

  render() {
    return (
      <Form store={this.store} onSubmit={this.onSubmit}>
        <Field name="username">
          <input />
        </Field>
        <Field name="password">
          <input type="password" />
        </Field>
        <button>Submit</button>
      </Form>
    );
  }
}

结语

这里只是把最核心的代码整理了出来,功能上当然比不上那些成百上千 star 的组件,但是用法上足够简单,并且已经能应对项目中的大多数情况。

我已在此基础上完善了一些细节,并发布了一个 npm 包——@react-hero/form,你可以通过npm安装,或者在github上找到源码。如果你有任何已经或建议,欢迎在评论或 issue 中讨论。