22|表单:如何设计一个表单组件?

你好,我是大圣。

上一讲我们详细讲解了如何使用Jest框架对组件库进行测试,TypeScript和Jest都为我们的代码质量和研发效率保驾护航。之前我们实现的Container和Button组件都是以渲染功能为主,可以根据不同的属性渲染不同的样式去实现布局和不同格式的按钮。

那么今天我再带你实现一个非常经典的表单组件,这个组件除了要渲染页面组件之外,还得支持很好的页面交互,下面我们先从Element3的表单组件开始讲解。

表单组件

Element表单组件的页面里,我们能看到表单种类的组件类型有很多,我们常见的输入框、单选框和评分组件等都算是表单组件系列的。

下面这段代码是Element3官方演示表单的Template,整体表单页面分三层:

  • el-form组件负责最外层的表单容器;
  • el-form-item组件负责每一个输入项的label和校验管理;
  • 内部的el-input或者el-switch负责具体的输入组件。
<el-form
  :model="ruleForm"
  :rules="rules"
  ref="form"
  label-width="100px"
  class="demo-ruleForm"
>
  <el-form-item label="活动名称" prop="name">
    <el-input v-model="ruleForm.name"></el-input>
  </el-form-item>
  <el-form-item label="活动区域" prop="region">
    <el-select v-model="ruleForm.region" placeholder="请选择活动区域">
      <el-option label="区域一" value="shanghai"></el-option>
      <el-option label="区域二" value="beijing"></el-option>
    </el-select>
  </el-form-item>
  <el-form-item label="即时配送" prop="delivery">
    <el-switch v-model="ruleForm.delivery"></el-switch>
  </el-form-item>
  <el-form-item label="活动性质" prop="type">
    <el-checkbox-group v-model="ruleForm.type">
      <el-checkbox label="美食/餐厅线上活动" name="type"></el-checkbox>
      <el-checkbox label="地推活动" name="type"></el-checkbox>
      <el-checkbox label="线下主题活动" name="type"></el-checkbox>
      <el-checkbox label="单纯品牌曝光" name="type"></el-checkbox>
    </el-checkbox-group>
  </el-form-item>
  <el-form-item label="特殊资源" prop="resource">
    <el-radio-group v-model="ruleForm.resource">
      <el-radio label="线上品牌商赞助"></el-radio>
      <el-radio label="线下场地免费"></el-radio>
    </el-radio-group>
  </el-form-item>
  <el-form-item label="活动形式" prop="desc">
    <el-input type="textarea" v-model="ruleForm.desc"></el-input>
  </el-form-item>
  <el-form-item>
    <el-button type="primary" @click="submitForm('ruleForm')"
      >立即创建</el-button
    >
    <el-button @click="resetForm('ruleForm')">重置</el-button>
  </el-form-item>
</el-form>

现在我们把上面的代码简化为最简单的形式,只留下el-input作为输入项,就可以清晰地看到表单组件工作的模式:el-form组件使用:model提供数据绑定;使用rules提供输入校验规则,可以规范用户的输入内容;使用el-form-item作为输入项的容器,对输入进行校验,显示错误信息。

  <el-form :model="ruleForm" :rules="rules" ref="form">
    <el-form-item label="用户名" prop="username">
      <el-input v-model="ruleForm.username"></el-input>
      <!-- <el-input :model-value="" @update:model-value=""></el-input> -->
    </el-form-item>
    <el-form-item label="密码" prop="passwd">
      <el-input type="textarea" v-model="ruleForm.passwd"></el-input>
    </el-form-item>
    <el-form-item>
      <el-button type="primary" @click="submitForm()">登录</el-button>
    </el-form-item>
  </el-form>

然后我们看下rules和model是如何工作的。

这里使用reactive返回用户输入的数据,username和passwd输入项对应,然后rules使用reactive包裹用户输入项校验的配置。

具体的校验规则,现在主流组件库使用的都是async-validator这个库,详细的校验规则你可以访问 async-validator的官网查看。而表单Ref上我们额外新增了一个validate方法,这个方法会执行所有的校验逻辑来显示用户的报错信息,下图就是用户输入不符合rules配置后,页面的报错提示效果。

const ruleForm = reactive<UserForm>({
  username:"",
  passwd:""
})
const rules = reactive({
  rules: {
    username: { required: true,min: 1, max: 20, message: '长度在 1 到 20 个字符', trigger: 'blur' },
    passwd: [{ required: true, message: '密码', trigger: 'blur' }]
  }
})
function submitForm() {
  form.value.validate((valid) => {
    if (valid) {
      alert('submit!')
    } else {
      console.log('error submit!!')
      return false
    }
  })
}

图片

表单组件实现

那么接下来我们就要实现组件了。我们进入到src/components目录下新建Form.vue去实现el-form组件,该组件是整个表单组件的容器,负责管理每一个el-form-item组件的校验方法,并且自身还提供一个检查所有输入项的validate方法。

在下面的代码中,我们注册了传递的属性的格式,并且注册了validate方法使其对外暴露使用。


interface Props {
  label?: string
  prop?: string
}
const props = withDefaults(defineProps<Props>(), { 
  label: "", 
  prop: "" 
})

const formData = inject(key)

const o: FormItem = {
  validate,
}

defineExpose(o)

那么在 el-form 组件中如何管理el-form-item组件呢?我们先要新建FormItem.vue文件,这个组件加载完毕之后去通知el-form组件自己加载完毕了,这样在el-form中我们就可以很方便地使用数组来管理所有内部的form-item组件。

import { emitter } from "../../emitter"
const items = ref<FormItem[]>([])

emitter.on("addFormItem", (item) => {
  items.value.push(item)
})

然后el-form-item还要负责管理内部的input输入标签,并且从form组件中获得配置的rules,通过rules的逻辑,来判断用户的输入值是否合法。另外,el-form还要管理当前输入框的label,看看输入状态是否报错,以及报错的信息显示,这是一个承上启下的组件。

onMounted(() => {
  if (props.prop) {
    emitter.on("validate", () => {
      validate()
    })
    emitter.emit("addFormItem", o)
  }
})
function validate() {
  if (formData?.rules === undefined) {
    return Promise.resolve({ result: true })
  }
  const rules = formData.rules[props.prop]
  const value = formData.model[props.prop]
  const schema = new Schema({ [props.prop]: rules })
  return schema.validate({ [props.prop]: value }, (errors) => {
    if (errors) {
      error.value = errors[0].message || "校验错误"
    } else {
      error.value = ""
    }
  })
}

这里我们可以看到,form、form-item和input这三个组件之间是嵌套使用的关系:

  • form提供了所有的数据对象和配置规则;
  • input负责具体的输入交互;
  • form-item负责中间的数据和规则管理,以及显示具体的报错信息。
    这就需要一个强有力的组件通信机制,在Vue中组件之间的通信机制有这么几种。

首先是父子组件通信,通过props和emits来通信。这个我们在全家桶实战篇和评级组件那一讲都有讲过,父元素通过props把需要的数据传递给子元素,子元素通过emits通知父元素内部的变化,并且还可以通过defineDepose的方式暴露给父元素方法,可以让父元素调用自己的方法。

那么form和input组件如何通信呢?这种祖先元素和后代元素,中间可能嵌套了很多层的关系,Vue则提供了provide和inject两个API来实现这个功能。

在组件中我们可以使用provide函数向所有子组件提供数据,子组件内部通过inject函数注入使用。注意这里provide提供的只是普通的数据,并没有做响应式的处理,如果子组件内部需要响应式的数据,那么需要在provide函数内部使用ref或者reative包裹才可以。

关于prvide和inject的类型系统,我们可以使用Vue提供的InjectiveKey来声明。我们在form目录下新建type.ts专门管理表单组件用到的相关类型,在下面的代码中,我们定义了表单form和表单管理form-item的上下文,并且通过InjectionKey管理提供的类型。

import { InjectionKey } from "vue"
import { Rules, Values } from "async-validator"

export type FormData = {
  model: Record<string, unknown>
  rules?: Rules
}

export type FormItem = {
  validate: () => Promise<Values>
}

export type FormType = {
  validate: (cb: (isValid: boolean) => void) => void
}

export const key: InjectionKey<FormData> = Symbol("form-data")

而下面的代码,我们则通过provide向所有子元素提供form组件的上下文。子组件内部通过inject获取,很多组件都是嵌套成对出现的,provide和inject这种通信机制后面我们还会不停地用到,做好准备。

provide(key, {
  model: props.model,
  rules?: props.rules,
})

# 子组件
const formData = inject(key)

然后就是具体的input实现逻辑,在下面的代码中,input 的核心逻辑就是对v-model的支持,这个内容我们在评级组件那一讲已经实现过了。

v-mode其实是:mode-value="x"和@update:modelValute两个写法的简写,组件内部获取对应的属性和modelValue方法即可。这里需要关注的代码是我们输入完成之后的事件,输入的结果校验是由父组件el-form-item来实现的,我们只需要通过emit对外广播出去即可。

<template>
  <div
    class="el-form-item"
  >
    <label
      v-if="label"
    >{{ label }}</label>
    <slot />
    <p
      v-if="error"
      class="error"
    >
      {{ error }}
    </p>
  </div>
</template>
<script lang="ts">
export default{
  name:'ElFormItem'
}
</script>

<script setup lang="ts">
import Schema from "async-validator"
import { onMounted, ref, inject } from "vue"
import { FormItem, key } from "./type"
import { emitter } from "../../emitter"

interface Props {
  label?: string
  prop?: string
}
const props = withDefaults(defineProps<Props>(), { label: "", prop: "" })
// 错误
const error = ref("")

const formData = inject(key)

const o: FormItem = {
  validate,
}

defineExpose(o)

onMounted(() => {
  if (props.prop) {
    emitter.on("validate", () => {
      validate()
    })
    emitter.emit("addFormItem", o)
  }
})

function validate() {
  if (formData?.rules === undefined) {
    return Promise.resolve({ result: true })
  }
  const rules = formData.rules[props.prop]
  const value = formData.model[props.prop]
  const schema = new Schema({ [props.prop]: rules })
  return schema.validate({ [props.prop]: value }, (errors) => {
    if (errors) {
      error.value = errors[0].message || "校验错误"
    } else {
      error.value = ""
    }
  })
}
</script>

<style lang="scss">
@import '../styles/mixin';
@include b(form-item) {
  margin-bottom: 22px;
  label{
    line-height:1.2;
    margin-bottom:5px;
    display: inline-block;
  }
  & .el-form-item {
    margin-bottom: 0;
  }
}
.error{
  color:red;
}
</style>

最后我们点击按钮的时候,在最外层的form标签内部会对所有的输入项进行校验。由于我们管理着所有的form-item,只需要遍历所有的form-item,依次执行即可。

下面的代码就是表单注册的validate方法,我们遍历全部的表单输入项,调用表单输入项的validate方法,有任何一个输入项有报错信息,整体的校验就会是失败状态。



function validate(cb: (isValid: boolean) => void) {
  const tasks = items.value.map((item) => item.validate())
  Promise.all(tasks)
    .then(() => { cb(true) })
    .catch(() => { cb(false) })
}

上面代码实际执行的是每个表单输入项内部的validate方法,这里我们使用的就是async-validate的校验函数。在validate函数内部,我们会获取表单所有的ruls,并且过滤出当前输入项匹配的输入校验规则,然后通过AsyncValidator对输入项进行校验,把所有的校验结果放在model对象中。如果errors[0].message非空,就说明校验失败,需要显示对应的错误消息,页面输入框显示红色状态。

import Schema from "async-validator"

function validate() {
  if (formData?.rules === undefined) {
    return Promise.resolve({ result: true })
  }
  const rules = formData.rules[props.prop]
  const value = formData.model[props.prop]
  const schema = new Schema({ [props.prop]: rules })
  return schema.validate({ [props.prop]: value }, (errors) => {
    if (errors) {
      error.value = errors[0].message || "校验错误"
    } else {
      error.value = ""
    }
  })
}

总结

今天的课程到这就结束了,我们来总结一下今天学到的内容吧。

今天我们设计和实现了一个比较复杂的组件类型——表单组件。表单组件在组件库中作用,就是收集和获取用户的输入值,并且提供用户的输入校验,比如输入的长度、邮箱格式等,符合校验规则后,就可以获取用户输入的内容,并提交给后端。

这一过程中我们要实现三类组件:

  • el-form提供表单的容器组件,负责全局的输入对象model和校验规则rules的配置,并且在用户点击提交的时候,可以执行全部输入项的校验规则;

  • 其次是input类组件,我们日常输入内容的输入框、下拉框、滑块等都属于这一类组件,这类组件主要负责显示对应的交互组件,并且监听所有的输入项,用户在交互的同时通知执行校验;

  • 然后就是介于form和input中间的form-item组件,这个组件负责每一个具体输入的管理,从form组件中获取校验规则,从input中获取用户输入的内容,通过async-validator校验输入是否合法后显示对应的输入状态,并且还能把校验方法提供给form组件,form可以很方便地管理所有form-item。

至此,form组件设计完毕,相信你对组件通信、输入类组件的实现已经得心应手了,并且对组件设计中如何使用TypeScript也有了自己的心得。组件设计我们需要考虑的就是内部交互的逻辑,对子组件提供什么数据,对父组件提供什么方法,需不需要通过provide或者inject来进行跨组件通信等等。相信实践过后,你会有更加深刻的理解和认识。

思考题

最后留一道思考题:今天的表单组件在设计上能否通过Vue 2时代流行的event-bus来实现呢?

期待在评论区看到你的思考,也欢迎你把这一讲分享给你的同事和朋友们,我们下一讲再见!

精选留言

  • Geek_fcdf7b

    2021-12-12 19:06:00


    大圣老师,请教一下,3.2版本之后,是不是定义响应式数据都可以用ref一把梭?我看有的文章是这样说的,ref在3.2之后性能进行了大幅度提升,所以建议使用ref,不管简单数据还是复杂数据都可以用ref,没必要用reactive
    作者回复

    现在ref确实是比reactive优先级更高一些,直接建议ref + toRefs一把梭,我碰到必须有reactive的场景的话会写个加餐对比

    2021-12-13 19:32:08

  • 江南烟雨时

    2022-04-22 15:01:22

    感觉还是视频课好一些啊
  • 小胖

    2021-12-12 19:13:33

    我总觉得这章的代码和表述有点对不上。
    比如这段:“然后就是具体的 input 实现逻辑,在下面的代码中,input 的核心逻辑就是对 v-model 的支持,这个内容我们在评级组件那一讲已经实现过了。”
    我的理解之后应该是要贴上Input或者elInput的代码的,可以后面只有FormItem的代码。
    而且,每段代码其实是不是第一行可以添加一个文件名的注释。
    同时,这章节的代码在github也没有看见。
    作者回复

    这里对input的封装并没有做type的判断,为了更好的展示git hook这一节独立一个github的repo,可以看这里 https://github.com/shengxinjing/ailemente/blob/main/src/components/form/Input.vue

    2021-12-13 20:00:08

  • 幺叁叁

    2022-01-26 15:16:39

    大圣老师,有个问题请教下怎么处理:
    ```html
    <el-form :form="form" :rules="rules">
    <el-form-item label="账号" prop="username">
    <el-input v-model="form.username"></el-input>
    </el-form-item>

    <el-form-item label="密码" prop="password">
    <el-input v-model="form.password"></el-input>
    </el-form-item>
    </el-form>
    ```
    在form-item组件中,使用emitter.on 监听了 validate事件;当在账号的输入框中触发validate事件,其他的form-item组件也会触发validate事件,请问下要如何避免呢?

  • 落风

    2022-04-13 17:53:40

    form-item 负责中间的数据和规则管理,以及显示具体的报错信息。
    基于这个描述,rules 配置应该也是放在 FormItem 组件的,而不是在 Form 中通过 provide 传递下来,Form 不需要感知具体某个 FormItem 的规则,只需要感知 FormItem 的校验结果
  • 木子初秋

    2022-03-05 23:04:44

    在FormItem组件中,下面这段代码里面的emitter.on接收的事件有什么用?
    我理解的是FormItem只需要给Form组件传递addFormItem这个事件就可以了,毕竟validate函数执行只需要在Form中运行即可。
    主要的疑惑:emitter.on需要其它组件通过emitter.emit广播了事件,才能获得对应事件,可是Form没有使用emitter.emit广播任何事件。
    onMounted(() => {
    if (props.prop) {
    emitter.on("validate", () => { validate() })
    emitter.emit("addFormItem", o)
    }
    })
  • 郝本坤

    2022-02-09 15:51:52

    mitt 和 event-bus 好像都有一个问题,当input emitter.emit('validate')时,会触发所有监听了validate 的el-form-item。实际我们只想触发父el-form-item的validate,因此这两种方法应该都不是最好的。
  • 余生只有你

    2022-01-25 16:36:10

    如果有多一层的数据结构,那岂不是无法读取value值了
  • 风一样

    2022-01-20 10:04:27

    老师,问一个基础的问题

    export type FormItem = {
    validate: () => Promise<Values>
    }

    这个类型声明中,=> 是什么意思呢?函数返回的类型,应该是“:”啊?
    作者回复

    你好,type类型别名可以使用 type NameResolver = () => string 来定义一个函数类型,直接function需要用function(a:string):string来定义 interface需要使用下面的语法,写法不同
    interface SearchFunc {
    (source: string, subString: string): boolean;
    }

    2022-01-20 13:33:04

  • 喵喵酱~

    2021-12-27 14:54:17

    大圣老师你好,请教一个问题:element3 表单验证组件中,有一个现象,一个必填项鼠标focus再blur后,并没有提示“请输入xxx”, 而是在点击确定后统一提示的错误信息,统一提示错误信息后再在必填项里输入数据,此时的必填项错误信息也没有消失。我是在你的文档里测试的。elementui就不是这样
    作者回复

    我看下,看来是element3的bug

    2022-01-16 22:20:01

  • 酱汁

    2021-12-12 17:04:00

    有该demo的源码吗老师
    作者回复

    https://github.com/shengxinjing/ailemente和https://github.com/hug-sun/element3 可以看到

    2021-12-13 19:58:31

  • 180620

    2021-12-11 10:14:15

    import { emitter } from "../../emitter"
    这段断码的意思是什么
    作者回复

    这里我引入了emitter,是一个event-bus的实现,可以看这里https://github.com/shengxinjing/ailemente/blob/main/src/emitter.ts

    2021-12-13 20:01:02

  • Johnson

    2021-12-10 07:53:23

    思考题:可以实现,但是不太优雅!
  • 海阔天空

    2021-12-10 07:44:27

    表单组件在设计在element中实现原理是通过 Vue 2 时代流行的 event-bus 来实现的,不过他们自己封装了emit和watch方法。vue3这实现更方便更简单了啊。
    作者回复

    其实复杂的也得自己封装一下 只不过vue3没有内置了

    2021-12-10 15:14:01