[譯] 如何用 React Hooks 打造一個不到 100 行代碼的異步表單校驗庫

表單校驗是一件很棘手的事情。深刻了解表單的實現以後,你會發現有大量的邊界場景要處理。幸運的是,市面上有不少表單校驗庫,它們提供了必要的表計量(譯註:如 dirty、invalid、inItialized、pristine 等等)和處理函數,來讓咱們實現一個健壯的表單。但我要使用 React Hooks API 來打造一個 100 行代碼如下的表單校驗庫來挑戰自我。雖然 React Hooks 還在實驗性階段,可是這是一個 React Hooks 實現表單校驗的證實。html

我要聲明的是,我寫的這個確實是不到 100 行代碼。但這個教程卻有 200 行左右的代碼,是由於我須要闡釋清楚這個庫是如何使用的。前端

我看過的大多數表單庫的新手教程都離不開三個核心話題:異步校驗,表單聯動:某些表單項的校驗須要在其餘表單項改變時觸發,表單校驗效率的優化。我很是反感那些教程把使用場景固定,而忽略其餘可變因素的影響的作法。由於在真實場景中每每事與願違,因此個人教程會盡可能覆蓋更多真實場景。react

咱們的目標須要知足:android

  • 同步校驗單個表單項,包括當表單項的值發生變化時,會跟隨變化的有依賴的表單項ios

  • 異步校驗單個表單項,包括當表單項的值發生變化時,會跟隨變化的有依賴的表單項git

  • 在提交表單前,同步校驗全部表單項github

  • 在提交表單前,異步校驗全部表單項npm

  • 嘗試異步提交,若是表單提交失敗,展現返回的錯誤信息後端

  • 給開發者提供校驗表單的函數,讓開發者可以在合適的時機,好比 onBlur 的時候校驗表單api

  • 容許單個表單項的多重校驗

  • 當表單校驗未經過時禁止提交

  • 表單的錯誤信息只在有錯誤信息變化或者嘗試提交表單的時候才展現出來

咱們將會經過實現一個包含用戶名,密碼,密碼二次確認的帳戶註冊表單來覆蓋這些場景。下面是個簡單的界面,咱們來一塊兒打造這個庫吧。

const form = useForm({
  onSubmit,
});

const usernameField = useField("username", form, {
  defaultValue: "",
  validations: [
    async formData => {
      await timeout(2000);
      return formData.username.length < 6 && "Username already exists";
    }
  ],
  fieldsToValidateOnChange: []
});
const passwordField = useField("password", form, {
  defaultValue: "",
  validations: [
    formData =>
      formData.password.length < 6 && "Password must be at least 6 characters"
  ],
  fieldsToValidateOnChange: ["password", "confirmPassword"]
});
const confirmPasswordField = useField("confirmPassword", form, {
  defaultValue: "",
  validations: [
    formData =>
      formData.password !== formData.confirmPassword &&
      "Passwords do not match"
  ],
  fieldsToValidateOnChange: ["password", "confirmPassword"]
});

// const { onSubmit, getFormData, addField, isValid, validateFields, submitted, submitting } = form
// const { name, value, onChange, errors, setErrors, pristine, validate, validating } = usernameField
複製代碼

這是一個很是簡單的 API,但着實給了咱們很大的靈活性。你可能已經意識到了,這個接口包含兩個名字很像的函數, validation 和 validate。validation 被定義成一個函數,它以表單數據和表單項的 name 爲參數,若是驗證出了問題,則返回一個錯誤信息,與此同時它會返回一個虛值(譯者注:可轉換爲 false 的值)。另外一方面,validate 函數會執行這個表單項的全部 validation 函數,而且更新這個表單項的錯誤列表。

重中之重,咱們須要一個來處理表單值的變化和表單提交的骨架。咱們的第一次嘗試不會包含任何校驗,它僅僅用來處理表單的狀態。

// 跳過樣板代碼: imports, ReactDOM, 等等.

export const useField = (name, form, { defaultValue } = {}) => {
  let [value, setValue] = useState(defaultValue);

  let field = {
    name,
    value,
    onChange: e => {
      setValue(e.target.value);
    }
  };
  // 註冊表單項
  form.addField(field);
  return field;
};

export const useForm = ({ onSubmit }) => {
  let fields = [];

  const getFormData = () => {
    // 得到一個包含原始表單數據的 object
    return fields.reduce((formData, field) => {
      formData[field.name] = field.value;
      return formData;
    }, {});
  };

  return {
    onSubmit: async e => {
      e.preventDefault(); // 阻止默認表單提交
      return onSubmit(getFormData());
    },
    addField: field => fields.push(field),
    getFormData
  };
};

const Field = ({ label, name, value, onChange, ...other }) => {
  return (
    <FormControl className="field">
      <InputLabel htmlFor={name}>{label}</InputLabel>
      <Input value={value} onChange={onChange} {...other} />
    </FormControl>
  );
};

const App = props => {
  const form = useForm({
    onSubmit: async formData => {
      window.alert("Account created!");
    }
  });

  const usernameField = useField("username", form, {
    defaultValue: ""
  });
  const passwordField = useField("password", form, {
    defaultValue: ""
  });
  const confirmPasswordField = useField("confirmPassword", form, {
    defaultValue: ""
  });

  return (
    <div id="form-container">
      <form onSubmit={form.onSubmit}>
        <Field {...usernameField} label="Username" />
        <Field {...passwordField} label="Password" type="password" />
        <Field {...confirmPasswordField} label="Confirm Password" type="password" />
        <Button type="submit">Submit</Button>
      </form>
    </div>
  );
};
複製代碼

這裏沒有太難理解的代碼。表單的值是咱們惟一所關心的。每一個表單項在它初始化結束以前把自身註冊在表單上。咱們的 onChange 函數也很簡單。這裏最複雜的函數就是 getFormData,即使如此,這也沒法跟抽象的 reduce 語法相比。getFormData 遍歷全部表單項,並返回一個 plain object 來表示表單的值。最後值得一提的就是在表單提交的時候,咱們須要調用 preventDefault 來阻止頁面從新加載。

事情發展的很順利,如今咱們來把驗證加上吧。當表單項的值發生變化或者提交表單的時候,咱們不是指明哪些具體的表單項須要被校驗,而是校驗全部的表單項。

export const useField = (
  name,
  form,
  { defaultValue, validations = [] } = {}
) => {
  let [value, setValue] = useState(defaultValue);
  let [errors, setErrors] = useState([]);

  const validate = async () => {
    let formData = form.getFormData();
    let errorMessages = await Promise.all(
      validations.map(validation => validation(formData, name))
    );
    errorMessages = errorMessages.filter(errorMsg => !!errorMsg);
    setErrors(errorMessages);
    let fieldValid = errorMessages.length === 0;
    return fieldValid;
  };

  useEffect(
    () => {
      form.validateFields(); // 當 value 變化的時候校驗表單項
    },
    [value]
  );

  let field = {
    name,
    value,
    errors,
    validate,
    setErrors,
    onChange: e => {
      setValue(e.target.value);
    }
  };
  // 註冊表單項
  form.addField(field);
  return field;
};

export const useForm = ({ onSubmit }) => {
  let fields = [];

  const getFormData = () => {
    // 得到一個 object 包含原始表單數據
    return fields.reduce((formData, field) => {
      formData[field.name] = field.value;
      return formData;
    }, {});
  };

  const validateFields = async () => {
    let fieldsToValidate = fields;
    let fieldsValid = await Promise.all(
      fieldsToValidate.map(field => field.validate())
    );
    let formValid = fieldsValid.every(isValid => isValid === true);
    return formValid;
  };

  return {
    onSubmit: async e => {
      e.preventDefault(); // 阻止表單提交默認事件
      let formValid = await validateFields();
      return onSubmit(getFormData(), formValid);
    },
    addField: field => fields.push(field),
    getFormData,
    validateFields
  };
};

const Field = ({
  label,
  name,
  value,
  onChange,
  errors,
  setErrors,
  validate,
  ...other
}) => {
  let showErrors = !!errors.length;
  return (
    <FormControl className="field" error={showErrors}>
      <InputLabel htmlFor={name}>{label}</InputLabel>
      <Input
        id={name}
        value={value}
        onChange={onChange}
        onBlur={validate}
        {...other}
      />
      <FormHelperText component="div">
        {showErrors &&
          errors.map(errorMsg => <div key={errorMsg}>{errorMsg}</div>)}
      </FormHelperText>
    </FormControl>
  );
};

const App = props => {
  const form = useForm({
    onSubmit: async formData => {
      window.alert("Account created!");
    }
  });

  const usernameField = useField("username", form, {
    defaultValue: "",
    validations: [
      async formData => {
        await timeout(2000);
        return formData.username.length < 6 && "Username already exists";
      }
    ]
  });
  const passwordField = useField("password", form, {
    defaultValue: "",
    validations: [
      formData =>
        formData.password.length < 6 && "Password must be at least 6 characters"
    ]
  });
  const confirmPasswordField = useField("confirmPassword", form, {
    defaultValue: "",
    validations: [
      formData =>
        formData.password !== formData.confirmPassword &&
        "Passwords do not match"
    ]
  });

  return (
    <div id="form-container">
      <form onSubmit={form.onSubmit}>
        <Field {...usernameField} label="Username" />
        <Field {...passwordField} label="Password" type="password" />
        <Field {...confirmPasswordField} label="Confirm Password" type="password" />
        <Button type="submit">Submit</Button>
      </form>
    </div>
  );
};

複製代碼

上面的代碼是改進版,大致瀏覽一下彷佛能夠跑起來了,可是要作到交付給用戶還遠遠不夠。這個版本丟掉了不少用於隱藏錯誤信息的標記態(譯者注:flag),這些錯誤信息可能會在不恰當的時機出現。好比在用戶還沒修改完輸入信息的時候,表單就立馬校驗並展現相應的錯誤信息了。

最基本的,咱們須要一些基礎的標記狀態來告知 UI,若是用戶沒有修改表單項的值,那麼就不展現錯誤信息。再進一步,除了這些基礎的,咱們還須要一些額外的標記狀態。

咱們須要一個標記態來記錄用戶已經嘗試提交表單了,以及一個標記態來記錄表單正在提交中或者表單項正在進行異步校驗。你可能也想弄清楚咱們爲何要在 useEffect 的內部調用 validateFields,而不是在 onChange 裏調用。咱們須要 useEffect 是由於 setValue 是異步發生的,它既不會返回一個 promise,也不會給咱們提供一個 callback。所以,惟一能讓咱們肯定 setValue 是否完成的方法,就是經過 useEffect 來監聽值的變化。

如今咱們一塊兒來實現這些所謂的標記態吧。用它們來更好的完善 UI 和細節。

export const useField = (
  name,
  form,
  { defaultValue, validations = [], fieldsToValidateOnChange = [name] } = {}
) => {
  let [value, setValue] = useState(defaultValue);
  let [errors, setErrors] = useState([]);
  let [pristine, setPristine] = useState(true);
  let [validating, setValidating] = useState(false);
  let validateCounter = useRef(0);

  const validate = async () => {
    let validateIteration = ++validateCounter.current;
    setValidating(true);
    let formData = form.getFormData();
    let errorMessages = await Promise.all(
      validations.map(validation => validation(formData, name))
    );
    errorMessages = errorMessages.filter(errorMsg => !!errorMsg);
    if (validateIteration === validateCounter.current) {
      // 最近一次調用
      setErrors(errorMessages);
      setValidating(false);
    }
    let fieldValid = errorMessages.length === 0;
    return fieldValid;
  };

  useEffect(
    () => {
      if (pristine) return; // 避免渲染完成後的第一次校驗
      form.validateFields(fieldsToValidateOnChange);
    },
    [value]
  );

  let field = {
    name,
    value,
    errors,
    setErrors,
    pristine,
    onChange: e => {
      if (pristine) {
        setPristine(false);
      }
      setValue(e.target.value);
    },
    validate,
    validating
  };
  form.addField(field);
  return field;
};

export const useForm = ({ onSubmit }) => {
  let [submitted, setSubmitted] = useState(false);
  let [submitting, setSubmitting] = useState(false);
  let fields = [];

  const validateFields = async fieldNames => {
    let fieldsToValidate;
    if (fieldNames instanceof Array) {
      fieldsToValidate = fields.filter(field =>
        fieldNames.includes(field.name)
      );
    } else {
      // 若是 fieldNames 缺省,則驗證全部表單項
      fieldsToValidate = fields;
    }
    let fieldsValid = await Promise.all(
      fieldsToValidate.map(field => field.validate())
    );
    let formValid = fieldsValid.every(isValid => isValid === true);
    return formValid;
  };

  const getFormData = () => {
    return fields.reduce((formData, f) => {
      formData[f.name] = f.value;
      return formData;
    }, {});
  };

  return {
    onSubmit: async e => {
      e.preventDefault();
      setSubmitting(true);
      setSubmitted(true); // 用戶已經至少提交過一次表單
      let formValid = await validateFields();
      let returnVal = await onSubmit(getFormData(), formValid);
      setSubmitting(false);
      return returnVal;
    },
    isValid: () => fields.every(f => f.errors.length === 0),
    addField: field => fields.push(field),
    getFormData,
    validateFields,
    submitted,
    submitting
  };
};

const Field = ({
  label,
  name,
  value,
  onChange,
  errors,
  setErrors,
  pristine,
  validating,
  validate,
  formSubmitted,
  ...other
}) => {
  let showErrors = (!pristine || formSubmitted) && !!errors.length;
  return (
    <FormControl className="field" error={showErrors}>
      <InputLabel htmlFor={name}>{label}</InputLabel>
      <Input
        id={name}
        value={value}
        onChange={onChange}
        onBlur={() => !pristine && validate()}
        endAdornment={
          <InputAdornment position="end">
            {validating && <LoadingIcon className="rotate" />}
          </InputAdornment>
        }
        {...other}
      />
      <FormHelperText component="div">
        {showErrors &&
          errors.map(errorMsg => <div key={errorMsg}>{errorMsg}</div>)}
      </FormHelperText>
    </FormControl>
  );
};

const App = props => {
  const form = useForm({
    onSubmit: async (formData, valid) => {
      if (!valid) return;
      await timeout(2000); // 模擬網絡延遲
      if (formData.username.length < 10) {
        //模擬服務端返回 400 
        usernameField.setErrors(["Make a longer username"]);
      } else {
        //模擬服務端返回 201 
        window.alert(
          `form valid: ${valid}, form data: ${JSON.stringify(formData)}`
        );
      }
    }
  });

  const usernameField = useField("username", form, {
    defaultValue: "",
    validations: [
      async formData => {
        await timeout(2000);
        return formData.username.length < 6 && "Username already exists";
      }
    ],
    fieldsToValidateOnChange: []
  });
  const passwordField = useField("password", form, {
    defaultValue: "",
    validations: [
      formData =>
        formData.password.length < 6 && "Password must be at least 6 characters"
    ],
    fieldsToValidateOnChange: ["password", "confirmPassword"]
  });
  const confirmPasswordField = useField("confirmPassword", form, {
    defaultValue: "",
    validations: [
      formData =>
        formData.password !== formData.confirmPassword &&
        "Passwords do not match"
    ],
    fieldsToValidateOnChange: ["password", "confirmPassword"]
  });

  let requiredFields = [usernameField, passwordField, confirmPasswordField];

  return (
    <div id="form-container">
      <form onSubmit={form.onSubmit}>
        <Field
          {...usernameField}
          formSubmitted={form.submitted}
          label="Username"
        />
        <Field
          {...passwordField}
          formSubmitted={form.submitted}
          label="Password"
          type="password"
        />
        <Field
          {...confirmPasswordField}
          formSubmitted={form.submitted}
          label="Confirm Password"
          type="password"
        />
        <Button
          type="submit"
          disabled={
            !form.isValid() ||
            form.submitting ||
            requiredFields.some(f => f.pristine)
          }
        >
          {form.submitting ? "Submitting" : "Submit"}
        </Button>
      </form>
    </div>
  );
};
複製代碼

最後一次嘗試,咱們加了不少東西進去。包括四個標記態:pristine、validating、submitted 和 submitting。還添加了 fieldsToValidateOnChange,將它傳給 validateFields 來聲明當表單的值發生變化的時候哪些表單項須要被校驗。咱們在 UI 層經過這些標記狀態來控制什麼時候展現錯誤信息和加載動畫以及禁用提交按鈕。

你可能注意到了一個很特別的東西 validateCounter。咱們須要記錄 validate 函數的調用次數,由於 validate 在當前的調用完成以前,它有可能會被再次調用。若是是這種場景的話,咱們應該放棄當前調用的結果,而只使用最新一次的調用結果來更新表單項的錯誤狀態。

一切就緒以後,這就是咱們的成果了。

React Hooks 提供了一個簡潔的表單校驗解決方案。這是我使用這個 API 的第一次嘗試。儘管有一點瑕疵,可是我依然感到了它的強大。這個接口有些奇怪,由於是按照我喜歡的樣子來的。然而除了這些瑕疵之外,它的功能仍是很強大的。

我以爲它還少了一些特性,好比一個 callback 機制來代表什麼時候 useState 更新 state 完畢,這也是一個在 useEffect hook 中檢查對比 prop 變化的方法。

後記

爲了保證這個教程的易於上手,我刻意省略了一些參數的校驗和異常錯誤處理。好比,我沒有校驗傳入的 form 參數是否真的是一個 form 對象。若是我能明確地校驗它的類型並拋出一個詳細的異常信息會更好。事實上,我已經寫了,代碼會像這樣報錯。

Cannot read property ‘addField’ of undefined
複製代碼

在把這份代碼發佈成 npm 包以前,還須要合適的參數校驗和異常錯誤處理。如我所說,若是你想深刻了解的話,我已經用 superstruct 實現了一個包含參數校驗的更健壯的版本

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索