{T}

设计表单组件

表单组件

常见的表单种类的组件类型有很多,输入框、单选框和评分组件等都算是表单组件系列的。整体表单页面分三层:

  • el-form 组件负责最外层的表单容器
  • el-form-item 组件负责每一个输入项的 label 和校验管理
  • 内部的 el-input 或者 el-switch 负责具体的输入组件

只留下 el-input 作为输入项,就可以清晰地看到表单组件工作的模式

html
<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 是如何工作的。具体的校验规则,现在主流组件库使用的都是 async-validator 这个库,详细的校验规则你可以访问 async-validator 的官网查看。而表单 Ref 上额外新增了一个 validate 方法,这个方法会执行所有的校验逻辑来显示用户的报错信息

javascript
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 方法使其对外暴露使用

javascript
interface Props {
  label?: string
  prop?: string
}

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

const formData = inject(key)

const o: FormItem = {
  validate,
}

defineExpose(o)

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

javascript
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,看看输入状态是否报错,以及报错的信息显示,这是一个承上启下的组件

javascript
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 管理提供的类型

javascript
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 获取,很多组件都是嵌套成对出现的

javascript
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 对外广播出去即可

Vue SFC
<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 方法,有任何一个输入项有报错信息,整体的校验就会是失败状态

javascript
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 非空,就说明校验失败,需要显示对应的错误消息,页面输入框显示红色状态。

javascript
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 = ""
    }
  })
}