Element Plus 组件库相关技术揭秘:14. React 和 Vue 都离不开的表单验证库 async-validator 之策略模式的应用
前言
目前社区中的开源组件库表单模块的验证,基本都是采用了 async-validator 这个库。其中比较受欢迎的 Vue 和 React 的两大阵营的开源 UI 库,antd 和 Element UI 都是使用了 async-validator 这个库,其中 antd 的表单组件底层是引用了 rc-field-form,而 rc-field-form 中的表单验证就是使用了 async-validator,受到尤雨溪推荐的 Vue3 开源 UI 库 Naive UI 的表单校验也是采用了 async-validator。那么 async-validator 到底有什么魔力让这么多开源 UI 库都使用它呢?
所以想要了解表单校验的背后原理,需要先了解 async-validator 的原理。而 async-validator 的实现就是策略模式的典型应用,所以我们还需要对策略模式进行了解,同时通过 async-validator 库原理的理解,从而加深对策略模式的掌握。策略模式应该是前端应用最广泛的一种设计模式,是因为 JavaScript 这门语言动态的特性使得策略模式在前端应用变得更加丝滑,使代码结构更加优雅,更灵活,更有拓展性,再配合闭包的特性进行使用,有些时候一些巧妙的设计,真的让人叹为观止,同时也让人感叹 JavaScript 这门语言的魔力。
我们在学习一个东西的原理,其实就是了解它背后所应用的一系列技术。
什么策略模式?
策略模式(Strategy Pattern)指的是定义一系列的算法,分别将它们进行封装,目的就是将算法的使用与算法的实现分离开来。 本质上就是对我们的代码进行解耦。
比如我们订单当中有很多种状态:进行中、已完成、已关闭。现在我们需要根据不同的状态显示不同的状态名称,那么通常我们可能会通过 if-else
进行判断不同状态类型值,显示不同的状态名称。
const getStatusText = status => {
if (status === 1) {
return '进行中'
} else if (status === 2) {
return '已完成'
} else if (status === 3) {
return '已关闭'
}
}
上述这段代码,未来如果我们还需要增加不同状态的话,我们需要在 getStatusText 方法中继续添加 else if
。
这样一来就违反了两个基本原则:
- 单一职责原则:因为之后修改任何一个状态,当前方法都需要进行修改。
- 开闭原则,也就是对拓展开发,对修改关闭。很明显上述代码中,再进行添加或删除某个状态,也需要修改当前的方法。
当然因为上述的代码逻辑相对比较简单,修改起来也比较容易,但如果 if else
模块中的代码量比较大的时候,后续的修改和添加则变得比较困难了,而且容易出错。这时我们就需要使用策略模式了,策略模式就是为了消除 if else
而生的。
而对于前端开发者来说,由于 JavaScript 这门语言本身的动态属性,使得策略模式的实现更加简单。可以使用一个对象专门用来维护这些对应的方法事件策略,在上述的例子中则可以使用一个对象来维护对应的状态名称。
const STATUS = {
1: "进行中",
2: "已完成",
3: "已关闭"
}
const getStatusText = status => {
return STATUS[status]
}
// 调用
getStatusText(1)
这样可以在不修改 getStatusText
函数原代码的情况下,灵活增加新的状态。比如我们需要增加一个 已支付
的状态,只需要在 STATUS 策略对象中增加一行代码:4: "已支付"
即可。
const STATUS = {
// ...
4: "已支付"
}
// 调用
getStatusText(4)
如果我们将上述不同状态名称看作是不同的逻辑策略的话,我们通过策略模式就实现了策略与业务解耦,并且我们可以独立维护这些策略,为业务带来更灵活的变化。
const STATUS = {
pending: () => {
// 进行中的算法部分
return "进行中"
},
done: () => {
// 已完成的算法部分
return "已完成"
},
closed: () => {
// 已关闭的算法部分
return "已关闭"
}
}
const getStatusText = status => {
return STATUS[status]()
}
// 调用
getStatusText("pending")
从我们上面这个例子可以很清晰地总结出策略模式的目的就是将算法的使用与算法的实现分离开来。
策略模式是程序设计模式中的一种,所谓设计模式,其实就是一种编程的思想或者方法论,并没有唯一的实现方式。
接下来我们将通过表单验证进行策略模式的深度应用,从而让我们更加深刻地掌握策略模式的精髓。
过程式的表单验证
例如我们有以下一个表单,我们必须要输入用户名和密码,而且对密码的长度还需要进行校验,只有符合条件的时候,才允许提交。并且需要在它们各种输入框的光标失焦的时候也进行校验。
<template>
用户名:<el-input v-model="username" type="text" @blur="handleUsernameBlur" />
密码:<el-input v-model="password" type="password" @blur="handlePasswordBlur" />
<el-button @click="handleSubmit">提交</el-button>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const username = ref('')
const password = ref('')
const handleSubmit = () => {
if (username.value === '') {
alert('请输入用户名')
return
} else if (password.value === '') {
alert('请输入密码')
return
} else if (password.value.length < 6 || password.value.length > 18) {
alert('密码长度必须大于6位小于18位')
return
}
}
const handleUsernameBlur = () => {
if (username.value === '') {
alert('请输入用户名')
}
}
const handlePasswordBlur = () => {
if (password.value === '') {
alert('请输入密码')
} else if (password.value.length < 6 || password.value.length > 18) {
alert('密码长度必须大于6位小于18位')
}
}
</script>
我们可以看到上述代码显得非常的冗赘,维护起来会很困难,也就是我们上面说到的,后续的修改和添加都会变得比较困难,而且容易出错。而且我们继续增加不同的字段的话,迭代的代码会越来越大而且混乱,并且这种情况很容易出现冗余代码 。
其实每一个验证都是互相独立的, 我们就可以分别对它们进行封装成一个函数,这样我们针对相同的情况只需维护一套验证算法即可。
我们对上述代码使用组合函数重构之后的代码如下:
<template>
用户名:<el-input v-model="username" type="text" @blur="handleUsernameBlur" />
密码:<el-input
v-model="password"
type="password"
@blur="handlePasswordBlur"
/>
<el-button @click="handleSubmit">提交</el-button>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const username = ref('')
const password = ref('')
// 封装用户名的验证函数
const usernameValidator = () => {
if (username.value === '') {
alert('请输入用户名')
return false
}
return true
}
// 封装密码的验证函数
const passwordValidator = () => {
if (password.value === '') {
alert('请输入密码')
return false
} else if (password.value.length < 6 || password.value.length > 18) {
alert('密码长度必须大于6位小于18位')
return false
}
return true
}
// 提交
const handleSubmit = () => {
if (usernameValidator() && passwordValidator()) {
alert('提交成功')
} else {
alert('提交失败')
}
}
const handleUsernameBlur = () => {
return passwordValidator()
}
const handlePasswordBlur = () => {
return passwordValidator()
}
</script>
我们可以看到改造后的代码比第一次实现的代码,维护起来要方便多了。如果后续我们需要对用户名或者密码的验证进行修改,我们只需要在对应的验证函数中修改即可。但即使是这样对于我们整个表单的验证来说,还是不够通用,比如我们上面的判断是否为空,就在用户名和密码的验证函数中重复书写了。而且后续我们继续增加字段,比如添加手机或者邮箱的字段,我们还是需要对整个代码块进行修改,比如还是需要对 handleSubmit
函数进行修改,才可以进行验证新增的手机或者邮箱字段。所以我们还是需要设计一个通用的验证逻辑,我们希望后续添加字段之后,只需要添加对应的字段验证策略逻辑即可,而不用修改 handleSubmit
函数。而这就是策略模式要做的事情。
设计验证策略
根据上文对策略模式的介绍,我们可以使用一个对象来维护对应的字段验证策略。
const rules = {
username() {
if (username.value === '') {
alert('请输入用户名')
return false
}
},
password() {
if (password.value === '') {
alert('请输入密码')
return false
} else if (password.value.length < 6 || password.value.length > 18) {
alert('密码长度必须大于6位小于18位')
return false
}
},
}
在 handleSubmit
函数中调用全部的验证策略。
const handleSubmit = () => {
const errors = []
Object.keys(rules).forEach(key => {
// 分别调用不同的验证策略,获得验证结果
const result = rules[key]()
errors.push(result)
})
if (errors.includes(false)) {
alert('提交失败')
} else {
alert('提交成功')
}
}
失焦函数中的调用如下:
const handleUsernameBlur = () => {
rules.username()
}
const handlePasswordBlur = () => {
rules.password()
}
这个时候,我们就会发现我们的代码已经实现了解耦了,策略实现和策略调用都进行了分离。后续如果我们再增加字段的验证策略,只需要在策略对象中添加对应的字段验证逻辑函数即可,而不再需要对 handleSubmit
函数进行修改。
我们上述代码已经初步具备策略模式的思想了,但还没完全可以实现通用,例如我们的策略组对象 rules 还是一个全局对象,在代码的各个角落被引用着,这还没充分实现解耦。又比如还不能在 React 中使用。所以我们还需要继续使用策略模式进行重构我们的代码。
实现通用的验证策略类
策略模式的目的就是将算法的使用与算法的实现分离开来。在上述表单验证的过程中,验证策略的调用方式是不变的,变化的是不同字段的验证策略。一个基于策略模式的程序至少由两部分组成,第一部分是策略组,第二部分是调用策略的类或方法。调用策略的类会根据用户的请求分别调用策略组中策略进行执行。
通过上述分析,我们可以得出调用策略的类需要实现的功能。首先需要存储用户定义的策略,再有一个可以给用户调用具体策略的方法。
最后我们可以设计一个如下的调用策略的类:
// 调用策略的类
class Schema {
rules = null
constructor(descriptor) {
this.define(descriptor)
}
// 存储策略
define(rules) {
this.rules = rules
}
// 调用策略,参数 keys 则需要验证的字段的数组
validate(keys) {
const errors = []
keys.forEach((key) => {
// 执行策略获得返回结果
const result = this.rules[key]()
errors.push(result)
})
// 如果存在 false 则返回 false
if (errors.includes(false)) {
return false
}
return true
}
}
调用执行则如下:
// 初始化的时候添加策略组,由 Schema 类内部进行存储
const validator = new Schema(rules)
const handleSubmit = () => {
if (validator.validate(['username', 'password'])) {
alert('提交成功')
} else {
alert('提交失败')
}
}
const handleUsernameBlur = () => {
validator.validate(['username'])
}
const handlePasswordBlur = () => {
validator.validate(['password'])
}
通过上述代码我们可以看到我们先通过 new 一个 Schema 类来实例化一个验证实例对象,并且在初始化的时候将我们定义好的验证策略传递给 Schema 类,Schema 类内部会把验证策略存储起来。
然后我们再通过验证实例对象的 validate 方法启动具体字段的验证,可以是启动一个字段的验证,也可以是全部的字段验证,具体可以通过参数进行配置。我们会根据 validate 方法的返回的布尔值判断是否通过了验证。
实现通用验证
我们在上述的实现策略组的验证,还没实现字段值与策略的解耦,也就是我们在策略中对字段验证,其中字段的变量是全局的,我们需要把它进行解耦。
我们期望我们传递什么字段值给验证实例对象的 validate 方法就调用什么字段的验证策略进行验证。调用方式如下:
validator.validate({'username': 'user', 'password': '123456'})
这样我们上述的调用执行代码则需要改成如下:
// 初始化的时候添加策略组,由 Schema 类内部进行存储
const validator = new Schema(rules)
const handleSubmit = () => {
// 传递什么字段值就验证什么字段
if (validator.validate({username: username.value, password: password.value}) {
alert('提交成功')
} else {
alert('提交失败')
}
}
const handleUsernameBlur = () => {
validator.validate({username: username.value})
}
const handlePasswordBlur = () => {
validator.validate({password: password.value})
}
验证策略中的字段值我们希望是通过内部传值实现,具体改动如下:
const rules = {
username(value) {
if (value === '') {
alert('请输入用户名')
return false
}
},
password(value) {
if (value === '') {
alert('请输入密码')
return false
} else if (value.length < 6 || value.length > 18) {
alert('密码长度必须大于6位小于18位')
return false
}
},
}
我们验证策略中需要验证的字段值是通过回传进行调用的,这样我们就达到了完全解耦的目的了。
那么要实现如上效果,我们只需要把验证策略调用类的 validate 方法做如下修改即可。
// 调用策略,source_ 是需要验证的数据源
validate(source_) {
const source = source_
const errors = []
// 关键修改
Object.keys(source_).forEach(key => {
// 执行策略,并且把对应的字段值传递给对应的验证策略方法
const result = this.rules[key](source[key])
errors.push(result)
})
// 如果存在 false 则返回 false
if (errors.includes(false)) {
return false
}
return true
}
我们在验证策略调用类的 validate 方法中根据传递进来的验证的数据源 source_
参数进行循环调用需要验证的策略,并且把对应的字段值传递过去。至此我们使用策略模式改造的表单验证的代码就变得非常的解耦了。
字段策略中的规则
我们在上述的表单的密码验证中,我们还存在两个 if else
,一个是判断是否为空,一个是判断长度,这两个分别属于不同的验证逻辑,等于是两个不同的验证策略,所以我们还可以继续进行策略模式改造。
const rules = {
username: {
validator(value: string) {
if (value === '' || value === undefined || value === null) {
alert('请输入用户名')
return false
}
},
},
password: [
{
validator(value: string) {
if (value === '' || value === undefined || value === null) {
alert('请输入密码')
return false
}
},
},
{
validator(value: string) {
if (value.length < 6 || value.length > 18) {
alert('密码长度必须大于6位小于18位')
return false
}
},
},
],
}
改造后的字段策略配置可以是一个对象,也可以是一个数组,因为一个字段可能存在多个验证策略规则。
这时我们需要对调用策略的类 Schema 中的存储策略的方法 define 进行修改,修改之后的代码如下:
// 存储策略
define(rules) {
this.rules = {}
Object.keys(rules).forEach((name) => {
const item = rules[name]
// 将所有的字段策略都设置成数组类型
this.rules[name] = Array.isArray(item) ? item : [item]
})
}
我们所进行的改造就是将所有的字段策略都设置成数组类型。那么这个时候我们再怎么进行调用策略验证函数,和执行的时候怎么把对应的字段值传递进去呢?我们就要对我们的程序架构作出重构调整。
首先我们需要统一需要遍历的数据结构,因为在一个表单结构里面会有很多字段,而验证的时候,有可能是验证全部的字段,也有可能是验证其中一个字段,需要验证的数据源我们上面已经确定是通过 validate 方法进行传参。如果我们以需要验证的数据源作为遍历的对象的话,相对而言不好处理,比如有一个字段是必填的,但在需要验证的数据源中有可能不存在,所以我们对比之后以策略对象为遍历对象,也就是有哪些策略,我们就验证哪些策略。
那么以策略对象作为遍历对象之后,我们的工作流程就清晰起来了,也就是先遍历策略对象的字段,再遍历字段中的策略数组,再执行具体策略规则对象的 validator 函数。
那么现在的难点在于执行规则对象的 validator 函数时怎么把对应的字段值传递过去。那么要达到这个目标我们希望把策略对象进行改造,把对应的字段值都和规则对象绑在一起:
const rules = {
password: [
{
rule: {
validator(value: string) {
if (value === '' || value === undefined || value === null) {
alert('请输入密码')
return false
}
},
value: '' // 对应的字段值
}
}
]
}
我们期待把策略对象的数据结构改成上述代码的样子,那么我们需要进行以下处理:
// 调用策略
validate(source_) {
const source = source_
const errors = []
// 最终保存验证数据的集合
const series = {}
const keys = Object.keys(this.rules)
keys.forEach((z) => {
// 字段中验证规则数组
const arr = this.rules[z]
// 对应的字段值
const value = source[z]
arr.forEach((r) => {
const rule = r
series[z] = series[z] || []
// 为每个验证策略配置对应的上下文内容,从而可以获取验证的规则,验证字段的值
series[z].push({
rule,
value,
})
})
})
console.log('series', series)
// ...
}
}
series 数据结构如下:
最终调用策略函数 validate 的代码将分成两部分,第一部分是处理验证数据与验证规则的结合,第二部分是循环调用验证规则进行验证。
修改如下:
// 调用策略
validate(source_) {
const source = source_
const errors = []
/** 第一部分是处理验证数据与验证规则的结合 start */
// 最终保存验证数据的集合
const series = {}
const keys = Object.keys(this.rules)
keys.forEach((z) => {
// 字段中验证规则数组
const arr = this.rules[z]
// 对应的字段值
const value = source[z]
arr.forEach((r) => {
const rule = r
series[z] = series[z] || []
// 为每个验证策略配置对应的上下文内容,从而可以获取验证的规则,验证字段的值
series[z].push({
rule,
value,
})
})
})
/** 第一部分是处理验证数据与验证规则的结合 end */
/** 第二部分是循环调用验证规则进行验证 start */
const objArrKeys = Object.keys(series)
// 遍历执行验证字段策略中的策略
objArrKeys.forEach((key) => {
const arr = series[key]
arr.forEach((a: any) => {
const rule = a.rule
const result = rule.validator(a.value)
errors.push(result)
})
})
/** 第二部分是循环调用验证规则进行验证 end */
// 如果存在 false 则返回 false
if (errors.includes(false)) {
return false
}
return true
}
}
最终实现效果如下:
我们目前实现的代码是所有的验证策略都会进行执行的,这就会带来一个问题,当我们只想验证其中一个字段时,它还是会触发其他字段的验证。比如用户名输入框失焦的时候只需要进行用户名字段的校验。
const handleUsernameBlur = () => {
validator.validate({ username: username.value })
}
我们当初设计就是我们传入什么字段的内容就校验什么字段。但现在我们想只校验用户名字段的时候,它就报错了。
这里的报错是因为它同时校验了密码字段,而密码字段我们是没有传值的,所以密码字段是 undefined,而我们在校验密码字段的时候是需要读取它的长度的,因为密码字段不存在所以报错。
所以我们希望在执行规则函数的时候,去判断需要校验的数据源中有没有当前字段,这样一来,我们就再需要在执行规则函数的时候传递当前的规则字段和需要校验的数据源。
所以我们还需要对调用策略的方法 validate 函数的代码进行以下修改:
接着我们需要在执行规则函数的时候,去判断需要校验的数据源中有没有当前字段,规则函数相应的修改如下:
我们通过 hasOwnProperty 方法在执行校验策略之前判断需要校验的数据源中有没有对应的校验字段,如果没有则不进行校验。
hasOwnProperty 方法是用来判断一个对象是否有你给出名称的属性或对象。不过需要注意的是,此方法无法检查该对象的原型链中是否具有该属性,该属性必须是对象本身的一个成员。
实现错误信息收集
现在我们希望通过以下方式对验证策略进行调用:
validator.validate({ username: username.value }, (errors) => {
if (errors.length) {
alert(errors[0])
}
})
我们希望通过 validate 的函数的第二个参数进行传递一个回调函数,回调函数的参数 errors 就是验证结果的错误信息。
接着我们对 validate 函数进行如下修改:
我们原来是执行 validate 函数之后返回一个布尔值进行判断是否验证成功,现在我们通过执行一个回调函数,并把收集到的验证错误信息传递回去。
接着我们对验证策略对象中的策略函数进行相应的修改:
这样我们就实现了对验证错误信息的收集:
实现异步验证
因为有可能我们需要在一些验证中进行请求后端进行校验,这样一来我们的校验需要实现异步验证了。这其实就相当于我们有很多 HTTP 的请求,我们需要把每个 HTTP 的请求结果都收集起来进行返回。
那么结合我们的验证业务需求,我们是先循环需要验证的字段策略,再循环字段中的所有规则数组。我们需要等待所有的验证都完成后才能将结果返回,我们可以使用 Promise 进行实现异步处理,我们这里先可以使用计算器的方式进行判断是否已经完成所有的验证。
修改后的 validate 函数代码如下:
// 调用策略
validate(source_: any, callback?: any) {
const source = source_
const errors = [] as string[]
// 最终保存验证数据的集合
const series = {} as any
const keys = Object.keys(this.rules)
keys.forEach((z) => {
// 字段中验证规则数组
const arr = this.rules[z]
// 对应的字段值
const value = source[z]
arr.forEach((r: any) => {
const rule = r
// 在规则中添加对应的字段记录
rule.field = z
series[z] = series[z] || []
// 为每个验证策略配置对应的上下文内容,从而可以获取验证的规则,验证字段的值
series[z].push({
rule,
value,
source, // 添加需要校验的数据源
})
})
})
return new Promise<void>((resolve, reject) => {
const objArrKeys = Object.keys(series)
const objArrLength = objArrKeys.length
// 需要验证的字段总数
let total = 0
// 遍历执行验证每一个字段策略
objArrKeys.forEach((key) => {
const arr = series[key]
// 每个字段需要验证的策略总数
let arrTatal = 0
// 遍历字段策略中的策略
arr.forEach((a: any) => {
const rule = a.rule
function cb(e = []) {
arrTatal++
if (arrTatal === arr.length) {
// 等待每个字段策略中策略全部验证完毕再计算下一个字段的验证
total++
}
const errorList = Array.isArray(e) ? e : [e]
errors.push(...errorList)
// 当 total === objArrLength 的时候就是字段策略循环验证完毕的时候
if (total === objArrLength) {
// 同时执行回调函数,兼容不同的写法需求
callback && callback(errors)
// 如果存在错误则 reject 错误信息,否则就 resolve 表示成功
errors.length ? reject(errors) : resolve()
}
}
// 执行校验策略函数的时候把对应的规则和需要校验的数据源也传递过去
rule.validator(rule, a.value, cb, a.source)
})
})
})
}
我们先返回一个 Promise,然后在 Promise 中进行循环执行各个字段中的规则函数,每执行一个规则策略函数 rule.validator()
的时候,就相当于进行了一次 HTTP 请求,请求的结果就是验证的返回信息。当全部字段验证完毕的时候,我们再去判断是否存在错误验证信息,没有则执行 resolve 方法代表验证通过,否则执行 reject 方法,并把错误信息也进行返回,代表验证失败。
我们知道同时发出很多 HTTP 请求的时候,HTTP 的响应是有先后顺序的,我们怎么知道所有的响应都已经完毕了呢?我们在上文已经提到了使用计算器的方式进行判断,具体操作如下。
我们将字段验证进度的计算变量设置为 total,初始值为 0,当完成一个字段的验证 total 的值就增加 1。当 total 的值等于需要验证的字段数量时,则把验证的结果返回。
我们把字段中策略验证进度的计算变量设置为 arrTatal,初始值为 0,当完成一个字段策略的验证 arrTatal 的值就增加 1。当 arrTatal 的值等于当前验证字段的策略数量时,则代表完成了一个字段的验证,所以需要把 total 的值增加 1。
由于修改后的 validate 函数返回的是一个 Promise,所以我们可以进行以下方式进行调用 validate 函数了。
const validator = new Schema(rules)
const handleSubmit = () => {
validator
.validate({ username: username.value, password: password.value })
.then(() => {
alert('提交成功')
})
.catch((errors) => {
console.error(errors)
alert('提交失败')
})
}
而这种调用方式正是我们 Element Plus 中使用的方式:
上图来自 Element Plus 的源码截图。
重构异步验证
我们上一小节中实现的异步验证的那一坨代码,其实职责是很不清晰的,也不利于后续的维护,所以我们需要对它进行重构。软件应该是“自描述”的,代码除了给机器看之外,也要给人看。我们希望写的代码更易读,让代码可以更好地表达自己的意图。
我们上一小节中实现异步验证的那一坨代码中,首先是职责不清晰,其次是变量 errors、total 和 arrTatal 在此模块中相当于是全局变量,在底下的模块中任何角落都可以对它们进行随意修改,整个代码结构显得非常松散。
提炼函数
首先职责不清,我们可以通过提炼函数,通过函数名称来知道我们的程序的业务结构,而提炼函数这个方法是《重构》这本书中介绍的一种代码重构手段。我们把实现异步的代码进行提取,封装成一个叫 asyncMap 的函数,表明这是一个处理异步业务逻辑的函数。修改后的代码如下:
这一次重构,我们原来 errors 的变量的作用域是整个 validate 函数的作用域的缩小到了只在 asyncMap 函数内了。此外 validate 函数的结构也变得瘦小了,结构也更清晰了。
重构后的 validate 函数:
这时大家肯定发现按照我们上面说的重构方法与逻辑,我们的 asyncMap 函数还是职责不够清晰,所以我们继续对 asyncMap 函数进行重构。
首先我们通过常规的重构手法,提炼函数,让修改看得见,具体就是把对字段验证进度和字段策略验证进度都分别进行封装成不同的函数。
现在我们通过提炼函数把对字段验证进度和字段策略验证进度都分别进行封装成不同的函数,这样我们的代码结构也进一步得到了优化。接下我们还继续进一步优化。
从业务角度来说,我们有并行校验规则,也就是我们目前的实现的代码,等待所有的规则都校验完成后才进行返回结果,但我们还有串行校验规则,就是有错就中断后面的规则校验,马上返回结果。
我们主要遍历循环并执行字段策略函数,第一次遍历的是字段策略,第二次遍历的是字段中的规则数组,那么从单一职责上来说我们需要让一个函数做尽可能少的事情。再者从重构手法上来说,我们需要把一些全局变量迁移封装到一个类或模块中,只允许模块内的代码使用它,从而尽量控制其作用域。
我们先把我们目前实现的代码进行封装成一个并行校验规则的函数。
/**
* 并行校验规则
* @param arr 字段策略数组
* @param callback 计算字段验证进度函数回调
*/
function asyncParallelArray(arr: any, callback: any) {
// 每个字段需要验证的策略总数
let tatal = 0
const results = [] as any
// 计算字段策略的验证进度
const count = (error: any) => {
results.push(...(error || []))
tatal++
// 等待每个字段策略中策略全部验证完毕再计算下一个字段的验证
if (tatal === arr.length) {
callback(results)
}
}
// 遍历字段策略中的策略
arr.forEach((a: any) => {
const rule = a.rule
function cb(e = []) {
const errorList = Array.isArray(e) ? e : [e]
count(errorList)
}
// 执行校验策略函数的时候把对应的规则和需要校验的数据源也传递过去
rule.validator(rule, a.value, cb, a.source)
})
}
异步验证函数 asyncMap 中的调用 asyncParallelArray 函数:
此次重构完之后异步验证函数 asyncMap 的职责也变得非常单一了,就只是计算字段的验证进度然后返回对应的结果。
继续优化并行校验规则函数 asyncParallelArray。asyncParallelArray 函数所做的事情就是遍历的是字段中的策略并执行规则验证函数,我们上面提到我们还有一个顺序校验规则,可以预想到的是在这两个函数中我们都需要执行字段中的策略验证函数。那么这两块是重复的代码,所以我们需要把它进行提炼函数以达到复用的目的。
提炼验证每一个规则的函数
// 验证每一个规则的函数
function sigleValidator(data: any, doIt: any) {
const rule = data.rule
function cb(e = []) {
const errorList = Array.isArray(e) ? e : [e]
doIt(errorList)
}
// 执行校验策略函数的时候把对应的规则和需要校验的数据源也传递过去
rule.validator(rule, data.value, cb, data.source)
}
值得注意的是,sigleValidator 函数我们并没有将它和 asyncMap 函数和 asyncParallelArray 函数那么放到外面,而是把它放在 validate 函数中,之后当成一个参数进行传递,最后在 asyncParallelArray 函数中进行调用。
在 asyncMap 函数中当参数传递。
最终在 asyncParallelArray 函数进行调用。
把 sigleValidator 函数放在 validate 函数中当成参数传递的好处是,sigleValidator 函数可以访问到 validate 函数中的变量,这样可以达到减少参数传递的目的。这一巧妙设计,是不是很让人叹为观止呢?这就是闭包函数妙用,也是 JavaScript 这门语言的动态特性,函数是一等公民。
通过配置实现验证
我们知道平时使用 Element UI 的表单验证的时候,很多时候我们只需进行以下的配置即可:
const rules = {
username: {
required: true,
message: '请输入用户名',
}
}
这其中的原理也很简单,我们上文是通过字段规则上的 validator 函数进行验证的,现在没有了 validator 函数,那么我们就需要给它一个默认的 validator 函数,事实上 async-validator 默认提供了不同类型的 validator 函数,通过 type 字段进行配置,这其实也是一个策略模式的应用。也就是如下:
const rules = {
username: {
type: "string"
required: true,
message: '请输入用户名',
}
}
那么我们就需要在对应程序中进行获取对应的验证策略的函数,具体如下:
getValidationMethod 函数内容如下:
字符串验证策略函数和默认验证策略函数组设置如下:
// 字符串验证策略函数
const string = (rule: any, value: any, callback: any, source: any) => {
const errors = []
// 存在 required 或者不存在 required 但对应的字段有值的情况下都需要进行验证
const validate =
rule.required ||
(!rule.required && Object.prototype.hasOwnProperty.call(source, rule.field))
if (validate) {
if (
!rule.required &&
(value === undefined || value === null || value === '')
) {
// 如果不存在 required 并且对应的字段值为空则通过验证
return callback()
} else if (
rule.required &&
(!Object.prototype.hasOwnProperty.call(source, rule.field) ||
value === undefined ||
value === null ||
value === '')
) {
// 如果存在 required 并且对应的字段值为空则不通过验证
errors.push('请输入内容')
}
}
callback(errors)
}
// 默认验证策略函数组,还有诸如 object、array 等类型
const validators = { string } as any
我们可以看到验证策略函数组 validators 的设置和我们文章开头介绍的策略模式在 JavaScript 这门语言中的应用是一致的。验证策略函数组 validators 对象中设置保存了很多默认的策略验证函数,然后再通过 validators[type]
形式动态获取,这样就可以根据不同的参数调用不同的验证函数了。
Form 表单验证初探
我们了解了 async-validator 库的基本原理之后,我们对 Element Plus 中的 Form 表单验证进行理解就比较容易了。
我们知道表单的构成元素主要有 form 组件、form-item 组件、表单元素组件(如 Input 组件)。
首先通过 form-item
组件的 prop 属性传递对应的字段,rules 属性则传递对应的字段验证规则,这样我们就可以实现验证初始化了。
const validator = new AsyncValidator({
[props.prop]: props.rules,
})
我们通过上述文章知道,我们创建验证实例对象之后,需要调用 validate 方法,需要传递需要验证的数据源。那么一个 form-item
组件对应的就是一个数据源。此外我们知道需要使用一个 form
组件对所有的 form-item
组件进行包裹,然后 form
组件需要传递一个 model 字段,这个 model 字段的内容就是所有表单的数据对象。下图为 Element Plus 官方文档对 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
组件遍历校验一遍。
更多内容,我们将在下一篇文章中进行详细介绍,敬请关注。
mini-async-validator 源码
本文中实现的 mini-async-validator 源码如下,方便上不了 GitHub 的同学查阅,后面也有对应的 GitHub 仓库地址。
// 验证策略
const rules = {
username: {
required: true,
message: '请输入用户名',
},
password: [
{
required: true,
message: '请输入密码',
},
{
validator(rule, value, callback, source) {
if (
Object.prototype.hasOwnProperty.call(source, rule.field) &&
(value.length < 6 || value.length > 18)
) {
callback('密码长度必须大于6位小于18位')
}
},
},
],
}
// 字符串验证策略函数
const string = (rule, value, callback, source) => {
const errors = []
// 存在 required 或者不存在 required 但对应的字段有值的情况下都需要进行验证
const validate =
rule.required ||
(!rule.required && Object.prototype.hasOwnProperty.call(source, rule.field))
if (validate) {
if (
!rule.required &&
(value === undefined || value === null || value === '')
) {
// 如果不存在 required 并且对应的字段值为空则通过验证
return callback()
} else if (
rule.required &&
(!Object.prototype.hasOwnProperty.call(source, rule.field) ||
value === undefined ||
value === null ||
value === '')
) {
// 如果存在 required 并且对应的字段值为空则不通过验证
errors.push('请输入内容')
}
}
callback(errors)
}
// 默认验证策略函数组
const validators = { string }
/**
* 并行校验规则
* @param arr 字段策略数组
* @param callback 计算字段验证进度函数回调
*/
function asyncParallelArray(arr, func, callback) {
// 每个字段需要验证的策略总数
let tatal = 0
const results = []
// 计算字段策略的验证进度
const count = (error) => {
results.push(...(error || []))
tatal++
// 等待每个字段策略中策略全部验证完毕再计算下一个字段的验证
if (tatal === arr.length) {
callback(results)
}
}
// 遍历字段策略中的策略
arr.forEach((a) => {
func(a, count)
})
}
/**
* 异步验证函数
* @param objArr series
* @param callback 回调函数
*/
function asyncMap(objArr, func, callback) {
const errors = []
const objArrKeys = Object.keys(objArr)
const objArrLength = objArrKeys.length
// 需要验证的字段总数
let total = 0
return new Promise((resolve, reject) => {
// 计算字的验证进度,同时如果字段验证完毕则把相关结果返回
const next = (error) => {
errors.push(...error)
// 等待每个字段策略中策略全部验证完毕再计算下一个字段的验证
total++
// 当 total === objArrLength 的时候就是字段策略循环验证完毕的时候
if (total === objArrLength) {
// 同时执行回调函数,兼容不同的写法需求
callback && callback(errors)
// 如果存在错误则 reject 错误信息,否则就 resolve 表示成功
errors.length ? reject(errors) : resolve()
}
}
// 遍历执行验证每一个字段策略
objArrKeys.forEach((key) => {
const arr = objArr[key]
asyncParallelArray(arr, func, next)
})
})
}
// 调用策略的类
class Schema {
rules = null
constructor(descriptor) {
this.define(descriptor)
}
// 存储策略
define(rules) {
this.rules = {}
Object.keys(rules).forEach((name) => {
const item = rules[name]
this.rules[name] = Array.isArray(item) ? item : [item]
})
}
// 调用策略
validate(source_, callback) {
const source = source_
// 最终保存验证数据的集合
const series = {}
const keys = Object.keys(this.rules)
keys.forEach((z) => {
// 字段中验证规则数组
const arr = this.rules[z]
// 对应的字段值
const value = source[z]
arr.forEach((r) => {
const rule = r
// 获取对应的验证策略的函数
rule.validator = this.getValidationMethod(rule)
// 在规则中添加对应的字段记录
rule.field = z
series[z] = series[z] || []
// 为每个验证策略配置对应的上下文内容,从而可以获取验证的规则,验证字段的值
series[z].push({
rule,
value,
source, // 添加需要校验的数据源
})
})
})
// 验证每一个规则的函数
function sigleValidator(data, doIt) {
const rule = data.rule
function cb(e = []) {
let errorList = Array.isArray(e) ? e : [e]
// 判断验证规则如果存在 message 字段则使用 message 字段的内容
if (errorList.length && rule.message !== undefined) {
errorList = [].concat(rule.message)
}
doIt(errorList)
}
// 执行校验策略函数的时候把对应的规则和需要校验的数据源也传递过去
rule.validator(rule, data.value, cb, data.source)
}
return asyncMap(series, sigleValidator, callback)
}
// 获取对应的验证策略的函数
getValidationMethod(rule) {
// 如果 validator 是函数,则使用配置的 validator 函数也就是自定义验证函数
if (typeof rule.validator === 'function') {
return rule.validator
}
// 获取对应的验证函数类型,默认为 string
const type = rule.type || 'string'
// 验证策略函数组 validators 中获取对应的验证函数,没有则为 undefined
return validators[type] || undefined
}
}
const validator = new Schema(rules)
const handleSubmit = () => {
validator
.validate({ username: username.value, password: password.value })
.then(() => {
console.log('提交成功')
})
.catch((errors) => {
console.error(errors)
console.error('提交失败')
})
}
总结
文本通过由浅入深逐步实现一个前端两大阵营 React 和 Vue 都离不开的表单验证工具库 mini-async-validator,旨在在实现的过程中彻底掌握前端的设计模式之策略模式,以及一些重构的手法。
async-validator 的基本原理就是策略的实现与策略的调用。具体就是通过定义一个验证策略组对象,然后通过调用策略类调用验证策略组对象中的策略。其中策略组对象中包含不同字段的策略,具体字段中可以存在一个规则对象,也可以是多个规则对象。调用策略组对象的过程就是先遍历策略组对象的字段策略,再遍历字段中的规则数组,最后执行规则中的验证函数,其中执行验证规则有可能是并行执行也有可能是串行执行,异步验证的时候就相当于有很多 HTTP 的请求,我们需要把每个 HTTP 的请求结果都收集起来进行返回。
async-validator 的基本原理本质上就是策略模式的应用。策略模式是程序设计模式中的一种,所谓设计模式,其实就是一种编程的思想或者方法论,并没有唯一的实现方式, 我们需要掌握的是它们的实现思想逻辑以及应用场景,以达到在日常工作中熟练使用。
至此我们整个 async-validator 库的基本原理和框架我们就实现完毕了,我们算是实现了一个 mini-async-validator。大家可以通过阅读本篇文章掌握 async-validator 的基本架构原理之后,再自行研究 async-validator 库的时候,相信也会变得很容易了。
本文中的源码地址分别是:github.com/amebyte/min… 和 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/7249299811497066551