# 22|表单:如何设计一个表单组件? 你好,我是大圣。 上一讲我们详细讲解了如何使用Jest框架对组件库进行测试,TypeScript和Jest都为我们的代码质量和研发效率保驾护航。之前我们实现的Container和Button组件都是以渲染功能为主,可以根据不同的属性渲染不同的样式去实现布局和不同格式的按钮。 那么今天我再带你实现一个非常经典的表单组件,这个组件除了要渲染页面组件之外,还得支持很好的页面交互,下面我们先从Element3的表单组件开始讲解。 ## 表单组件 在[Element表单组件](https://e3.shengxinjing.cn/#/component/form)的页面里,我们能看到表单种类的组件类型有很多,我们常见的输入框、单选框和评分组件等都算是表单组件系列的。 下面这段代码是Element3官方演示表单的Template,整体表单页面分三层: * el-form组件负责最外层的表单容器; * el-form-item组件负责每一个输入项的label和校验管理; * 内部的el-input或者el-switch负责具体的输入组件。 ```xml 立即创建 重置 ``` 现在我们把上面的代码简化为最简单的形式,只留下el-input作为输入项,就可以清晰地看到表单组件工作的模式:el-form组件使用:model提供数据绑定;使用rules提供输入校验规则,可以规范用户的输入内容;使用el-form-item作为输入项的容器,对输入进行校验,显示错误信息。 ```xml 登录 ``` 然后我们看下rules和model是如何工作的。 这里使用reactive返回用户输入的数据,username和passwd输入项对应,然后rules使用reactive包裹用户输入项校验的配置。 具体的校验规则,现在主流组件库使用的都是async-validator这个库,详细的校验规则你可以访问 [async-validator的官网](https://github.com/yiminghe/async-validator)查看。而表单Ref上我们额外新增了一个validate方法,这个方法会执行所有的校验逻辑来显示用户的报错信息,下图就是用户输入不符合rules配置后,页面的报错提示效果。 ```typescript const ruleForm = reactive({ 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 } }) } ``` ## ![图片](https://static001.geekbang.org/resource/image/41/52/41183d472a7b16f80ed846c58fe43852.png?wh=1688x1074) ## 表单组件实现 那么接下来我们就要实现组件了。我们进入到src/components目录下新建Form.vue去实现el-form组件,该组件是整个表单组件的容器,负责管理每一个el-form-item组件的校验方法,并且自身还提供一个检查所有输入项的validate方法。 在下面的代码中,我们注册了传递的属性的格式,并且注册了validate方法使其对外暴露使用。 ```typescript interface Props { label?: string prop?: string } const props = withDefaults(defineProps(), { 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组件。 ```typescript import { emitter } from "../../emitter" const items = ref([]) emitter.on("addFormItem", (item) => { items.value.push(item) }) ``` 然后el-form-item还要负责管理内部的input输入标签,并且从form组件中获得配置的rules,通过rules的逻辑,来判断用户的输入值是否合法。另外,el-form还要管理当前输入框的label,看看输入状态是否报错,以及报错的信息显示,这是一个承上启下的组件。 ```typescript 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管理提供的类型。 ```typescript import { InjectionKey } from "vue" import { Rules, Values } from "async-validator" export type FormData = { model: Record rules?: Rules } export type FormItem = { validate: () => Promise } export type FormType = { validate: (cb: (isValid: boolean) => void) => void } export const key: InjectionKey = Symbol("form-data") ``` 而下面的代码,我们则通过provide向所有子元素提供form组件的上下文。子组件内部通过inject获取,很多组件都是嵌套成对出现的,provide和inject这种通信机制后面我们还会不停地用到,做好准备。 ```typescript 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对外广播出去即可。 ```xml ``` 最后我们点击按钮的时候,在最外层的form标签内部会对所有的输入项进行校验。由于我们管理着所有的form-item,只需要遍历所有的form-item,依次执行即可。 下面的代码就是表单注册的validate方法,我们遍历全部的表单输入项,调用表单输入项的validate方法,有任何一个输入项有报错信息,整体的校验就会是失败状态。 ```typescript 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 = "" } }) } ``` ## 总结 今天的课程到这就结束了,我们来总结一下今天学到的内容吧。 今天我们设计和实现了一个比较复杂的组件类型——表单组件。表单组件在组件库中作用,就是收集和获取用户的输入值,并且提供用户的输入校验,比如输入的长度、邮箱格式等,符合校验规则后,就可以获取用户输入的内容,并提交给后端。 这一过程中我们要实现三类组件: * 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来实现呢? 期待在评论区看到你的思考,也欢迎你把这一讲分享给你的同事和朋友们,我们下一讲再见!