查看原文
其他

【第937期】探索两种优雅的表单验证——策略设计模式和ES6的Proxy代理模式

jawil 前端早读课 2019-06-26

前言

今日早读文章由 @jawil 授权分享。

本文由 @ 浴火小青春推荐

正文从这开始~

在一个Web项目中,注册,登录,修改用户信息,下订单等功能的实现都离不开提交表单。这篇文章就阐述了如何编写相对看着舒服的表单验证代码。

假设我们正在编写一个注册的页面,在点击注册按钮之前,有如下几条校验逻辑。

  •  所有选项不能为空

  •  用户名长度不能少于6位

  • 密码长度不能少于6位

  • 手机号码必须符合格式

  • 邮箱地址必须符合格式

注:为简单起见,以下例子以传统的浏览器表单验证,Ajax异步请求不做探讨,浏览器端验证原理图:




简要说明:

这里我们前端只做浏览器端的校验。很多工具可以在表单检验过后、浏览器发送请求前截取表单数据,攻击者可以修改请求中的数据,从而绕过 JavaScript,将恶意数据注入服务器,这样会增加XSS(全称 Cross Site Scripting)攻击的机率。对于一般的网站,都不赞成采用浏览器端的表单验证方法。浏览器端和服务器端双重验证方法在浏览器端验证方法基础上增加服务器端的验证,其原理如图所示,该方法增加服务器端的验证,弥补了传统浏览器端验证的缺点。若表单输入不符合要求,浏览器端的 Javascript 验证能很快地给出响应,而服务器端的验证则可以防止恶意用户绕过 Javascript 验证,保证最终数据的准确性。

HTML 代码:

<!DOCTYPE html>
<html>
<head>
   <meta charset="utf-8">
   <meta http-equiv="X-UA-Compatible" content="IE=edge">
   <title>探索几种表单验证最佳实践方式</title>
</head>
<body>
   <form action="http://xxx.com/register" id="registerForm" method="post">
       <div class="form-group">
           <label for="user">请输入用户名:</label>
           <input type="text" class="form-control" id="user" name="userName">
       </div>
       <div class="form-group">
           <label for="pwd">请输入密码:</label>
           <input type="password" class="form-control" id="pwd" name="passWord">
       </div>
       <div class="form-group">
           <label for="phone">请输入手机号码:</label>
           <input type="tel" class="form-control" id="phone" name="phoneNumber">
       </div>
       <div class="form-group">
           <label for="email">请输入邮箱:</label>
           <input type="text" class="form-control" id="email" name="emailAddress">
       </div>
       <button type="button" class="btn btn-default">Submit</button>
   </form>
</body>
</html>

JavaScript 代码:

let registerForm = document.querySelector('#registerForm')
 registerForm.addEventListener('submit', function() {
     if (registerForm.userName.value === '') {
         alert('用户名不能为空!')
         return false
     }
     if (registerForm.userName.length < 6) {
         alert('用户名长度不能少于6位!')
         return false
     }
     if (registerForm.passWord.value === '') {
         alert('密码不能为空!')
         return false
     }
     if (registerForm.passWord.value.length < 6) {
         alert('密码长度不能少于6位!')
         return false
     }
     if (registerForm.phoneNumber.value === '') {
         alert('手机号码不能为空!')
         return false
     }
     if (!/^1(3|5|7|8|9)[0-9]{9}$/.test(registerForm.phoneNumber.value)) {
         alert('手机号码格式不正确!')
         return false
     }
     if (registerForm.emailAddress.value === '') {
         alert('邮箱地址不能为空!')
         return false
     }
     if (!/^\w+([+-.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*
     $/.test(registerForm.emailAddress.value)) {
         alert('邮箱地址格式不正确!')
         return false
     }
 }, false)

1、问题

这样编写代码,的确能够完成业务的需求,能够完成表单的验证,但是存在很多问题,比如:

  • registerForm.addEventListener绑定的函数比较庞大,包含了很多的if-else语句,看着都恶心,这些语句需要覆盖所有的校验规则。

  • registerForm.addEventListener绑定的函数缺乏弹性,如果增加了一种新的校验规则,或者想要把密码的长度校验从6改成8,我们都必须深入registerForm.addEventListener绑定的函数的内部实现,这是违反了开放-封闭原则的。

  • 算法的复用性差,如果程序中增加了另一个表单,这个表单也需要进行一些类似的校验,那我们很可能将这些校验逻辑复制得漫天遍野。

所谓办法总比问题多,办法是有的,比如马上要讲解的使用 策略模式 使表单验证更优雅更完美,我相信很多人很抵触设计模式,一听设计模式就觉得很遥远,觉得自己在工作中很少用到设计模式,那么你就错了,特别是JavaScript这种灵活的语言,有的时候你已经在你的代码中使用了设计模式,只是你不知道而已。更多关于设计模式的东西,以后会陆续写博客描述,这里只希望大家抛弃设计模式神秘的感觉,通俗的讲,它无非就是完成一件事情通用的办法而已。

2、思路

回到正题,假如我们不想使用过多的 if - else 语句,那么我们心中比较理想的代码编写方式是什么呢?我们能不能像编写配置一样的去做表单验证呢?再来一个”一键验证“的功能,是不是很爽?答案是肯定的,所以我们心中理想的编写代码的方式如下:

// 获取表单form元素
let registerForm = document.querySelector('#registerForm')

// 创建表单校验实例
let validator = new Validator();
// 编写校验配置
validator.add(registerForm.userName, 'isNonEmpty', '用户名不能为空')
validator.add(registerForm.userName, 'minLength:6', '用户名长度不能小于6')

// 开始校验,并接收错误信息
let errorMsg = validator.start()

// 如果有错误信息输出,说明校验未通过
if(errorMsg){
alert(errorMsg)
return false//阻止表单提交
}

怎么样?感受感受,是不是看上去优雅多了?好了,有了这些思路,我们就可以向目标迈进了,下一步就要了解了解什么事策略模式了。

3、策略模式

策略模式,单纯的看它的名字”策略“,指的是做事情的方法,比如我们想到某个地方旅游,你可以有几种策略供选择:

1、飞机,嗖嗖嗖直接就到了,节省时间。

2、火车,可以选择高铁出行,专为飞机恐惧症者提供。

3、徒步,不失为一个锻炼身体的选择。

4、other method……

在程序设计中,我们也经常遇到类似的情况,要实现一种方案有多种方案可以选择,比如,一个压缩文件的程序,即可选择zip算法,也可以选择gzip算法。

所以,做一件事你会有很多方法,也就是所谓的策略,而我们今天要讲的策略模式也就是这个意思,它的核心思想是,将做什么和谁去做相分离。所以,一个完整的策略模式要有两个类,一个是策略类,一个是环境类(主要类),环境类接收请求,但不处理请求,它会把请求委托给策略类,让策略类去处理,而策略类的扩展是很容易的,这样,使得我们的代码易于扩展。

在表单验证的例子中,各种验证的方法组成了策略类,比如:判断是否为空的方法(如:isNonEmpty),判断最小长度的方法(如:minLength),判断是否为手机号的方法(isMoblie)等等,他们组成了策略类,供给环境类去委托请求。下面,我们就来实战一下。

4、用策略模式重构表单校验

策略模式的组成

  • 抽象策略角色:策略类,通常由一个接口或者抽象类实现。

  • 具体策略角色:包装了相关的算法和行为。

  • 环境角色:持有一个策略类的引用,最终给客户端用的。

4.1具体策略角色——编写策略类

策略类很简单,它是由一组验证方法组成的对象,即策略对象,重构表单校验的代码,很显然第一步我们要把这些校验逻辑都封装成策略对象:

/*策略对象*/
const strategies = {
       isNonEmpty(value, errorMsg) {
           return value === '' ?
               errorMsg : void 0
       },
       minLength(value, length, errorMsg) {
           return value.length < length ?
               errorMsg : void 0
       },
       isMoblie(value, errorMsg) {
           return !/^1(3|5|7|8|9)[0-9]{9}$/.test(value) ?
               errorMsg : void 0
       },
       isEmail(value, errorMsg) {
           return !/^\w+([+-.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/.test(value) ?
               errorMsg : void 0
       }
   }

4.2抽象策略角色——编写Validator类

根据我们的思考,我们使用add方法添加验证配置,如下:

validator.add(registerForm.userName, 'isNonEmpty', '用户名不能为空')
validator.add(registerForm.userName, 'minLength:6', '用户名长度不能小于 6')

add方法接受三个参数,第一个参数是表单字段,第二个参数是策略对象中策略方法的名字,第三个参数是验证未通过的错误信息。

然后使用 start 方法开始验证,若验证未通过,返回验证错误信息,如下:

let errorMsg = validator.start()

另外,再解释一下下面这句代码:

add方法第一个参数我们说过了,是要验证的表单元素,第二个参数是一个字符串,使用 冒号(:) 分割,前面是策略方法名称,后面是传给这个方法的参数,第三个参数仍然是错误信息。

但是这种参数配置还是有问题,我们的要求是多种校验规则,比如用户名既不能为空,又要满足用户名长度不小于6,并不是单一的,上面的为什么要写两次,这种看着就不舒服,这时候我就需要对配置参数做一点小小的改动,我们用数组来传递多个校验规则:

validator.add(registerForm.userName, [{
       strategy: 'isNonEmpty',
       errorMsg: '用户名不能为空!'
   }, {
       strategy: 'minLength:6',
       errorMsg: '用户名长度不能小于6位!'
   }])

最后是Validator类的实现:

/*Validator类*/
class Validator {
   constructor() {
       this.cache = [] //保存校验规则
   }
   add(dom, rules) {
       for (let rule of rules) {
           let strategyAry = rule.strategy.split(':') //例如['minLength',6]
           let errorMsg = rule.errorMsg //'用户名不能为空'
           this.cache.push(() => {
               let strategy = strategyAry.shift() //用户挑选的strategy
               strategyAry.unshift(dom.value) //把input的value添加进参数列表
               strategyAry.push(errorMsg) //把errorMsg添加进参数列表,[dom.value,6,errorMsg]
               return strategies[strategy].apply(dom, strategyAry)
           })
       }
   }
   start() {
       for (let validatorFunc of this.cache) {
           let errorMsg = validatorFunc()//开始校验,并取得校验后的返回信息
           if (errorMsg) {//r如果有确切返回值,说明校验没有通过
               return errorMsg
           }
       }
   }
}

4.3环境角色——客户端调用代码

使用策略模式重构代码以后,我们仅仅通过‘配置’的方式就可以完成一个表单的校验,这些校验规则也可以复用在程序的任何地方,还能作为插件的形式,方便地被移植到其他项目中。

/*客户端调用代码*/
let registerForm = document.querySelector('#registerForm')
const validatorFunc = () => {
   let validator = new Validator()

   validator.add(registerForm.userName, [{
       strategy: 'isNonEmpty',
       errorMsg: '用户名不能为空!'
   }, {
       strategy: 'minLength:6',
       errorMsg: '用户名长度不能小于6位!'
   }])

   validator.add(registerForm.passWord, [{
       strategy: 'isNonEmpty',
       errorMsg: '密码不能为空!'
   }, {
       strategy: 'minLength:',
       errorMsg: '密码长度不能小于6位!'
   }])

   validator.add(registerForm.phoneNumber, [{
       strategy: 'isNonEmpty',
       errorMsg: '手机号码不能为空!'
   }, {
       strategy: 'isMoblie',
       errorMsg: '手机号码格式不正确!'
   }])

   validator.add(registerForm.emailAddress, [{
       strategy: 'isNonEmpty',
       errorMsg: '邮箱地址不能为空!'
   }, {
       strategy: 'isEmail',
       errorMsg: '邮箱地址格式不正确!'
   }])
   let errorMsg = validator.start()
   return errorMsg
}

registerForm.addEventListener('submit', function() {
   let errorMsg = validatorFunc()
   if (errorMsg) {
       alert(errorMsg)
       return false
   }
}, false)

在修改某个校验规则的时候,只需要编写或者改写少量的代码。比如我们想要将用户名输入框的校验规则改成用户名不能少于4个字符。可以看到,这时候的修改是毫不费力的。代码如下:

validator.add(registerForm.userName, [{
       strategy: 'isNonEmpty',
       errorMsg: '用户名不能为空!'
   }, {
       strategy: 'minLength:4',
       errorMsg: '用户名长度不能小于4位!'
   }])

4.4策略模式的优缺点

策略模式利用组合、委托和多态等技术思想,可以有效的避免多种条件选择语句;

策略模式提供了对开放-封闭原则的完美支持,将算法封装在独立的strategy中,使得它易于切换,易于理解,易于拓展;

策略模式中的算法也可以复用在系统的其它地方,从而避免了许多重复的复制黏贴的工作;

在策略模式利用组合和委托来让Context拥有执行算法的能力,这也是继承一种更轻便的替代方案。

当然,策略模式也有一些缺点,但掌握了策略模式,这些缺点并不严重。

编写难度加大,代码量变多了,这是最直观的一个缺点,也算不上缺点,毕竟不能完全以代码多少来衡量优劣。

首先,使用策略模式会在程序中增加许多策略类或者策略对象,但实际上这比把它们负责的逻辑堆砌在Context中要好。

其次,要使用策略模式,必须了解所有的strategy,必须了解各个strategy之间的不同点,这样才能选择一个合适的strategy。比如,我们要选择一种合适的旅游出行路线,必须先了解选择飞机、火车、自行车等方案的细节。此时strategy要向客户暴露它的所有实现,这是违反最少知识原则的。

4.5策略模式的意义

策略模式使开发人员能够开发出由许多可替换的部分组成的软件,并且各个部分之间是弱连接的关系。

弱连接的特性使软件具有更强的可扩展性,易于维护;更重要的是,它大大提高了软件的可重用性。

关于ES6d的Proxy对象

策略模式固然可行,但是包装的有点多了,而且不便于书写,代码书写量增加了不少,也就是有一定门槛,那有没有更好的实现方式呢?我们能不能通过一层代理,在设置属性时候就去拦截它呢?这就是今天要讲到的ES6的Proxy对象。

1、概述

Proxy 用于修改某些操作的默认行为,等同于在语言层面做出修改,所以属于一种“元编程”(meta programming),即对编程语言进行编程。

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。

let obj = new Proxy({}, {
 get (target, key, receiver) {
   console.log(`getting ${key}!`)
   return Reflect.get(target, key, receiver)
 },
 set (target, key, value, receiver) {
   console.log(`setting ${key}!`)
   return Reflect.set(target, key, value, receiver)
 }
})

上面代码对一个空对象架设了一层拦截,重定义了属性的读取(get)和设置(set)行为。这里暂时先不解释具体的语法,只看运行结果。对设置了拦截行为的对象obj,去读写它的属性,就会得到下面的结果。

obj.count = 1
//  setting count!
++obj.count
//  getting count!
//  setting count!
//  2

上面代码说明,Proxy 实际上重载(overload)了点运算符,即用自己的定义覆盖了语言的原始定义。

ES6 原生提供 Proxy 构造函数,用来生成 Proxy 实例。

let proxy = new Proxy(target, handler);

Proxy 对象的所有用法,都是上面这种形式,不同的只是handler参数的写法。其中,new Proxy()表示生成一个Proxy实例,target参数表示所要拦截的目标对象,handler参数也是一个对象,用来定制拦截行为。

下面是另一个拦截读取属性行为的例子。

var proxy = new Proxy({}, {
 get: function(target, property) {
   return 35;
 }
});

proxy.time // 35
proxy.name // 35
proxy.title // 35

上面代码中,作为构造函数,Proxy接受两个参数。第一个参数是所要代理的目标对象(上例是一个空对象),即如果没有Proxy的介入,操作原来要访问的就是这个对象;第二个参数是一个配置对象,对于每一个被代理的操作,需要提供一个对应的处理函数,该函数将拦截对应的操作。比如,上面代码中,配置对象有一个get方法,用来拦截对目标对象属性的访问请求。get方法的两个参数分别是目标对象和所要访问的属性。可以看到,由于拦截函数总是返回35,所以访问任何属性都得到35。

注意,要使得Proxy起作用,必须针对Proxy实例(上例是proxy对象)进行操作,而不是针对目标对象(上例是空对象)进行操作。

2、利用Proxy重构表单验证

利用proxy拦截不符合要求的数据

function validator(target, validator, errorMsg) {
   return new Proxy(target, {
       _validator: validator,
       set(target, key, value, proxy) {
           let errMsg = errorMsg
           if (value == '') {
               alert(`${errMsg[key]}不能为空!`)
               return target[key] = false
           }
           let va = this._validator[key]
           if (!!va(value)) {
               return Reflect.set(target, key, value, proxy)
           } else {
               alert(`${errMsg[key]}格式不正确`)
               return target[key] = false
           }
       }
   })
}

负责校验的逻辑代码

const validators = {
       name(value) {
           return value.length > 6
       },
       passwd(value) {
           return value.length > 6
       },
       moblie(value) {
           return /^1(3|5|7|8|9)[0-9]{9}$/.test(value)
       },
       email(value) {
           return /^\w+([+-.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/.test(value)
       }
   }

客户端调用代码

const errorMsg = { name: '用户名', passwd: '密码', moblie: '手机号码', email: '邮箱地址' }
const vali = validator({}, validators, errorMsg)
let registerForm = document.querySelector('#registerForm')
registerForm.addEventListener('submit', function() {
       let validatorNext = function*() {
           yield vali.name = registerForm.userName.value
           yield vali.passwd = registerForm.passWord.value
           yield vali.moblie = registerForm.phoneNumber.value
           yield vali.email = registerForm.emailAddress.value
       }
       let validator = validatorNext()
       validator.next();
       !vali.name || validator.next(); //上一步的校验通过才执行下一步
       !vali.passwd || validator.next();
       !vali.moblie || validator.next();
   }, false)

优点:条件和对象本身完全隔离开,后续代码的维护,代码整洁度,以及代码健壮性和复用性变得非常强。

缺点:兼容性不好,有babel怕啥,粗糙版,很多细节其实还可以优化,这里只提供一种思路。

参考文献

  • JavaScript设计模式与开发实践

  • ECMAScript 6 入门

  • 策略模式在表单验证中的应用

关于本文

作者:@jawil

原文:https://github.com/jawil/blog/issues/19

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存