Final Form 设计思路浅析

导读

为了提高表单开发的效率,笔者开始进行 Form 表单状态管理工具调研 的工作,并且已经完成了《React Hook Form 设计思路浅析》和《Formik 设计思路浅析》。

又因为 Final Form(以下简称 FF)的框架无关性,所以先写了一篇《Final Form 跨框架 Demo 对比(JS vs React vs Vue)》(以下简称《Demo 对比》)进行铺垫,建议先阅读一下这篇文章,可以更好的理解本文。

本文是「浅析」系列的最后一篇,接下来就是制定「通用 Form 组件 API 协议」了。

介绍

Final Form 官网的介绍:

Framework agnostic, high performance, subscription-based form state management.

谷歌翻译:框架无关、高性能、基于订阅的表单状态管理。

原生 JS 是最能体现其设计思想的,所以我们以《Demo 对比》中 JS + HTML 的例子来解析,该例改编自 官方 Demo - Vanilla JS,本文只引用代码,场景说明及其他详情见《Demo 对比》一文。

简单说明下:为了让 JS 代码更聚焦,所以把 formConfig 配置单独拆出来放到了 constants.js 中,它和 HTML 简单看一下即可,重点关注 index.js 的代码。

HTML

    <form id="form">
      <h1>Final Form Demo</h1>
      <div>
        <label htmlFor="uname">User Name *</label>
        <input type="text" name="uname" placeholder="User Name" />
        <p>User Name Required!</p>
      </div>
      <div>
        <label htmlFor="pswd">Password *</label>
        <input type="password" name="pswd" placeholder="Password" />
        <p>Password Required!</p>
      </div>
      <div>
        <label htmlFor="confirm">Confirmation *</label>
        <input
          type="password"
          name="confirm"
          placeholder="Password Confirmation"
        />
        <p>Password Confirmation Required!</p>
      </div>
      <div>
        <button type="submit">Submit</button>
        <button type="button" id="reset">Reset</button>
      </div>
    </form>
复制代码

constants.js

export const formConfig = {
  initialValues: {},
  onSubmit(values) {
    console.log("submiting");
    return new Promise((rev) => {
      setTimeout(() => {
        console.log("Submit", values);
        alert(JSON.stringify(values));
        rev();
      }, 300);
    });
  },
  validate(values) {
    const errors = {};
    if (!values.uname) {
      errors.uname = "User Name Required!";
    }
    if (!values.pswd) {
      errors.pswd = "Password Required!";
    }
    if (!values.confirm) {
      errors.confirm = "Password Confirmation Required!";
    }
    if (values.confirm !== values.pswd) {
      errors.confirm = "Must be same as Password!";
    }
    return errors;
  },
  validateOnBlur: true,
};
复制代码

index.js(重要)

import "./style.css";
import { createForm } from "final-form";
import { formConfig } from "./constants";
/* Notice 1: createForm */
const form = createForm(formConfig);

document.getElementById("form").addEventListener("submit", (event) => {
  event.preventDefault();
  form.submit();
});
document.getElementById("reset").addEventListener("click", () => form.reset());

const registered = {};

function registerField(input) {
  const { name } = input;
  /* Notice 2: form.registerField */
  form.registerField(
    name,
    (fieldState) => {
      /* Notice 3: fieldState */
      const { blur, change, error, focus, touched, value } = fieldState;
      const errorElement = input.nextElementSibling;
      if (!registered[name]) {
        // first time, register event listeners
        input.addEventListener("blur", () => blur());
        input.addEventListener("input", (event) => change(event.target.value));
        input.addEventListener("focus", () => focus());
        registered[name] = true;
      }
      
      input.value = value || "";

      // show/hide errors
      if (errorElement) {
        if (touched && error) {
          errorElement.innerHTML = error;
          errorElement.style.display = "block";
        } else {
          errorElement.innerHTML = "";
          errorElement.style.display = "none";
        }
      }
    },
    { value: true, error: true, touched: true }
  );
}

[...document.forms[0]].forEach((input) => {
  if (input.name) {
    registerField(input);
  }
});
复制代码

代码太长不想看,没关系,我们先捋一下整体思路,然后逐行拆解下。引用《Demo 对比》中的内容,梳理一下 Demo 的代码逻辑:

大概的逻辑是:

  1. createForm 传入 formConfig 生成 form 对象;
  2. 给 button 绑定上 form.submitform.reset 事件;
  3. (核心)声明一个注册函数 registerField,入参为 input DOM,内部调用 form.registerField 为 input 绑定 blur、input、focus 事件,并控制错误提示 DOM 的内容和显隐
  4. 循环 HTML 中所有 input 组件,调用 registerField 函数。

FFAPI 非常简单,可以说核心就只是 createForm,其他的 fieldSubscriptionItemsformSubscriptionItemsARRAY_ERRORFORM_ERROR 不太常用,所以我们只重点解析一下 createForm 和它的核心方法即可,其实还是有一定复杂度的。

API 解析

createForm

const form = createForm(formConfig);
// code...
  form.submit();
// code...
  form.reset()
// code...
  form.registerField(...)
复制代码

从代码我们可以看出,这个方法返回一个 form 对象,自带很多方法,所有的逻辑都是围绕这个 form 展开的。Final Form 的文档中有明确说明:

Final Form utilizes the well-known Observer pattern to subscribe to updates about specific portions of state.

谷歌翻译:Final Form 利用著名的观察者模式来订阅有关特定状态部分的更新。

其底层采用了「观察者」模式的设计,所以这个 form 对象当中一定维护着一个「可订阅对象」Subject,所有 form.xxx 的方法都能够访问 form 的上下文,也就是能够访问到 Subject

再说的具体一点,form.submit 在被触发的时候,有能力更改 Subject 的某些属性,比如 Subject.isSubmitting,这时所有监听了该属性的 Observer 都会做出相应的动作,比如触发自己的校验,执行用户注册的函数等。

这也解释了为什么不能像 <form onSubmit={formConfig.onSubmit} /> 这样,直接将 onSubmit 的回调传给表单组件了,因为在提交之前还有触发校验、更改状态等一系列复杂的逻辑。说的高端点,是有一整套生命周期要经历的,这也是表单状态管理组件的核心功能,也是难点。通过 createForm 处理之后,返回的 form.submit 就具有了在触发 onSubmit 逻辑之前触发校验的能力,其他方法也类似。

入参(Config)有 8 个:debug、destroyOnUnregister、initialValues、keepDirtyOnReinitialize、mutators、onSubmit、validate、validateOnBlur,都不需要太多解释,具体可查看文档

返回值(FormApi)有 19 个,绝大多数是 change、blur、submit、reset 这种执行函数,具体可以查看文档。这里面有一个最重要的方法要单独拿出来说一下,那就是 registerField

FormApi.registerField

这可以算是 FF 最核心的功能了,简单描述其功能就是:表单输入组件(field)的注册。field 的相关逻辑非常复杂,比如 输入时触发其它 field 的校验、没被访问过时不触发校验、显示/隐藏错误提示 等,其实绝大多数都是跟校验相关。但是多种触发校验的方式、校验规则的联动依赖、同/异步校验同时存在的情况,决定了几乎是一个 n*n*n 复杂度的问题,这也是我们需要表单状态管理工具的原因。

说回 registerField,它接收 4 个参数:

(
  name: string,
  subscriber: FieldState => void,
  subscription: { [string]: boolean },
  config?: FieldConfig
) => Unsubscribe
复制代码

其它参数比较好理解,查看文档即可,我们重点看一下第二个参数 subscriber。它是一个 register 注册函数,自带 fieldState 入参(上下文),从 Demo 来简单看一下这个入参的能力:

const { blur, change, error, focus, touched, value } = fieldState;
复制代码

除了上述展示的属性外,它的属性还有很多一共有 20 多个,能力十分强大,详见文档。为了方便理解,我们简单将其分为 2 类:一类是 blur、change、focus执行函数,毫无疑问是用来传给表单输入组件的「onXXX」这种回调 API 的。另一类是 error、touched、value状态数据,主要用来判断、输出。

这些上下文都是提供给用户去实现自己想要的注册逻辑的,也就是 register 注册函数函数体里的内容。在 Demo 中就是为 input 绑定事件,控制错误提示等代码。这个 register 函数理论上会在各种触发条件下执行,比如 blur、change、submit 等时机。试着推测一下其底层实现,方式有两种:

  1. 每次执行 blur、change、focus 等方法(handleFunc),会改变 value、state 等值(reactive values),同时执行 register 函数,以触发显示/隐藏错误信息等功能。即:handleFunc => reactive values + handleFunc => register
  2. handleFunc 触发 value、state 等值的变化,register 函数监听 value、state 的变化,从而间接触发了 register。即: handleFunc => reactive values => register

handleFucreactive values 特别多时,第 2 种实现方式在扩展性和维护性上就具有明显的优势了,它可以让新增功能的复杂度维持在常数级,而第 1 种复杂度至少是线性递增的。再加上官方文档中的描述,笔者大胆推测 FF 采用的是第 2 种方式。篇幅关系,实现细节就不展开了,有机会再说。

对于 Array 的处理

FF 没有特别的对 Array 值的处理,这点与 React Hook FormFormik 不同,后两者都提供了 insert、push、swap、pop、move 等操作数组的方法。如果从根本上来说,这些方法本质上都是触发 change 逻辑而已,如果框架不提供,那就需要在所有承载 Array 的组件内部去实现这些数组操作逻辑,然后主动调用 change 函数。如果将这些方法的实现逻辑收敛到框架中,无疑会节省很多重复劳动,理论上更易用,更合理。所以笔者大胆建议,可以在 FieldState 中再多返回一个 arrayHandlers,其中附带各种操作数组的方法。

当然,如果落地到实际使用,是可以自己二次封装实现的,封装成 hooks 或者 render props,甚至自己新扩展一个 FormApi.registerArrayField 都可以。

总结

3 个表单状态管理框架的浅析(另见《React Hook Form 设计思路浅析》和《Formik 设计思路浅析》)终于都写完了,收获颇多,现总结部分结论如下:

  • React Form Hook 无疑是目前最符合 hooks 时代的框架。只是概念稍微有点多,其核心概念 control 其实不太好理解;
  • Formik 作为先驱者,提供了基本的设计思路,比如对于 Array 数据的处理。但是在 hooks 时代,Render Props 的痕迹太重,有点不太「时尚」,关键在 Array 的 hook 实现上还存在瑕疵,所以被 RFH 追赶并在不远的将来超越,也是可以理解得了;
  • Final Form 是最能体现表单状态管理本质的框架,因为其用原生 JS 实现了框架无关,所以最触及根本。但是这也限制了其易用性与时尚性。不过还是可以以其为底层,自己封装更好用的工具的。

不过笔者还是有个疑惑:为什么 Vue 生态中没有一个 stars 和 download 数据比较高的表单状态管理工具呢?也许是笔者没有查到,有相关信息的朋友请留言知悉,笔者感激不尽。

接下来就是设计通用的表单组件 API 了,目前来看参考 Final Form 的参数设计的可能性比较大,比较它的概念最少,设计相对最清晰,各位敬请期待。

“在激烈竞争中,取胜的系统在最大化或者最小化一个或几个变量上会走到近乎荒谬的极端。”

"In the fierce competition, the winning system will go to the absurd extreme in the maximization or minimization of one or several variables"——Charlie Thomas Munger

猜你喜欢

转载自juejin.im/post/7106458644014858254