目录
前言
目前社区中的开源组件库表单模块的验证,基本都是采用了 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>
用户名:<input v-model="username" type="text" @blur="handleUsernameBlur" />
密码:<input v-model="password" type="password" @blur="handlePasswordBlur" />
<button @click="handleSubmit">提交</button>
</template>
<script 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>
用户名:<input v-model="username" type="text" @blur="handleUsernameBlur" />
密码:<input v-model="password" type="password" @blur="handlePasswordBlur" />
<button @click="handleSubmit">提交</button>
</template>
<script 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) {
if (value === "" || value === undefined || value === null) {
alert("请输入用户名");
return false;
}
},
},
password: [
{
validator(value) {
if (value === "" || value === undefined || value === null) {
alert("请输入密码");
return false;
}
},
},
{
validator(value) {
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)
// ...
}
}

最终调用策略函数 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) => {
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 函数的代码进行以下修改:

接着我们需要在执行规则函数的时候,去判断需要校验的数据源中有没有当前字段,规则函数相应的修改如下:
const rules = {
username: {
validator(rule, value, source) {
if (
Object.prototype.hasOwnProperty.call(source, rule.field) &&
(value === "" || value === undefined || value === null)
) {
alert("请输入用户名");
return false;
}
},
},
password: [
{
validator(rule, value, source) {
if (
Object.prototype.hasOwnProperty.call(source, rule.field) &&
(value === "" || value === undefined || value === null)
) {
alert("请输入密码");
return false;
}
},
},
{
validator(rule, value, source) {
if (
Object.prototype.hasOwnProperty.call(source, rule.field) &&
(value.length < 6 || value.length > 18)
) {
alert("密码长度必须大于6位小于18位");
return false;
}
},
},
],
};
我们通过 hasOwnProperty 方法在执行校验策略之前判断需要校验的数据源中有没有对应的校验字段,如果没有则不进行校验。
hasOwnProperty 方法是用来判断一个对象是否有你给出名称的属性或对象。不过需要注意的是,此方法无法检查该对象的原型链中是否具有该属性,该属性必须是对象本身的一个成员。