跳转至

12 受控表单组件:如何开发受控的表单组件?

你好,我是杨文坚。今天我们为自研组件库增加表单组件。

表单技术,日常业务运用非常广泛,除了常见的用户注册和登录场景,信息填写操作、信息编辑操作和上传文件图片等操作都是基于表单技术的。所以很多前端组件库都会实现相关的表单组件,提供给开发者使用,尽量减少表单的开发工作量。

而表单组件的实现,都是基于“受控组件”的技术概念来实现的。那什么是受控组件和非受控组件呢?

什么是受控组件和非受控组件?

“受控组件”和“非受控组件”,是谁最先提出来的,目前无从得知,比较流行的描述来自React官方官网。

受控组件,按照React官网的描述,就是用React.js 内部state来管理HTML表单的数据状态,同时也控制用户操作表单时的数据输入。这类被 React.js 以这种方式控制取值的表单输入元素就叫做“受控组件”。

非受控组件,React 官网的描述是这样的,“在大多数情况下,我们推荐使用受控组件来处理表单数据。在一个受控组件中,表单数据是由 React 组件来管理的。另一种替代方案是使用非受控组件,这时表单数据将交由 DOM 节点来处理”。

从描述可以得知,“受控组件”和“非受控组件”的技术概念,跟React.js等框架的关系耦合不大。简单来讲,“受控组件”就是通过内置统一状态或者数据管理来控制表单操作,而“非受控组件”就是使用原生HTML的表单特性来实现表操作

因为这个技术概念,主要应用场景是表单场景,所以为了统一语义,避免产生歧义,我们对“受控组件”和“非受控组件”统一称为“受控表单组件”和“非受控表单组件”。而“受控组件”的理念,可以更优雅地设计实现表单组件的功能,也是业界Vue.js和React.js开发表单组件的首选技术方案,所以今天要实现的“表单组件”都是“受控表单组件”类型

知道了两种表单组件的技术概念,那么两种组件各有什么优劣呢?在日常工作开发中又要怎么选择使用呢?

受控表单组件和非受控表单组件各有什么优劣?

我们直接看代码案例,用 Vue.js 3.x 实现一个注册表单的功能代码,做个实际的效果对比。

先用 Vue.js 3.x 基于非受控表单组件的概念实现一个注册表单:

<template>
  <h1>这是一个非受控表单组件</h1>
  <form @submit="onSubmit">
    <div>
      <span>用户名称:</span>
      <input ref="inputUserName" name="username" type="text" />
    </div>
    <div>
      <span>密码:</span>
      <input ref="inputPassword" name="password" type="password" />
    </div>
    <div>
      <span>确认密码:</span>
      <input
        ref="inputConfirmPassword"
        name="confirmPassword"
        type="password"
      />
    </div>
    <div>
      <button>提交注册</button>
    </div>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue';
const inputUserName = ref<HTMLInputElement>();
const inputPassword = ref<HTMLInputElement>();
const inputConfirmPassword = ref<HTMLInputElement>();

const onSubmit = (e: Event) => {
  e.preventDefault();
  const formData = {
    userName: inputUserName?.value?.value,
    password: inputPassword?.value?.value,
    confirmPassword: inputConfirmPassword?.value?.value
  };
  window.alert(`提交数据:${JSON.stringify(formData)}`);
};
</script>

然后再用  Vue.js 3.x 基于受控表单组件概念,实现一个注册表单:

<template>
  <h1>这是一个受控表单组件</h1>
  <form @submit="onSubmit">
    <div>
      <span>用户名称:</span>
      <input v-model="state.userName" type="text" />
    </div>
    <div>
      <span>密码:</span>
      <input v-model="state.password" type="password" />
    </div>
    <div>
      <span>确认密码:</span>
      <input v-model="state.confirmPassword" type="password" />
    </div>
    <div>
      <button>提交注册</button>
    </div>
  </form>
</template>

<script setup lang="ts">
import { reactive, toRaw } from 'vue';
const state = reactive({
  userName: '',
  password: '',
  confirmPassword: ''
});

const onSubmit = (e: Event) => {
  e.preventDefault();
  const formData = toRaw(state);
  window.alert(`提交数据:${JSON.stringify(formData)}`);
};
</script>

以上就是“非受控表单组件”和“受控表单组件”这两种表单组件的Vue.js 3.x代码实现方式,我们简单对比一下代码实现的复杂度和代码量:

图片

如果我们在Vue.js 3.x环境里,再加上“数据监听”和“数据校验”这两个功能维度,会是怎样呢?

图片

实现同样的注册表单的功能代码,受控表单组件的实现方式,明显比非受控表单组件更简单,而且更加方便数据字段的扩展。所以一般在开发组件库里的表单组件时,我们都基于“受控组件”的技术理念来开发“受控表单组件”。

但是,开发组件库的表单组件,使用“受控组件”的概念是远远不够的,组件库,一个很重要的优势是复用性,实际工作开发需求时,表单的功能需求是多种多样的。

举个例子,注册表单需要账号密码,输入框的表单要实现字段布局、数据定义和数据校验的操作,如果再加上一个协议同意的选择框,是不是表单布局需要改一下?表单校验函数也需要改一下?以此类推,每添加一个表单字段内容,都需要重复做一堆开发琐碎工作。

所以想要开发组件库里的表单组件,我们需要将“重复工作”抽象成组件库里的组件,提升表单开发的复用性和易用性,也就是要抽象表单通用逻辑,封装成统一受控的表单组件。那么,如何封装Vue.js 3.x的受控表单组件呢?

怎么封装Vue.js3.x的受控表单组件?

首先,我们要明白,传统表单是由一个个数据字段组成的,承载这些数据的显示和输入的是HTML里的<input>、<select>和<option>等元素。

这些元素,在组件库里承载表单字段里数据操作,通常有很多种称呼,例如“表单数据组件”“数据输入组件”“数据录入组件”,为了避免歧义,表单里类似<input>、<select>等承载表单数据功能的组件,我们就统一称为“表单数据组件”。同时,承载表单提交的能力的组件,比如HTML里<form>标签类似功能组件,我们就称为“表单组件”。

统一好组件库里各类组件的称呼,接下来,我们就开始分析如何封装Vue.js 3.x的受控表单组件,主要为三步。

第一步,梳理出表单操作中可复用的逻辑。表单操作可以复用的逻辑是什么呢?

我们先来看看表单的主要操作,“数据显示”“数据输入”“数据校验”和“数据提交”这四个操作逻辑。

数据显示和输入,不同表单数据组件的操作会有差异。比如,<input>是输入内容,类似“填空题”,<select>是选择数据内容,类似“选择题”。这相当于两种不同类型“题目”,它们的“答题卷”都是分开的,无法统一。但,我们可以从这两种操作中抽象出一个共性,定义数据的字段名称,也就是在表单数据里定义这个数据的名称。

因此,这里我们就梳理出了第一个可复用逻辑,数据字段名称

接下来是表单里的“数据校验”操作逻辑。数据校验的核心就是数据在“改变”和“提交”这两个时间点做处理,不受不同类型表单数据组件的用户使用方式差异的影响。那么我们可以再抽象出一个复用逻辑,就是数据校验

小结一下,表单场景里,我们可以复用的逻辑是“数据字段名称”和“数据校验”,那接下来就要实现这个逻辑复用的实际功能组件了。

第二步,把可以复用的逻辑功能封装成通用表单逻辑功能组件。“数据字段名称”和“数据校验”操作,都是跟着作用于每个表单里的“数据字段”的。通常在英文语境里,我们称呼这个表单的数据字段为“Field”,在此我们就统一用中文描述为“表单字段”。

现在要做什么就清晰了,我们要实现一个通用的“表单字段组件”,来统一管理“表单数据组件”里的“字段名称”和“数据校验”,同时也要“辅助”支持“表单组件”提交数据时候做统一的数据字段收集和统一的提交前校验,这个“辅助操作”我们后面再详讲。

第三步,也就是实现代码的阶段。我们先画图设计一下实现流程,“表单字段组件”其实就是“表单数据组件”和“表单组件”之间的“桥梁”:

图片

那我们再进一步细化“表单字段组件”发挥的具体的桥梁作用:

图片

表单字段组件,给表单数据组件传递“字段名称”和“字段校验规则”,给表单组件也是传递“字段名称”和“字段校验规则”。

通过两张图,我们可以总结出一个实现代码的要素,需要一个“上下文”来给整个表单共享“字段名称”和“字段校验规则”的内容,而“表单字段组件”只是作为一个入口,在使用时帮忙把“字段名称”和“字段校验规则”注册到这个共享内容的“上下文”里。

那么,要在Vue.js 3.x里实现跨组件的数据共享,有哪些方式呢?

还记得前面我们讲过的内容不?在Vue.js 3.x里实现跨组件的数据共享,有Props结合Emits组件间数据通信、有用共享响应式数据文件方式,还有引入Pinia这个数据状态管理库等多种方式,但是实现起来太麻烦了。

我这里选择了Vue.js 3.x 的新特性, provide和inject。我们可以在父级组件用provide定义一个“共享数据”及其名称,在子组件里用inject,通过这个数据名称拿到这个父级组件的“共享数据”。如果这个“共享数据”是“响应式数据”类型,我们在子组件里修改这个“共享数据”就可以触发响应式特性,影响到父组件和其他子组件的操作。反之,如果“共享数据”是“普通变量数据”,子组件里是无法修改影响父组件的。

好,我们现在就来实现具体的代码。

先定义各种数据类型:

// 表单组件 实例的数据类型
export interface FormInstance {
  addField(field: FormItemContext): void;
}

// 表单字段组件 实例的数据类型
export interface FormItemInstance {
  validateField(): Promise<ValidateResult>;
}

// 表单字段组件 内置数据类型
export interface FormItemContext extends FormItemInstance {
  label?: string;
  name?: string;
  rule?: ValidateRule;
}

// 表单组件 共享数据类型
export interface FormContext extends FormInstance {
  model?: {
    [key: string]: unknown;
  };
  formInstance?: FormInstance;
}

// 校验结果数据类型
export interface ValidateResult {
  hasError: boolean;
  name?: string;
  value?: unknown;
  message?: string;
}

// 校验规则数据类型
export interface ValidateRule {
  // 字段校验方法
  validator?: (value: unknown) => ValidateResult | Promise<ValidateResult>;
}

然后实现表单组件的代码,也是父级组件,用provide提供了一个“共享数据”:

<template>
  <form :class="{ [className]: true }">
    <slot />
  </form>
</template>

<script lang="ts" setup>
import { reactive, provide } from 'vue';
import { prefixName } from '../theme';
import { FORM_CONTEXT_KEY } from './common';
import type { FormInstance, FormContext, FormItemContext } from './types';
const className = `${prefixName}-form`;

const props = defineProps<{ model?: FormContext['model'] }>();

const fieldList: FormItemContext[] = [];

const addField = (field: FormItemContext) => {
  fieldList.push(field);
};

// 共享数据 (共享上下文)
const formContext = reactive<FormContext>({
  model: props.model,
  addField
});

provide<FormContext>(FORM_CONTEXT_KEY, formContext);

defineExpose<FormInstance>({
  addField
});
</script>

现在我们再来实现“表单字段组件”:

<template>
  <Row :class="{ [baseClassName]: true }">
    <Row :class="labelClassName">
      <Col :span="labelCol">
        <span>{{ props.label }}</span>
      </Col>
      <Col :span="wrapperCol"><slot /></Col>
    </Row>
    <Row :class="wrapperClassName" v-if="props.name">
      <Col :span="labelCol"></Col>
      <Col :span="wrapperCol">
        <span v-if="errorTip" :style="{ fontSize: 12, color: 'red' }">{{
          errorTip
        }}</span>
      </Col>
    </Row>
  </Row>
</template>

<script lang="ts" setup>
import { inject, onMounted, ref, toRaw, watch } from 'vue';
import Col from '../col';
import Row from '../row';
import { prefixName } from '../theme';
import { FORM_CONTEXT_KEY } from './common';
import type {
  FormItemInstance,
  FormContext,
  ValidateRule,
  ValidateResult
} from './types';

const labelCol = 8;
const wrapperCol = 16;

const baseClassName = `${prefixName}-form-item`;
const labelClassName = `${baseClassName}-label`;
const wrapperClassName = `${baseClassName}-wrapper`;

const errorTip = ref<string>('');

const formContext: FormContext | undefined =
  inject<FormContext>(FORM_CONTEXT_KEY);

const props = defineProps<{
  name?: string;
  label?: string;
  rule?: ValidateRule;
}>();

async function validateFieldValue(val: unknown): Promise<ValidateResult> {
  if (props.rule?.validator) {
    const result = await props.rule?.validator?.(val);
    if (result.hasError && result.message) {
      errorTip.value = result.message;
    } else {
      errorTip.value = '';
    }
    return { ...result, ...{ name: props.name, value: toRaw(val) } };
  }
  return {
    hasError: false
  };
}

async function validateField(): Promise<ValidateResult> {
  if (props.rule?.validator && props.name) {
    const result = await validateFieldValue(formContext?.model?.[props?.name]);
    if (result.hasError && result.message) {
      errorTip.value = result.message;
    } else {
      errorTip.value;
    }
    return result;
  }
  return {
    hasError: false
  };
}

onMounted(() => {
  if (formContext?.model && props.name && formContext?.model?.[props?.name]) {
    formContext?.addField({
      name: props.name,
      rule: props.rule,
      validateField
    });

    watch([() => formContext?.model?.[props.name as string]], ([newValue]) => {
      validateFieldValue(newValue);
    });
  }
});

defineExpose<FormItemInstance>({
  validateField
});
</script>

可以看出,这个表单字段组件,作为表单里子组件的身份使用时,将“字段名称”和“字段校验规则”存入统一上下文的“共享数据” (formContext)。

表单字段组件里,能根据字段名称,从共享数据里拿到当前管理的“字段数据”,就是包裹在表单数据组件里的数据。拿到这个字段数据和字段数据校验规则,我们可以在表单字段组件内部,监听这个数据变化,实现实时校验和提醒用户是否数据填写正确。

现在我们实现一个表单字段校验功能效果:

<template>
  <div class="example">
    <Form ref="formRef" :model="model">
      <FormItem label="数据1(数字校验)" name="data1" :rule="rule1">
        <input v-model="model.data1" />
      </FormItem>
      <FormItem label="数据2(字母校验)" name="data2" :rule="rule2">
        <input v-model="model.data2" />
      </FormItem>
    </Form>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue';
import { Form } from '../src';
import { FormInstance } from '../src';

const { FormItem } = Form;
const formRef = ref<FormInstance>();
const model = reactive<{ data1: string; data2: string }>({
  data1: '123',
  data2: 'abc'
});

const rule1 = {
  validator: (val: string) => {
    const hasError = /^[0-9]{1,}$/gi.test(`${val || ''}`) !== true;
    return {
      hasError,
      message: hasError ? '仅支持0-9的数字' : ''
    };
  }
};

const rule2 = {
  validator: (val: string) => {
    const hasError = /^[a-z]{1,}$/gi.test(`${val || ''}`) !== true;
    return {
      hasError,
      message: hasError ? '仅支持a-z的大小写字母' : ''
    };
  }
};
</script>

代码是用表单组件、表单字段组件集合表单数据组件(原生HTML的<input>标签)实现的一个表单的实时数据校验,最终实现效果如动图所示:
图片

好了,我们已经用表单组件里通用的“表单字段组件”,来管理每个“表单数据组件”,也就是实现了“桥梁”的一端,接下来就是要连接“桥梁”的另一端,也就是“表单组件”的提交数据的统一校验。

如何给封装的受控表单组件做统一提交校验?

在HTML里,原生的表单标签<form>是支持统一的submit事件的。我们现在要做的是,拦截这个表单事件,在提交数据前,从“共享数据”里拿到校验规则和所有数据来做数据校验,校验不通过就阻断表单提交。

具体代码如下:

<template>
  <form :class="{ [className]: true }" @submit="handleSubmit">
    <slot />
  </form>
</template>

<script lang="ts" setup>
import { reactive, provide, toRaw } from 'vue';
import { prefixName } from '../theme';
import { FORM_CONTEXT_KEY } from './common';
import type { FormInstance, FormContext, FormItemContext } from './types';
const className = `${prefixName}-form`;

const props = defineProps<{ model?: FormContext['model'] }>();

const fieldList: FormItemContext[] = [];

const addField = (field: FormItemContext) => {
  fieldList.push(field);
};

const resetFields = () => {
  fieldList.forEach((field) => {
    field?.resetField();
  });
};

const formContext = reactive<FormContext>({
  model: props.model,
  addField,
  resetFields
});

provide<FormContext>(FORM_CONTEXT_KEY, formContext);

defineExpose<FormInstance>({
  addField,
  resetFields
});

const emits = defineEmits<{
  (event: 'submit', e: Event): void;
  (event: 'finish', e: unknown): void;
  (event: 'finishFail', e: unknown): void;
}>();

const validateFields = async () => {
  const errorList = [];
  for (let i = 0; i < fieldList.length; i++) {
    const field = fieldList[i];
    const result = await field?.validateField();
    if (result?.hasError) {
      errorList.push(result);
    }
  }
  return errorList;
};

// 统一处理表单提交
const handleSubmit = (e: Event) => {
  e.stopPropagation();
  e.preventDefault();
  emits('submit', e);
  if (props.model) {
    // 表单提交前 处理所有字段校验
    validateFields()
      .then((errorList) => {
        if (errorList.length > 0) {
          emits('finishFail', errorList);
        } else {
          emits('finish', toRaw(props.model));
        }
      })
      .catch((e) => {
        emits('finishFail', e);
      });
  }
};
</script>

好了,至此我们就实现了一个完整的“受控表单组件”的基本组件内容,更多详细的代码实现细节,你可以查看完整代码案例。

总结

这节课我们主要学习了如何在Vue.js 自研组件库场景下实现“受控表单组件”,有两个掌握重点。

第一个重点“什么是受控组件和非受控组件”,我们了解了“受控组件”的技术概念,同时如何在Vue.js 3.x的框架环境,实现它描述的“受控”能力。

当然,你也要知道,这个技术概念并不是React.js或者Vue.js专有的,任何Web框架,只要能以统一的“数据状态”来代替HTML原生能力管理表单,就可以算是“受控组件”概念的技术实现。

第二个重点“如何抽象表单组件的复用逻辑”。这里你会发现最终抽选出来的逻辑核心就是“表单数据校验”。没错,表单最复杂、最核心的一个逻辑就是“数据校验”,如果以后你遇到要实现表单场景的功能,我希望你把数据校验操作作为首要的技术考虑点。

思考题

表单组件除了劫持代理“submit”事件,还有其它的方式来管理表单提交数据的操作吗?

欢迎你留言参与讨论,如果有疑问也欢迎评论,下一讲见。

完整的代码在这里

精选留言(4)
  • 初烬 👍(2) 💬(1)

    if (formContext?.model && props.name && formContext?.model?.[props?.name]) 这里是不是写错了。如果model['name'],初始化为null 岂不是不执行addField方法?

    2022-12-23

  • ifelse 👍(0) 💬(0)

    学习打卡

    2024-09-09

  • 庄周梦蝶 👍(0) 💬(0)

    感觉难的是各种不满足需求的表单组件,要自己二次封装。比如可编辑列表,作为表单的输入组件,然可编辑列表里面又有各种组件

    2023-02-18

  • ll 👍(0) 💬(2)

    看完思考题突然有个疑问,就是一定要用 form 吗?因为受控表单很重要一点就是劫持代理 submit 事件,那换一个没有原生 submit 事件的元素,像是div,会不会方便点,不知可行不,会不会有意想不到的效果?

    2022-12-22