Element Plus 组件库相关技术揭秘:15. Form 表单的设计与实现
前言
这一篇,我们将在上一篇文章《14.React 和 Vue 都离不开的表单验证库 async-validator 之策略模式的应用》的基础上继续深入了解 Element Plus 中的 Form 表单 的实现原理。
Form 表单应该是我们使用最多的组件了,所以我们有必要对它的实现原理进行了解。
Element UI 的 Form 组件 结构和 React 技术栈的 antd 的 Form 组件 是很相似的。它们的构成元素都是由 Form 组件、Form-item 组件、表单元素组件(如 Input 组件) 组合而成,然后使用相关表单组件,我们就可以很方便进行收集、验证和提交数据了。
在开始之前我们可以思考以下的问题:
- 为什么表单元素组件,例如 Input 组件 使用
v-model
进行数据绑定之后,还需要在 Form 组件 进行传递一个model
属性对象把整个表单的数据源都传递进去? - 为什么我们在对应的 Form-item 组件上设置了
rules
校验规则后还需要设置prop
属性才能进行校验? - 动态表单组件的时候是怎么验证的?
如果是 React 技术栈过来的同学,肯定对 Element UI 的 Form 表单 组件会有很强烈的以上1,2点的疑惑。
表单设计思路
我们知道表单组件构成元素都是由 Form 组件、Form-item 组件、表单元素组件(如 Input 组件) 组合而成,那么为什么要这么设计呢?
我们在上一篇文章《14.React 和 Vue 都离不开的表单验证库 async-validator 之策略模式的应用》中知道表单验证的实现就是策略模式的典型应用,对应地 Form-item 组件 就是一组验证策略,通常是一个字段的验证策略,一个表单通常需要收集很多数据,那么就需要很多 Form-item 组件,每个 Form-item 组件 相当于一个容器,里面通常装着一个 表单元素组件(如 Input 组件),这样就实现了字段与字段组件策略之间的解耦,还有排版布局的解耦,因为一个 Form-item 组件 就是一组相同的 CSS 样式。每个 表单元素组件 又进行了独立封装,这样每个 表单元素组件 与 Form-item 组件 又进行了解耦,且是零耦合,因为 表单元素组件 可以不依赖 Form-item 组件 和 Form 组件 进行使用。 Form 组件 则相当于一个大容器,装载着所需要收集的数据字段的表单组件,也可以看成是一个大管家,管理着整一个表单的状态与数据的验证,合规了才能进行提交。这些都恰恰印证了我们上一篇文章中说到的那句:因为 JavaScript 这门语言动态的特性使得策略模式在前端应用变得更加丝滑,使代码结构更加优雅,更灵活,更有拓展性。
从数据验证的角度进行设计就是每个 Form-item 组件 能够独立验证自己的字段数据,也就是鼠标触发 blur
或者 change
事件的时候进行验证,最后点击提交的时候再由大管家 Form 组件 进行验证所有的字段数据,本质是将所有的 Form-item 组件 进行校验。
Form 表单验证原理初探
一个最基本的 Form 验证表单代码结构如下:
<el-form :model="form">
<el-form-item label="用户名:" prop="username" :rules="[{ required: true, message: '请输入用户名', trigger: 'blur' }]">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit">提交</el-button>
<el-button>取消</el-button>
</el-form-item>
</el-form>
<script lang="ts" setup>
import { reactive } from 'vue'
const form = reactive({
username: ''
})
const onSubmit = () => {
console.log('submit!')
}
</script>
那么 Form-item 组件 通过 prop 属性传递对应的字段,rules 属性则传递对应的字段验证规则,再通过 async-validator 库这样我们就可以实现验证初始化了。
const validator = new AsyncValidator({
[props.prop]: props.rules,
})
我们通过上一篇文章《14.React 和 Vue 都离不开的表单验证库 async-validator 之策略模式的应用》中知道,我们创建验证实例对象之后,需要调用 validate 方法,需要传递验证的数据源。一个 Form-item 组件 对应的就是一个数据源。从上面的代码中可以看到 Form 组件 需要传递一个 model 字段,这个 model 字段的内容就是所有表单的数据对象。那么对应 Form-item 组件 中的字段值则是 model[props.prop]
简单总结 Form-item 组件 中验证就是如下:
const validator = new AsyncValidator({
[props.prop]: props.rules,
})
validator.validate({ [props.prop]: model[props.prop] })
.then(() => {
// 校验成功
})
.catch((err) => {
// 校验失败
})
提交的时候,就是将所有的 Form-item 组件 遍历校验一遍。
那么接下来我们就先实现 Form-item 组件。
Form 表单基本结构搭建
我们先按照前面的文章《7. 组件实现的基本流程及 Icon 组件的实现》中介绍的组件创建流程先创建 **Form 组件 **的目录结构。
├── packages
│ ├── components
│ │ ├── icon
│ │ │ ├── __tests__ # 测试目录
│ │ │ ├── src # 组件入口目录
│ │ │ │ ├── form-item.ts # Form-item 组件属性与 TS 类型
│ │ │ │ ├── form-item.vue # Form-item 组件模板内容
│ │ │ │ ├── form.ts # Form 组件属性与 TS 类型
│ │ │ │ └── form.vue # Form 组件模板内容
│ │ │ ├── style # 组件样式目录
│ │ │ └── index.ts # 组件入口文件
│ │ └── package.json
接着我们在 components/form/index.ts 文件进行导出 **Form 组件 **和 Form-item 组件。
import { withInstall, withNoopInstall } from '@cobyte-ui/utils'
import Form from './src/form.vue'
import FormItem from './src/form-item.vue'
export const ElForm = withInstall(Form, {
FormItem,
})
export default ElForm
// 通过 withInstall 方法给 FormItem 添加了一个 install 方法
export const ElFormItem = withNoopInstall(FormItem)
export type FormInstance = InstanceType<typeof Form>
export type FormItemInstance = InstanceType<typeof FormItem>
这段代码在本专栏的前面的文章中已经进行了详细讲解了,这里就不进行讲解了。
然后在 form-item.vue 和 form.vue 写上基本的结构代码。
form.vue 文件
<template>
<form><slot /></form>
</template>
<script lang="ts" setup>
defineOptions({
name: 'ElForm',
})
</script>
form-item.vue 文件
<template>
<div><slot /></div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'ElFormItem',
})
</script>
接着我们在 play/main.ts 文件中导入 Form 组件 和 Form-item 组件。
import { ElForm, ElFormItem } from '@cobyte-ui/components/form'
// 组件库
const components = [
// 省略...
ElForm,
ElFormItem,
]
接着我们在 play/src/App.vue 文件写上测试代码:
<template>
<el-form>
<el-form-item label="用户名:">
<el-input v-model="data.username" />
</el-form-item>
<el-button @click="handleSubmit">提交</el-button>
</el-form>
</template>
<script lang="ts" setup>
import { reactive } from 'vue'
const data = reactive({ username: '' })
const handleSubmit = () => {
console.log('handleSubmit')
}
</script>
最后我们通过 pnpm dev
命令启动测试代码,结果如下:
至此我们的 Form 组件 的基本代码结构和测试代码我们就已经搭建完毕了。接着我们先实现能够独立验证的 Form-item 组件,再实现大管家 Form 组件,这样方便大家理解。
Form-item 的基础 props 实现
那么一个最基本的 Form-item 组件 有以下 props 属性。
- prop
model
的键名 - label 标签文本
- rules 表单验证规则
form-item.ts 文件的初始实现代码如下:
import { definePropType } from '@cobyte-ui/utils'
import type { Arrayable } from '@cobyte-ui/utils'
import type { FormItemRule } from '@cobyte-ui/tokens'
export const formItemProps = {
label: String,
prop: String,
rules: {
type: definePropType<Arrayable<FormItemRule>>([Object, Array]),
}
} as const
其中要说明的是 rules 的类型,因为我们的表单验证是通过 async-validator 这个库实现的,所以 rules 类型也就是 async-validator 中 RuleItem 类型,即如下图:
但这个 RuleItem 类型中却没有触发方式的类型,因为我们的规则需要有个触发方式的类型,表示这个校验规则是什么时候需要进行校验的,比如是鼠标离开的时候校验,也就是 blur 类型,或者改变的时候校验,也就是 change 类型。
所以我们需要在 async-validator 的 RuleItem 类型的基础上添加多一个 trigger 类型表示触发条件。
所以我们在 packages/tokens/form.ts 文件中添加一个 FormItemRule 的本地继承 RuleItem 的规则类型。
import type { RuleItem } from 'async-validator'
export interface FormItemRule extends RuleItem {
trigger?: Arrayable<string>
}
然后在 packages/tokens/index.ts 文件中集中导出 packages/tokens/form.ts 中类型。
export * from './form'
又因为 rules 类型可以是一个对象也可以是一个数组。所以我们在 packages/utils/typescript.ts 中创建一个 Arrayable 的工具类型,用于创建给定一个类型然后返回该类型和该类型的数组集合的联合类型。
export type Arrayable<T> = T | T[]
然后在 packages/utils/index.ts 文件中集中导出 packages/utils/typescript.ts 中的工具类型。
definePropType 在是为了方便书写而定义的一个工具函数,主要是从返回的类型中确保一个 props 中的 type 的类型的准确性。在packages/utils/vue/runtime.ts 中进行定义。
import type { PropType } from 'vue'
export const definePropType = <T>(val: any): PropType<T> => val
然后在 packages/utils/index.ts 文件中集中导出 packages/utils/vue/runtime.ts 中的工具函数。
如果我们不使用 definePropType,我们需要想下面这样书写。
export const formItemProps = {
rules: {
type: [Object, Array] as Arrayable<FormItemRule>,
}
}
而使用了 definePropType 则变成下面这样书写。
export const formItemProps = {
rules: {
type: definePropType<Arrayable<FormItemRule>>([Object, Array]),
}
}
这样就可以让类型更加的准确,因为使用断言的话,类型即便不正确也被强行断言成正确的了,而使用了 definePropType 之后,我们从返回的类型中进行确保类型的准确性。
Form-item 的基础 HTML 结构
接着我们完善 form-item.vue 文件的基础代码。
<template>
<div :class="ns.b()">
<label :class="ns.e('label')">
{{ label }}
</label>
<div :class="ns.e('content')">
<slot />
<div :class="ns.e('error')">
{{ validateMessage }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useNamespace } from '@cobyte-ui/hooks'
import { formItemProps } from './form-item'
defineOptions({
name: 'ElFormItem',
})
const props = defineProps(formItemProps)
const ns = useNamespace('form-item')
// 验证错误信息
const validateMessage = ref('')
</script>
我们再看看新的渲染结果:
一个 Form-item 组件的基本结构就是 label 标签 (el-form-item__label
),容器 (el-form-item__content
),验证错误显示 (el-form-item__error
)。
至于如何生成这些 BEM 规范的 class 可以查看本专栏前面的文章《6. CSS 架构模式之 BEM 在组件库中的实践》。样式部分这里不作过多展开,本文只专注功能的实现。
跨组件通讯之 provide、inject 的实践
我们知道 Form-item 最重要的一个功能就是验证数据,也就是 Form-item 会有一个 validate 的函数,但数据是在 Form-item 的后代表单元素组件中产生,例如 Input 组件中产生。那么就要在表单元素组件中使用 Form-item 的验证函数 validate,这就涉及到父子组件之间的通讯问题了,而且 Form-item 组件与表单元素组件并不一定是父子组件,也有可能中间隔了几代,所以又涉及到跨组件之间的通讯了。
那么跨组件的通讯方式有多种方式,其中 Vue3 就提供了 provide
和 inject
。
Form-item 提供的就是一个自定义的上下文对象 context,provide
和 inject
使用的时候还需要提供一个 key,我们可以对这个 key 进行一个封装。我们把它封装在 packages/tokens/form.ts 文件中。
export const formItemContextKey = Symbol('formItemContextKey')
provide
和 inject
的 key 一般通过 Sysmbol
的方式进行声明的。但这样父组件通过 provide
函数注入的数据类型往往是未知的,而子组件调用 inject
引入数据时也无法确定具体的数据类型是什么。于是 Vue 官方提供了 InjectionKey
函数来对传参进行类型约束,确保父子间传递的数据类型是可见、透明的。
所以我们可以提供一个 FormItemContext 的类型,也就是自定义的上下文类型。
export const formItemContextKey: InjectionKey<FormItemContext> =
Symbol('formItemContextKey')
那么这个自定义的上下文我们需要提供些什么内容呢?我们目前可以知道的,这个上下文要包含当前的 props 以及一个验证函数 validate,所以这个类型就是在继承 FormItemProps 基础上再添加一个 validate 函数类型。而这个 validate 函数的类型初步只要一个 trigger 规格触发方式的参数是字符串类型,以及它的返回类型是 Promise<any>
。
代码如下:
export interface FormItemContext extends FormItemProps {
validate(trigger: string): Promise<any>
}
export const formItemContextKey: InjectionKey<FormItemContext> =
Symbol('formItemContextKey')
接着我们在 form-item.vue 文件下继续完善功能:
我们在 form-item.vue 中通过 provide
添加自定义的上下文,那么我们就可以在其后代组件中进行获取了。
我们在 packages/components/input/src/input.vue 中进行获取。
import { formItemContextKey } from '@cobyte-ui/tokens'
const formItem = inject(formItemContextKey, undefined)
接着我们在对应的函数位置触发对应的规则验证。
这样我们就实现了后代组件对祖先组件的互相通讯。
获取 Form-item 校验规则
我们回到 Form-item 的 validate 方法中,当触发 validate 函数的时候,我们就要根据触发的方式去拿到对应的规则。
由于 rules 可以是数组也可以是对象,所以我们需要把它统一处理成数组。
const _rules = computed(() => {
const rules: FormItemRule[] = props.rules
? Array.isArray(props.rules)
? props.rules
: [props.rules]
: []
return rules
})
根据触发的方式获取对应的规则
const getFilteredRule = (trigger: string) => {
// 获取校验规则
const rules = _rules.value
return rules.filter((rule) => {
// 如果规则中没写明触发方式或者 trigger 不存在,这种情况全部校验
if (!rule.trigger || !trigger) return true
if (Array.isArray(rule.trigger)) {
// 如果 trigger 是数组则判断数组中有没有包含当前触发的方式
return rule.trigger.includes(trigger)
} else {
// 如果 trigger 是字符串则判断规则中 trigger 是否相同
return rule.trigger === trigger
}
})
}
在 validate 函数中获取对应的规则
const validate: FormItemContext['validate'] = async (trigger) => {
const rules = getFilteredRule(trigger)
console.log('trigger', trigger, rules)
}
我们在测试代码中写上对应的 blur 和 change 的校验规则:
测试效果如下:
我们可以看到获取到了对应的校验规则了。
拿到校验规则之后,我们就可以通过 async-validator 进行校验了,而 async-validator 校验的时候需要校验的数据源,而我们 Form-item 的对应数据源码要从 Form 组件上的 model 属性进行获取,所以我们接下来需要完善 Form 组件。
Form 表单组件基础实现
Form 组件有两个比较重要的属性 model 和 rules,model 是整个表单的数据源码, rules 则是 key - value 形式的校验规则,key 是需要校验的字段名称,value 则是校验规则。所以基础的 form.ts 文件内容如下:
import { definePropType } from '@cobyte-ui/utils'
import type { FormRules } from '@cobyte-ui/tokens'
export const formProps = {
model: Object,
rules: {
type: definePropType<FormRules>(Object),
},
} as const
而 form.vue 文件内容则如下:
<template>
<form :class="ns.b()"><slot /></form>
</template>
<script lang="ts" setup>
import { provide, reactive, toRefs } from 'vue'
import { useNamespace } from '@cobyte-ui/hooks'
import { formContextKey } from '@cobyte-ui/tokens'
import { formProps } from './form'
defineOptions({
name: 'ElForm',
})
const props = defineProps(formProps)
// BEM 规范,可以看本专栏前面的文章
const ns = useNamespace('form')
// 通过 provide 和 inject 往下传递数据
provide(
formContextKey,
reactive({
...toRefs(props),
})
)
</script>
我们通过上文知道,我们需要在后代组件 Form-item 里面拿到表单的数据源,所以我们可以通过 provide 和 inject 往下传递数据。
合并 Form 与 Form-item 规则
我们的用户有可能在 Form 组件上通过 rules 属性传递了校验规则,同时也在 Form-item 组件上也传递了校验规则,所以我们需要把它们进行合并。
form-item.vue 文件的处理:
我们把校验规则处理抽成一个统一的处理函数 ensureArray,然后再获取 Form 组件的 rules 属性,当 Form 组件的 rules 存在并且 Form-item 的 prop 属性也需要存在,才进行规则合并,在合并 Form 组件的 rules 规则之前,同样需要通过 ensureArray 函数统一处理成数组。
值得注意的是当需要校验字段的时候 Form-item 的 prop 属性也需要存在,也就是为什么我们平时写 Form-item 组件表单的时候需要配置 prop 属性的原因了。
From-item 规则校验的实现
form-item.vue 文件的处理:
我们通过 getFilteredRule 函数根据触发方式参数 trigger 获取到 Form-item 的校验规则,然后获取 Form-item 的 prop 属性,再获取 Form 组件的 model 的数据源,这样通过 AsyncValidator 实例化的初始规则参数就为:{[modelName]: rules}
,实例化之后调用 validator 函数需要验证 Form-item 的数据源则是 { [modelName]: model[modelName] }
, validator 的第二个参数中 firstFields 为 true 表示遇到第一个规则校验失败就不再进行校验了。
然后校验成功我们使用 onValidationSucceeded 函数进行处理,失败则使用 onValidationFailed 进行处理,失败就是把对应的规则上的 message 字段信息赋值给 validateMessage 变量,成功则把 validateMessage 变量赋值为空。
我们给 play/src/App.vue 写上测试代码:
测试效果如下:
至此我们就实现了一个 Form-item 组件 单独验证,也就是鼠标离开表单时和值发生改变时,就是 blur 和 change 事件。
那么接下来我们要实现当点击提交的时候,对全部表单进行校验的功能,也就是 **Form 组件 **的校验功能。
Form 表单校验实现
我们通过前文可以知道 Form 组件 校验的时候是校验所有的 Form-item 组件,其实是初始化的时候将所有的设置有 prop 属性的 Form-item 组件 收集起来。
那么怎么收集起来呢?就是 Form 组件 提供一个 addField 的方法通过 provide API 提供给后代组件,然后在 Form-item 组件 里通过 inject 获取到这个 addField 方法,然后把 Form-item 组件 中创建的上下文 context 对象通过 addFiled 方法传递给 Form 组件,这样 Form 组件 中就有每个 Form-item 组件 的上下文 context 对象了,再通过 Form-item 组件 的上下文 context 对象就可以调用 Form-item 组件 中的 validate 校验函数了,这样就可以在 Form 组件 中对 Form-item 组件 进行校验了。
form.vue 文件修改如下:
接着在 form-item.vue 中添加初始化的代码:
我们可以看到在 Form-item 组件 中,所谓初始化也就是在 onMounted 函数中判断当存在 props.prop 的时候,就通过 Form 组件 提供的 addField 方法把 Form-item 组件 的上下文 context 对象传递过去,而在 **Form-item 组件 **的上下文 context 对象中就存在 Form-item 组件 的 validate 校验方法,这样 Form 组件 中就可以调用 **Form-item 组件 **中的校验了。
接着 Form 组件调用 **Form-item 组件 **中 validate 方法进行校验。
我们可以看到在 Form 组件的 validate 方法中循环通过 addField 方法收集到的 Form-item 组件的上下文,然后再执行 Form-item 组件的上下文中的 validate 方法,并且触发方式参数为空,代表校验所有的规则,这个我们在上文讲 Form-item 组件的校验实现的时候已经讲过了。最后判断错误信息是否为空,如果为空,则代表验证全部通过,否则代表校验失败。
最后我们需要把 Form 组件的 validate 方法暴露出去,这样我们就可以在使用 Form 组件的时候,通过 ref 获取到 Form 组件的实例对象,这样就可以通过 Form 组件的实例对象上的 validate 对全部的表单数据进行验证了。
测试验证代码如下:
<template>
<el-form
ref="formRef"
:model="data"
:rules="{
username: {
min: 6,
max: 10,
message: '用户名长度最少6位且不能超过10位',
trigger: 'change',
},
}"
>
<el-form-item
label="用户名:"
prop="username"
:rules="[
{
required: true,
message: '请输入用户名',
trigger: 'blur',
},
]"
>
<el-input v-model="data.username" />
</el-form-item>
<el-form-item
label="密码"
prop="password"
:rules="[
{
required: true,
message: '请输入密码',
trigger: 'blur',
},
{
min: 6,
max: 18,
message: '密码名必须要大于6位小于18位',
trigger: ['blur', 'change'],
},
]"
>
<el-input v-model="data.password" />
</el-form-item>
<el-button @click="handleSubmit">提交</el-button>
</el-form>
</template>
<script lang="ts" setup>
import { reactive, shallowRef } from 'vue'
import type { FormInstance } from '@cobyte-ui/components/form'
const formRef = shallowRef<FormInstance>()
const data = reactive({ username: '', password: '' })
const handleSubmit = () => {
formRef.value.validate((valid: boolean) => {
if (valid) {
console.log('验证成功')
} else {
console.log('验证失败')
}
})
}
</script>
测试验证结果如下:
至此我们整个表单实现原理的基础部分就实现完成了。
总结
我们在上一篇文章《14.React 和 Vue 都离不开的表单验证库 async-validator 之策略模式的应用》中知道表单验证的实现就是策略模式的典型应用,对应地 Form-item 组件 就是一组验证策略,通常是一个字段的验证策略,一个表单通常需要收集很多数据,那么就需要很多 Form-item 组件,每个 Form-item 组件 相当于一个容器,里面通常装着一个 表单元素组件(如 Input 组件),这样就实现了字段与字段组件策略之间的解耦,还有排版布局的解耦,因为一个 Form-item 组件 就是一组相同的 CSS 样式。每个 表单元素组件 又进行了独立封装,这样每个 表单元素组件 与 Form-item 组件 又进行了解耦,且是零耦合,因为 表单元素组件 可以不依赖 Form-item 组件 和 Form 组件 进行使用。 Form 组件 则相当于一个大容器,装载着所需要收集的数据字段的表单组件,也可以看成是一个大管家,管理着整一个表单的状态与数据的验证,合规了才能进行提交。这些都恰恰印证了我们上一篇文章中说到的那句:因为 JavaScript 这门语言动态的特性使得策略模式在前端应用变得更加丝滑,使代码结构更加优雅,更灵活,更有拓展性。
从数据验证的角度进行设计就是每个 Form-item 组件 能够独立验证自己的字段数据,也就是鼠标触发 blur
或者 change
事件的时候进行验证,最后点击提交的时候再由大管家 Form 组件 进行验证所有的字段数据,本质是将所有的 Form-item 组件 进行校验。
具体就是 Form 组件 提供一个 addField 的方法通过 provide API 提供给后代组件,然后在 Form-item 组件 里通过 inject 获取到这个 addField 方法,然后把 Form-item 组件 中创建的上下文 context 对象通过 addFiled 方法传递给 Form 组件,这样 Form 组件 中就有每个 Form-item 组件 的上下文 context 对象了,再通过 Form-item 组件 的上下文 context 对象就可以调用 Form-item 组件 中的 validate 校验函数了,这样就可以在 Form 组件 中对 Form-item 组件 进行校验了。
最后,前言中提出的 3 个问题,还有最后一个问题还没进行解析,计划在下一篇中进行讲解。
此文章的实现代码仓库:github.com/amebyte/ele…
欢迎关注本专栏,了解更多 Element Plus 组件库知识
本专栏文章:
5. 从终端命令解析器说起谈谈 npm 包管理工具的运行原理
8. 为什么组件库或插件需要定义 peerDependencies
9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现
11. 深入理解组件库中SCSS和CSS变量的架构应用和实践
12. 组件 v-model 的封装实现原理及 Input 组件的核心实现
13. 深入理解 Vue3 的 v-model 及自定义指令的实现原理
转载自:https://juejin.cn/post/7258966810350174263