探索兩種優雅的表單驗證——策略設計模式和ES6的Proxy代理模式

原文收錄在我的 GitHub博客 (https://github.com/jawil/blog) ,喜歡的可以關注最新動態,大家一起多交流學習,共同進步,以學習者的身份寫博客,記錄點滴。

在一個Web項目中,注冊,登錄,修改用戶信息,下訂單等功能的實現都離不開提交表單。這篇文章就闡述了如何編寫相對看著舒服的表單驗證代碼。

假設我們正在編寫一個注冊的頁面,在點擊注冊按鈕之前,有如下幾條校驗邏輯。

  • [x] 所有選項不能為空
  • [x] 用戶名長度不能少于6位
  • [x] 密碼長度不能少于6位
  • [x] 手機號碼必須符合格式
  • [x] 郵箱地址必須符合格式

注:為簡單起見,以下例子以傳統的瀏覽器表單驗證,Ajax異步請求不做探討,瀏覽器端驗證原理圖:

image005

簡要說明:

這里我們前端只做瀏覽器端的校驗。很多工具可以在表單檢驗過后、瀏覽器發送請求前截取表單數據,攻擊者可以修改請求中的數據,從而繞過 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 入門
策略模式在表單驗證中的應用

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,818評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,185評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,656評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,647評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,446評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,951評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,041評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,189評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,718評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,800評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,419評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,420評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,755評論 2 371

推薦閱讀更多精彩內容

  • 國家電網公司企業標準(Q/GDW)- 面向對象的用電信息數據交換協議 - 報批稿:20170802 前言: 排版 ...
    庭說閱讀 11,045評論 6 13
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,781評論 18 139
  • 工廠模式類似于現實生活中的工廠可以產生大量相似的商品,去做同樣的事情,實現同樣的效果;這時候需要使用工廠模式。簡單...
    舟漁行舟閱讀 7,796評論 2 17
  • 1. Java基礎部分 基礎部分的順序:基本語法,類相關的語法,內部類的語法,繼承相關的語法,異常的語法,線程的語...
    子非魚_t_閱讀 31,709評論 18 399
  • 今天我們先講3點,分別是積極主動,以終為始和要事第一。 把這些知識點融合一起就是找準目標,不要被瑣事干擾避免,遇到...
    軟妹子的日常閱讀 178評論 0 0