React 實現一個簡單實用的 Form 元件

varharrie發表於2019-02-24

為什麼要造輪子

在 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 中討論。

相關文章