Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React表单Form 组件的设计与实现 #28

Open
varHarrie opened this issue Feb 23, 2019 · 0 comments
Open

React表单Form 组件的设计与实现 #28

varHarrie opened this issue Feb 23, 2019 · 0 comments
Labels
Milestone

Comments

@varHarrie
Copy link
Owner

varHarrie commented Feb 23, 2019

为什么要造轮子

在 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.errors[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(undefined);

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(
    name && store ? store.error(name) : 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 中讨论。

@varHarrie varHarrie changed the title React 实现一个简单实用的 Form 组件 React 实现高度简洁的 Form 组件 Feb 27, 2019
@varHarrie varHarrie changed the title React 实现高度简洁的 Form 组件 React表单Form 组件的设计与实现 Mar 25, 2019
@varHarrie varHarrie added this to the Posts milestone Aug 5, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant