luokuning / blogs

翻译,随笔,以及懒得整理……
81 stars 2 forks source link

关于输入验证的一些想法 #12

Open luokuning opened 6 years ago

luokuning commented 6 years ago

TL;DR; 在 Redux 中间件的启发下,实现一个高灵活性的输入验证流。

最近突然对用户的输入验证有了一些想法。

对用户的输入验证我们应该很熟悉,毕竟在提交表单的时候尤为常见。通常来说验证用户的输入并不是问题,表单内置的验证 已经可以较好的完成这个功能,但是当一个应用存在很多表单组件,而且我们想定制验证的流程与界面,每个表单的验证规则又都存在交叉时,会让验证变得比较棘手,也很容易产生一些冗余的代码。

试想一下,一个页面存在三个 input[type=text] 的输入框,分别命名为 A, B, C。

  1. A 的验证规则是:不为空、只能是英文字母数字或者下划线、长度不限制;
  2. B 的验证规则是:不为空、只能是英文字母数字或者下划线、长度不超过 20 个字符;
  3. C 的验证规则是:可以为空,也可以为任意字符,但是长度不超过 30 个字符;

为了分别验证三个表单的输入我们可能需要写三个验证函数:

// 注意 u 修饰符用来正确处理代码点大于 \uFFFF 的字符

function validateA(str) {
  // 不为空、只能是英文字母数字或者下划线、长度不限制;
  return /^[\w_]+$/.test(str.trim())
}

function validateB(str) {
  // 不为空、只能是英文字母数字或者下划线、长度不超过 20 个字符;
  return /^\w{1,20}$/u.test(str.trim())
}

function validateC(str) {
  // 可以为空,也可以为任意字符,但是长度不能超过 30 个字符;
  return /^.{0,30}$/u.test(str.trim())
}

这三个函数并不复杂,似乎就算是页面里再多几个类似这样的输入框,同步增加几个验证函数也能写的这么很愉快。但是仔细想想,正则表达式语义非常不清晰,稍微复杂一点的表达式通常都难以阅读,而且关键的点是输入框 A,B,C 的验证规则都相互交叉:A 跟 B 有 不为空、只能是英文字母数字或者下划线的规则,而 B 跟 C 都有关于长度限制的验证,那我们是否能把公共的验证逻辑提取出来呢?

答案是完全可以的。不过在提取之前我们先设想一下理想情况下验证用户输入的代码应该是什么样子: 我们希望每个验证的因素都是一个函数,接收一个对象作为参数,这个对象包含了需要被验证的字符的信息。每个验证函数需要对输入的数据进行处理,如果验证通过,则交由下一个验证函数处理,如果验证不通过,则返回一个新的对象,同时返回的对象里有一个 reason 属性,代表验证不通过的文字描述。代码可能如下:

const result = validate(
  shouldLteLen(20),
  shouldBeAlphanumeric(),
)('123456789')
// result = { str: '123456789' }
// result 没有 reason 属性,表示验证通过
const result = validate(
  shouldLteLen(10),
  shouldBeAlphanumeric(),
)('abc123456789')
// result = { str: '123456789', reason: '长度不能超过 10 个字符' }
// result 包含 reason 属性,表示验证不通过

我们上面的代码语义非常清晰,甚至在不需要阅读每个函数源代码的情况下,只通过阅读函数名,就能知道验证了什么。而且因为函数最后返回了 reason 这个对错误的文本描述,我们可以很方便的直接把错误信息提示给用户。

接下来就是实现 validate 函数与 shouldLteLen 这种验证函数了。 其实我对上面函数的想法在一定程度上是受 Redux 中间件的启发。这里我们也直接利用 Redux 中的 compose 函数来实现 validate 函数:

function validate(...funcs) {
  return str => {
    return compose(...funcs)(arg => arg)({ str })
  }
}
function compose(...funcs) {
  if (funcs.length === 0) {
    return arg => arg
  }

  if (funcs.length === 1) {
    return funcs[0]
  }

  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

所以每个验证函数的写法也跟 Redux 中间件的写法类似:

function shouldLteLen(len) {
  return next => source => {
    const { str } = source
    if (str.trim().length <= len) {
      return next(source)
    }
    return {
      str,
      reason: `不能超过 ${len} 个字符`,
    }
  }
}

function shouldBeAlphanumeric() {
  return next => source => {
    const { str } = source
    if (/^\w$/g.test(str.trim())) {
      return next(source)
    }
    return {
      str,
      reason: '必须是数字, 字母和下划线',
    }
  }
}

通过串联验证的方式,我们可以非常方便的增减验证规则。比如我们想在上面验证的基础上增加一个必须存在下划线的规则,可以再定义一个函数 shouldContainsUnderscore:

function shouldContainsUnderscore() {
  return next => source => {
    const { str } = source
    if (/_/.test(str)) {
      return next(source)
    }
    return {
      str,
      reason: '必须包含下划线',
    }
  }
}

实际使用的时候则添加为 validate 函数的参数即可:

const userNameValidator = validate(
  shouldLteLen(10),
  shouldBeAlphanumeric(),
  shouldContainsUnderscore(),
)
const { reason } = userNameValidator(userName)

if (reason) {
  alert(`用户名${reason}`)
}

可以看到我们使用了搭积木一样的模式来对表单输入进行验证,高可定制化。实际项目中可以有若干个很小的验证中间件,通过组合的方式将它们整合为大的完整的输入验证。使用这种模式几乎可以实现在不修改旧代码而只新增代码(验证中间件)的基础上实现验证规则的增加。


68b28a5cc0ea0b084a45068927b7a6eb

leoyli commented 6 years ago

There are full of middleware in Node.js/Express. lol... It's really powerful!

luokuning commented 6 years ago

@leoyli Indeed. And if I remember correctly, the redux middleware mechanism has been inspired by Express.

EzrealPrince commented 4 years ago

那个 return funcs.reduce((a, b) => (...args) => a(b(...args))) 这段代码中的扩展运算符和展开运算符的具体作用是什么呢?我看代码调用的时候传的参数只有一个 function (arg => arg),还会出现其他什么情况么?

luokuning commented 4 years ago

@EzrealPrince 是的,对于例子里的这种情况其实可以把 funcs.reduce((a, b) => (...args) => a(b(...args))) 修改成 funcs.reduce((a, b) => (fn) => a(b(fn)))。之所以要使用 rest parameters,可能是出于一种通用情况的考虑,一般来讲函数接收的参数与返回的参数如果是一致的话(类型、个数,shape 匹配),都是可以用 compose 串联起来。