Final Form 跨框架 Demo 对比(JS vs React vs Vue)

导读

笔者近期在研究提升团队表单研发效率的方案,已经写了《Form 表单状态管理工具调研》、《React Hook Form 设计思路浅析》、《Formik 设计思路浅析》三篇文章,按规律应该轮到《final-form 设计思路浅析》了。但是 final-form(以下简称 FF) 最大的特点就是框架无关性,那么就得先直观体验一下是怎么个无关,毕竟「Talk is cheap, show me the code」。

所以本篇会用同一个「用户注册」的场景,来分别用 JS + HTML + FFReact + Hooks + FFVue3 + FFreact-final-form-hooksvue-final-form 5 种方式来实现,充分地理解其框架无关性,并窥探其设计理念,为下一篇文章打基础。

注意,本文的目的是展示和对比代码,解析会在下一篇文章。

正文

场景介绍

image.png

如上图,一个模拟用户注册的表单,基础代码放在文末,功能点如下:

  1. 3 个 input 对应的 key 分别是 unamepswdconfirm
  2. blursubmit 的时候触发校验,校验不通过则提示;
  3. 校验规则为: 3 个字段都必填confirm 必须等于 pawd
  4. 校验全部通过后,点击 Submit 按钮,alert 出 data 对象;
  5. 点击 Reset 按钮,清空所有数据;

为了减少重复代码,把公共配置写在这里,后面的代码就省略了,CSS 代码放在了文章最后。

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

export const fields = [
  {
    key: "uname",
    label: "User Name *",
    placeholder: "User Name",
    type: "text", // input type
  },
  {
    key: "pswd",
    label: "Password *",
    placeholder: "Password",
    type: "password",
  },
  {
    key: "confirm",
    label: "Confirmation *",
    placeholder: "Password Confirmation",
    type: "password",
  },
];

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,
};
复制代码

JS + HTML

此例为原生 JS + HTML(代码见上文),改编自 官方 Demo - Vanilla JS,以 webpack 构建工程:

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 } = 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;
      }

      // 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);
  }
});
复制代码

大概的逻辑是:

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

需要关注的点都已经加了注释,还是比较好理解的。

PS:好久没写原生 JS 操作 DOM 了,内心莫名有一种爽感。

React Simple

此例为 React + Hooks,改编自 官方 Demo - Simple React,代码较多,重点看 Field 子组件的实现:

import React, { useState, useEffect, useMemo, useCallback } from "react";
import { createForm } from "final-form";
import { formConfig, fields } from "./constants";
import "./style.css";

/* Notice 1: Must use Component because of useState can't be used in loop */
const Field = (props) => {
  const { key, label, type, placeholder } = props.field;
  const [hook, setHook] = useState({});

  /* Notice 2: Must use useCallback otherwise will be infinite re-render */
  const unsubscribe = useCallback(
    () =>
      props.form.registerField(
        key,
        (fieldState) => {
          setHook(fieldState);
        },
        { value: false, error: true, touched: true }
      ),
    [props.form, key]
  );

  /* Notice 3: Unsubscribe when Unmounted */
  useEffect(() => () => unsubscribe(), []);

  return (
    <div>
      <label htmlFor={key}>{label}</label>
      <input
        name={key}
        type={type}
        onBlur={hook.blur}
        onFocus={hook.focus}
        onInput={(e) => hook.change(e.target.value || undefined)}
        value={hook.value}
        placeholder={placeholder}
      />
      {hook.touched && hook.error && <p>{hook.error}</p>}
    </div>
  );
};

const MyForm = () => {
  /* Notice 4: Define form only once by useMemo */
  const form = useMemo(() => createForm(formConfig), []);

  const [formState, setFormState] = useState({});

  const unsubscribe = useCallback(
    () =>
      form.subscribe(
        (formState) => {
          setFormState(formState);
        },
        { active: true, pristine: true, submitting: true, values: true }
      ),
    [form]
  );

  useEffect(() => () => unsubscribe(), []);

  return (
    <form
      id="form"
      onSubmit={(e) => {
        e.preventDefault();
        form.submit();
      }}
    >
      <h1>Final Form Demo</h1>
      {fields.map((field) => (
        <Field key={field.key} field={field} form={form} />
      ))}
      <div>
        <button type="submit" disabled={formState.submitting}>
          Submit
        </button>
        <button
          type="button"
          id="reset"
          onClick={form.reset}
          disabled={formState.submitting || formState.pristine}
        >
          Reset
        </button>
      </div>
    </form>
  );
};

export default MyForm;
复制代码

注意:因为 hooks 不能在循环里调用,像下面这样是不行的:

fields.map(({ key }) => { const { hook, setHook } = useState(...); });
复制代码

所以必须封装一个 Field 组件来容纳 useState 逻辑,然后再在循环中使用这个组件。最关键的 registerField 逻辑也封装在 Field 中,与原生 JS 不同的是这里不用操作 DOM 了,而是直接用 jsx 的语法直接绑定在元素上,更符合现在的主流思路。其他关键点都打了注释。

另外,注意代码中的备注,多处用到了 useCallbackuseMemo,主要就是防止无限 re-render,如果觉得不好理解也没关系,直接忽略看主要逻辑即可。

React Final Form Hooks

此例直接使用了 react-final-form-hooks,是 FF 作者的另一个作品,代码如下:

import React from "react";
import { useForm, useField } from "react-final-form-hooks";
import { formConfig, fields } from "./constants";
import "./style.css";

const Filed = (props) => {
  const { key, label, type, placeholder } = props.field;
  const hook = useField(key, props.form);
  return (
    <div>
      <label htmlFor={key}>{label}</label>
      <input type={type} {...hook.input} placeholder={placeholder} />
      {hook.meta.touched && hook.meta.error && <p>{hook.meta.error}</p>}
    </div>
  );
};

const MyForm = () => {
  const { form, handleSubmit } = useForm(formConfig);
  return (
    <form id="form" onSubmit={handleSubmit}>
      <h1>Final Form Demo</h1>
      {fields.map((field) => (
        <Filed key={field.key} field={field} form={form} />
      ))}
      <div>
        <button type="submit">Submit</button>
        <button type="button" id="reset" onClick={form.reset}>
          Reset
        </button>
      </div>
    </form>
  );
};

export default MyForm;
复制代码

注意:与 React Simple 情况类似,因为 hooks 不能在循环里调用,像下面这样是不行的:

fields.map(({ key }) => useField(key, form));
复制代码

所以只能再抽一个 Field 组件了,然后循环使用这个组件,这里算是再强调一次。

可以明显感觉到代码量较 React Simple 少了很多,主要是少了 unmount 的逻辑,并且使用了结构语法。所以,如果不是为了调研,需要对比,还是建议直接使用 react-final-form-hooks

Vue3 Simple

此例为 Vue3 + <script setup> 的写法,与 React + Hooks 的相似度极高,只是有 2 个最明显的差异,先看代码:

Field.vue

<!-- Field.vue -->
<script setup>
import { defineProps, ref, onUnmounted } from "vue";
// eslint-disable-next-line vue/no-setup-props-destructure
const { field, form } = defineProps(["field", "form"]);
const { key, label, type, placeholder } = field;
const hook = ref({});

const unsubscribe = form.registerField(
  key,
  (fieldState) => {
    hook.value = fieldState;
  },
  { value: false, error: true, touched: true }
);

onUnmounted(() => {
  unsubscribe();
});
</script>
<template>
  <div>
    <label :htmlFor="key">{{ label }}</label>
    <input
      :name="key"
      :type="type"
      :value="hook.value"
      :placeholder="placeholder"
      @blur="hook.blur"
      @focus="hook.focus"
      @input="(e) => hook.change(e.target.value || undefined)"
    />
    <p v-show="hook.touched && hook.error">{{ hook.error }}</p>
  </div>
</template>
复制代码

Form.vue

<!-- Form.vue -->
<script setup>
import { ref, onUnmounted } from "vue";
import Field from "./Field.vue";
import { createForm } from "final-form";
import { formConfig, fields } from "./constants";
const form = createForm(formConfig);
const formStateRef = ref({});

const unsubscribe = form.subscribe(
  (formState) => {
    formStateRef.value = formState;
  },
  { active: true, pristine: true, submitting: true, values: true }
);

onUnmounted(() => {
  unsubscribe();
});

const handleSubmit = (event) => {
  event.preventDefault();
  form.submit();
};
</script>
<template>
  <form id="form" @submit="handleSubmit">
    <h1>Final Form Demo</h1>
    <Field
      v-for="field in fields"
      :key="field.key"
      :field="field"
      :form="form"
    />
    <div>
      <button type="submit" :disabled="formStateRef.submitting">Submit</button>
      <button
        type="button"
        id="reset"
        @click="form.reset"
        :disabled="formStateRef.submitting || formStateRef.pristine"
      >
        Reset
      </button>
    </div>
  </form>
</template>
复制代码

可以看到,除了语法上的差异,代码结构和逻辑几乎一样,明显的差异有 2 点:

  1. 由于 Vue SFC 的语法限制,FormField 组件必须拆成 2 个文件,无法像 React 那样写在一个文件里。当然,Vue 也有 JSX 语法,也支持函数式组件,不过上文已经说过,要以 <script setup> 语法糖为前提。但是如果是要封装一个通用 Form 组件的话,或许 JSX 是更好的选择
  2. 此例中 unsubscribe 的声明可以直接写在 script 中,如果在 React 中这样写,会爆栈。因为 setState 会触发整个组件的 re-render,会导致无限循环,所以必须用 useCallback 包裹一下 。在 Vue3 中则不会出现此问题,这主要是因为 Vue3 的 template 做了很多的处理,此处猜测是静态提升的作用,篇幅关系就不展开了。

所以 Vue3 的 Demo 基本就是 React 的翻版,而且用了 FF 也不好用 v-model 了,这就导致也许 JSX 的写法更合适。

Vue Final Form

此例直接使用了 vue-final-form(以下简称 VFF),stars 数不高,但是收录在了 FF 的 衍生作品列表 中,来看下代码:

<script setup>
import { FinalForm, FinalField } from "vue-final-form";
import { formConfig, fields } from "./constants";
</script>
<template>
  <FinalForm :submit="formConfig.onSubmit" :validate="formConfig.validate">
    <template v-slot="formProps">
      <form @submit="formProps.handleSubmit">
        <h1>Final Form Demo</h1>
        <FinalField v-for="field in fields" :key="field.key" :name="field.key">
          <template v-slot="fieldProps">
            <div>
              <label :htmlFor="field.key">{{ field.label }}</label>
              <input
                v-on="fieldProps.events"
                :name="fieldProps.name"
                :type="field.type"
                :value="fieldProps.value"
                :placeholder="field.placeholder"
              />
              <p v-show="fieldProps.meta.error && fieldProps.meta.touched">
                {{ fieldProps.meta.error }}
              </p>
            </div>
          </template>
        </FinalField>
        <div>
          <button type="submit" :disabled="formProps.submitting">Submit</button>
          <button
            type="button"
            id="reset"
            @click="formProps.reset"
            :disabled="formProps.submitting || formProps.pristine"
          >
            Reset
          </button>
        </div>
      </form>
    </template>
  </FinalForm>
</template>
复制代码

从 Demo 中可以看到,VFF 只暴露出了 2 个组件:FinalFormFinalField,这两个组件都用 v-slot 为其子组件提供了特殊上下文,与 React 的 Render Props 非常类似。其实 React Final Form 最开始也是类似的用法,后续才出了 Hooks 版。所以以此推断,实际上 Vue 也可以有一个 hooks 版本。

其实这种 v-slot 或 render props 的方式也有一个好处,那就是不需要再封装出一个组件了,在某种程度上这也算是一个优点,不过不具有决定性。

注意,VFF 实际上还不太完善,比如没有接收 validateOnBlur: true 这个配置的地方,至少在文档中没有看到,所以还是不建议将 VFF 用到实践中。更推荐像 React Final Form Hooks 一样,自定义一套 Hooks,其实就是 useFormuseField 两个 Hooks,如果有机会的话,笔者也许会尝试一下。

PS:后来看了 vue-final-form 源码,发现竟然有 useForm 和 useField 的 hooks,只是没写在文档里,这下可参考的就更直接了

总结

首先,final-form 的的确确是一个框架无关的表单管理工具。表单状态管理工具的本质,其实就是处理 validate 校验,以及各种触发时机和触发后的状态。维护用户输入数据反而是最简单的,可以说没什么难度。从 JS + HTML 的 Demo 代码中可以很直观的看出,其原理就是用观察者模式建了一个 form 对象,然后在其内部维护各种校验状态和校验逻辑,并返回处理后的各种方法,以供表单使用。

通过以上案例的对比,粗略总结出以下结论:

  1. 首推 hooks 的方式,代码少方便。代价就是必须拆成 2 个组件;
  2. 用 Render Props 提供组件的方式也可以,但是没有 hooks 的方式直观,自由;
  3. 最次用原生 FF 的方式,使用监听者模式的对象。即使这样也比自己手写完善;
  4. Vue3 目前没有找到合适的 Hooks,理论上封装应该也不难,参考 React Final Form Hooks 的源码实现一个即可。

接下来会有一篇解析 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

附录

Common CSS

/* style.css */
h1,
form {
  text-align: center;
}

label {
  display: inline-block;
  width: 110px;
  text-align: right;
  margin-right: 16px;
}

div {
  margin: 8px 0;
}

button {
  margin: 0 8px;
}

p {
  color: #f00;
  margin: 0;
  font-size: 12px;
}
复制代码

猜你喜欢

转载自juejin.im/post/7106324419257384968
今日推荐