设计一个好用的Validator

575 阅读7分钟
原文链接: zhuanlan.zhihu.com

其实很少在周末写文章的,更别谈玩前端了。既然给自己下了个目标,每个星期产出两篇文章,就要尽量做到呗。这次我讲一个验证器的设计思路,也是一年前就造出来的小轮子(ManitoYu/yc-validator),现在功能更为强大了,灵感当然来自于项目啦。

动机

因为项目的性质,我做过大量的表单,有表单的地方,就存在字段验证。无论是实时验证还是提交时验证,都需要获取字段内容进行判断,而判断的方法无外乎就是正则、比对,对于这样的代码,完全可以抽离一个模式,形成固定的套路,可以减少BUG,方便调用,利于封装扩展以及测试。

特性

我总结以往的经验,列举一下一个好用的验证器应该是个什么样子?

1、可扩展。能够自定义验证规则。

2、可组合。多个规则可以组合成一套规则。

3、支持异步验证。一个常见的场景,注册时实时提示用户名是否被占用。

4、开箱即用。提供预制的常用验证规则。

API设计

一个库无论底层怎么实现,最终需要对外暴露调用接口。然而接口的方式可以有不同的风格,可以直接暴露函数,或者走配置路线,还有DSL等。我还是选择了暴露函数,无论是配置还是DSL,解析后,还是会调用底层实现函数,我干脆直接提供函数好了,对于有兴趣的同学,可以做二次封装。

根据之前列举的性质,我设计了如下API:

// 单个验证器
validator

// 组合验证器
validators

// 异步组合验证器
asyncValidators

// 并行验证器
coValidators

对,我就只给出函数名,啥都没有,连签名都没有!开始我们的启发式设计。

验证器的设计

什么是验证器?它接收一个数据,返回是否合法。用什么可以表达合不合法呢?最直接的就是返回boolean,但是这样会缺少错误信息,所以至少需要返回一个字符串用于表达错误。基于一年前的设计,返回的是一个tuple,类似于[boolean, string]的结构,第一个元素表达是否合法,第二个是错误信息,现在觉得这样有点傻,string的存在与否本身表达的就是是否合法。对于我当时为啥这样设计,可能是想保持返回值类型的一致吧。我现在改变了设计,验证器的返回值可能有两种,string表示不合法,同时是一个错误信息,null表示合法。

其次,验证器需要保证和数据的解耦,也就是说创建的验证器是独立的,数据是延后传入的。直接想到的就是thunk函数,所以validator需要接收一个函数,这个函数表达的是一个验证规则,签名是any => boolean,接收一个值,返回是否合法。另外一个参数是错误信息。

基于以上描述,我们得到了validator的签名:

validator<A>(rule => (A => boolean), msg: string): (A => string | null)

validator调用之后,会返回一个函数,把待验证的数值传入,便会启动验证。

const required = validator(value => value != '', 'field must be required')
required('balabala') // null
required('') // field must be required

看上去挺爽的样子。现在我们搞定了第一条特性,再来点异步怎么样?

此时的我,有点犹豫。我是新加一个名为asyncValidator的API,还是直接扩展validator的功能呢?要是以我的性格,是加新API了。但是我这次否定了自己,如果validator可以同时支持同步异步,岂不是更为方便,虽然会造成一定的混淆,比如从函数名上不能清晰地知道是同步还是异步,但是类似于重载函数的方式感觉挺普遍的,于是决定先试一下重载validator。

既然谈到异步的问题上了,自然而然就会想到Promise。Promise本身就代表着一个异步值类型,所以可以直接扩展validator的签名。现在是这样了:

validator<A>(rule => (A => boolean) | (A => Promise<boolean>), msg: string): (A => string | Promise<string> | null)

validator的rule多增加了一种类型A => Promise<boolean>,也就是说,当验证器拿到值后,可以做一些异步任务,返回一个包裹了boolean的Promise,被包裹的boolean,即代表是否合法。整个函数都变为异步的了,自然而然返回值也要多增加一种类型Promise<string>,一个包裹了错误信息的Promise。所以,异步validator是这样玩的:

const required = validator(value => AsyncTask().then(a => value != a), 'value existed')
required('balabala').then(msg => console.log(msg)) // null
required('').then(msg => console.log(msg)) // field must be required

我们还可以做的更好。有时候,我们有这样一个需求,提示的错误信息并不总是静态的,可能每次传值给验证器,根据上下文的状态,想返回不同的错误信息。可以扩展msg的类型,同时支持求值函数。好了,我们的验证器更为完善了,如下:

validator<A>(rule => (A => boolean) | (A => Promise<boolean>), msg: string | (A => string)): (A => string | Promise<string> | null)

validator(value => value != '', value => 'field must be required' + value)

我们现在完成了特性1和3,还差可组合性。

怎么才能组合呢?或者换种说法,怎么设计函数才能便于组合呢?如果你知道compose函数,大概就不难想了 。如果函数的参数类型和返回值相同,这样的函数就是可组合的。所以,validators组合多个validator之后,依然要返回一个validator。这样思路就有了,不难想象,签名如下:

validators(...instances: Array<validator>): validator

这里的validators就是类似于compose的作用。示例代码如下:

const foo = validators(
  validator(v => v != 1, 'value must not be 1'),
  validator(v => v != 2, 'value must not be 2'),
  validator(v => v != 3, 'value must not be 3'),
)
foo(5) // null
foo(2) // value must not be 2

由于第二个验证器没有通过,返回了错误信息。

现在的validators同样存在异步的问题,还需要支持异步validator。我们想要这样的效果:

const foo = asyncValidators(
  validator(v => v != 1, 'value must not be 1'),
  validator(v => Promise.resolve(false), 'value must not be 2'),
  validator(v => v != 3, 'value must not be 3'),
)
foo(5).then(msg => console.log(msg)) // value must not be 2

第二个验证器是异步的,所以组合后的foo也应该返回promise。想实现这样的效果,可以使用递归(具体实现见代码)。

通过validators的示例,我们发现,如果有多个验证器,中间一个失败了,后面的验证器都不会再执行了。有时候有这样的需求,需要一次性拿到所有验证器的结果。比如这样:

const foo = coValidators(
  validator(v => v == 1, 'value must be 1'),
  validator(v => v == 2, 'value must be 2'),
  validator(v => v == 3, 'value must be 3')
)
foo(5) // ['value must be 1', 'value must be 2', 'value must be 3']

coValidators的行为和validators是类似的,但是coValidators是一次性返回所有不满足验证规则的错误信息,而且会根据参数中是否有异步验证器,动态返回Promise。

好了,到现在,我们设计好了这个函数,而且满足了特性1、2、3,对于预制常用验证器,读者可以自行去进行扩展,我就不给了,免得被喷正则写的烂,哈哈。此外,还提供了validate、asyncValidate用于对一个对象进行字段校验,这也是在实际生产中会用的比较多的API,再给一个综合示例:

const {
  validator,
  validators,
  asyncValidators,
  coValidators,
  validate,
  asyncValidate
} = PerfectValidator

const validateName = asyncValidators(
  validator(value => value != '', '名称不能为空'),
  validator(value => value.length < 10, '名称不能超过10个字符'),
  validator(
    value => new Promise(resolve => setTimeout(() => resolve(value != 'manito'), 2000)),
    '名称已经被使用'
  )
)

const validateAge = validators(
  validator(value => /^\d+$/.test(value), '年龄必须是数字'),
  validator(value => value > 0 && value < 100, '年龄必须在0-100之间')
)

const result = asyncValidate({
  name: validateName,
  age: validateAge
})({
  name: 'manito',
  age: 101
})

result.then(errors => console.log(errors))

输出如下:

针对于扩展的写法,我勉强给几个列子吧:

const isEmail = msg => validator(value => /^\w+@(\w+\.)+\w+$/.test(value), msg)
const isPhone = msg => validator(value => /^\d{11}$/.test(value), msg)

validators(
  isEmail('邮箱不合法'),
  isPhone('手机不合法')
)

isEmail和isPhone就可以作为预制的验证器,跟错误消息分离,这样调用的时候,直接填写自定义的错误消息即可,是不是很方便呢。

我给这个库命名为perfect-validator,希望它在你们心中也是完美的验证器。

GitHUb

github.com/ManitoYu/pe…