JavaScript 設計模式(三):代理模式

代理模式

代理模式:爲一個對象提供一個代用品或佔位符,以便控制它的訪問。

當我們不方便直接訪問某個對象時,或不滿足需求時,可考慮使用一個替身對象來控制該對象的訪問。替身對象可對請求預先進行處理,再決定是否轉交給本體對象。

生活小栗子:

  1. 代購;
  2. 明星經紀人;
  3. 和諧上網

經常 shopping 的同學,對代購應該不陌生。自己不方便直接購買或買不到某件商品時,會選擇委託給第三方,讓代購或黃牛去做購買動作。程序世界的代理者也是如此,我們不直接操作原有對象,而是委託代理者去進行。代理者的作用,就是對我們的請求預先進行處理或轉接給實際對象。

模式特點

  1. 代理對象可預先處理請求,再決定是否轉交給本體;
  2. 代理和本體對外顯示接口保持一致性
  3. 代理對象僅對本體做一次包裝

模式細分

  1. 虛擬代理(將開銷大的運算延遲到需要時執行)
  2. 緩存代理(爲開銷大的運算結果提供緩存)
  3. 保護代理(黑白雙簧,代理充當黑臉,攔截非分要求)
  4. 防火牆代理(控制網絡資源的訪問)
  5. 遠程代理(爲一個對象在不同的地址控件提供局部代表)
  6. 智能引用代理(訪問對象執行一些附加操作)
  7. 寫時複製代理(延遲對象複製過程,對象需要真正修改時才進行)

JavaScript 中常用的代理模式爲 “虛擬代理” 和 “緩存代理”。

模式實現

實現方式:創建一個代理對象,代理對象可預先對請求進行處理,再決定是否轉交給本體,代理和本體對外接口保持一致性(接口名相同)。
// 例子:代理接聽電話,實現攔截黑名單
var backPhoneList = ['189XXXXX140'];       // 黑名單列表
// 代理
var ProxyAcceptPhone = function(phone) {
    // 預處理
    console.log('電話正在接入...');
    if (backPhoneList.includes(phone)) {
        // 屏蔽
        console.log('屏蔽黑名單電話');
    } else {
        // 轉接
        AcceptPhone.call(this, phone);
    }
}
// 本體
var AcceptPhone = function(phone) {
    console.log('接聽電話:', phone);
};

// 外部調用代理
ProxyAcceptPhone('189XXXXX140'); 
ProxyAcceptPhone('189XXXXX141'); 

代理並不會改變本體對象,遵循 “單一職責原則”,即 “自掃門前雪,各找各家”。不同對象承擔獨立職責,不過於緊密耦合,具體執行功能還是本體對象,只是引入代理可以選擇性地預先處理請求。例如上述代碼中,我們向 “接聽電話功能” 本體添加了一個屏蔽黑名單的功能(保護代理),預先處理電話接入請求。

虛擬代理(延遲執行)

虛擬代理的目的,是將開銷大的運算延遲到需要時再執行。

虛擬代理在圖片預加載的應用,代碼例子來至 《JavaScript 設計模式與開發實踐》

// 本體
var myImage = (function(){
    var imgNode = document.createElement('img');
    document.body.appendChild(imgNode);
    return {
        setSrc: function(src) {
            imgNode.src = src;
        }
    }
})();

// 代理
var proxyImage = (function(){
    var img = new Image;
    img.onload = function() {
        myImage.setSrc(this.src);             // 圖片加載完設置真實圖片src
    }
    return {
        setSrc: function(src) {
            myImage.setSrc('./loading.gif');  // 預先設置圖片src爲loading圖
            img.src = src;
        }
    }
})();

// 外部調用
proxyImage.setSrc('./product.png');           // 有loading圖的圖片預加載效果

緩存代理(暫時存儲)

緩存代理的目的,是爲一些開銷大的運算結果提供暫時存儲,以便下次調用時,參數與結果不變情況下,從緩存返回結果,而不是重新進行本體運算,減少本體調用次數。

應用緩存代理的本體,要求運算函數應是一個純函數,簡單理解比如一個求和函數 sum, 輸入參數 (1, 1), 得到的結果應該永遠是 2

純函數:固定的輸入,有固定的輸出,不影響外部數據。

模擬場景:60道判斷題測試,每三道題計分一次,根據計分篩選下一步的三道題目?

三道判斷題得分結果:

  1. (0, 0 ,0)
  2. (0, 0, 1)
  3. (0, 1, 0)
  4. (0, 1, 1)
  5. (1, 0, 0)
  6. (1, 0, 1)
  7. (1, 1, 0)
  8. (1, 1, 1)

總共七種計分結果。60/3 = 20,共進行 20 次計分,每次計分執行 3 個循環累計,共 60 個循環。接下來,借用 “緩存代理” 方式,來實現最少本體運算次數。

// 本體:對三道題答案進行計分
var countScore = function(ansList) {
    let [a, b, c] = ansList;
    return a + b + c;
}

// 代理:對計分請求預先處理
var proxyCountScore = (function() {
    var existScore = {};    // 設定存儲對象
    return function(ansList) {
        var attr = ansList.join(',');  // eg. ['0,0,0']
        if (existScore[attr] != null) {
            // 從內存返回
            return existScore[attr];
        } else {
            // 內存不存在,轉交本體計算並存入內存
            return existScore[attr] = countScore(ansList);
        }
    }
})();

// 調用計分
proxyCountScore([0,1,0]);

60 道題目,每 3 道題一次計分,共 20 次計分運算,但總的計分結果只有 7 種,那麼實際上本體 countScore() 最多隻需運算 7 次,即可囊括所有計算結果。

通過緩存代理的方式,對計分結果進行臨時存儲。用答案字符串組成屬性名 ['0,1,0'] 作爲 key 值檢索內存,若存在直接從內存返回,減少包含複雜運算的本體被調用的次數。之後如果我們的題目增加至 90 道, 120 道,150 道題時,本體 countScore() 運算次數仍舊保持 7 次,中間節省了複雜運算的開銷。

ES6 的 Proxy

ES6新增的 Proxy 代理對象的操作,具體的實現方式是在 handler 上定義對象自定義方法集合,以便預先管控對象的操作。

ES6 的 Proxy語法:let proxyObj = new Proxy(target, handler);
  • target: 本體,要代理的對象
  • handler: 自定義操作方法集合
  • proxyObj: 返回的代理對象,擁有本體的方法,不過會被 handler 預處理
// ES6的Proxy
let Person = {
    name: '以樂之名'
};

const ProxyPerson = new Proxy(Person, {
    get(target, key, value) {
        if (key != 'age') {
            return target[key];
        } else {
            return '保密'
        }
    },
    set(target, key, value) {
        if (key === 'rate') {
            target[key] = value === 'A' ? '推薦' : '待提高'
        }
    }
})

console.log(ProxyPerson.name);  // '以樂之名'
console.log(ProxyPerson.age);   // '保密'
ProxyPerson.rate = 'A';         
console.log(ProxyPerson.rate);  // '推薦'
ProxyPerson.rate = 'B';         
console.log(ProxyPerson.rate);  // '待提高'

handler 除常用的 set/get,總共支持 13 種方法:

handler.getPrototypeOf()
// 在讀取代理對象的原型時觸發該操作,比如在執行 Object.getPrototypeOf(proxy) 時

handler.setPrototypeOf()
// 在設置代理對象的原型時觸發該操作,比如在執行 Object.setPrototypeOf(proxy, null) 時

handler.isExtensible()
// 在判斷一個代理對象是否是可擴展時觸發該操作,比如在執行 Object.isExtensible(proxy) 時

handler.preventExtensions()
// 在讓一個代理對象不可擴展時觸發該操作,比如在執行 Object.preventExtensions(proxy) 時

handler.getOwnPropertyDescriptor()
// 在獲取代理對象某個屬性的屬性描述時觸發該操作,比如在執行 Object.getOwnPropertyDescriptor(proxy, "foo") 時

handler.defineProperty()
// 在定義代理對象某個屬性時的屬性描述時觸發該操作,比如在執行 Object.defineProperty(proxy, "foo", {}) 時

handler.has()
// 在判斷代理對象是否擁有某個屬性時觸發該操作,比如在執行 "foo" in proxy 時

handler.get()
// 在讀取代理對象的某個屬性時觸發該操作,比如在執行 proxy.foo 時

handler.set()
// 在給代理對象的某個屬性賦值時觸發該操作,比如在執行 proxy.foo = 1 時

handler.deleteProperty()
// 在刪除代理對象的某個屬性時觸發該操作,比如在執行 delete proxy.foo 時

handler.ownKeys()
// 在獲取代理對象的所有屬性鍵時觸發該操作,比如在執行 Object.getOwnPropertyNames(proxy) 時

handler.apply()
// 在調用一個目標對象爲函數的代理對象時觸發該操作,比如在執行 proxy() 時。

handler.construct()
// 在給一個目標對象爲構造函數的代理對象構造實例時觸發該操作,比如在執行 new proxy() 時

適用場景

  • 虛擬代理:

    1. 圖片預加載(loading 圖)
    2. 合併HTTP請求(數據上報彙總)
  • 緩存代理:(前提本體是純函數)

    1. 緩存異步請求數據
    2. 緩存較複雜的運算結果
  • ES6 的 Proxy:

    1. 實現對象私有屬性
    2. 實現表單驗證

“策略模式” 可應用於表單驗證信息,“代理方式” 也可實現。這裏引用 Github - jawil 的一個例子,思路供大家分享。

// 利用 proxy 攔截格式不符數據
function validator(target, validator, errorMsg) {
    return new Proxy(target, {
        _validator: validator,
        set(target, key, value, proxy) {
            let errMsg = errorMsg;
            if (value == null || !value.length) {
                console.log(`${errMsg[key]} 不能爲空`);
                return target[key] = false;
            }
            let va = this._validator[key];  // 這裏有策略模式的應用
            if (!!va(value)) {
                return Reflect.set(target, key, value, proxy);
            } else {
                console.log(`${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.phone.value
        yield vali.email = registerForm.email.value
    }
    let validator = validatorNext();
    for (let field of validator) {
        validator.next();
    }
}

實現思路: 利用 ES6 的 proxy 自定義 handlerset() ,進行表單校驗並返回結果,並且借用 “策略模式" 獨立封裝驗證邏輯。使得表單對象,驗證邏輯,驗證器各自獨立。代碼整潔性,維護性及複用性都得到增強。

關於 “設計模式” 在表單驗證的應用,可參考 jawil 原文:《探索兩種優雅的表單驗證——策略設計模式和ES6的Proxy代理模式》

優缺點

  • 優點:

    1. 可攔截和監聽外部對本體對象的訪問;
    2. 複雜運算前可以進行校驗或資源管理;
    3. 對象職能粒度細分,函數功能複雜度降低,符合 “單一職責原則”;
    4. 依託代理,可額外添加擴展功能,而不修改本體對象,符合 “開發-封閉原則”
  • 缺點:

    1. 額外代理對象的創建,增加部分內存開銷;
    2. 處理請求速度可能有差別,非直接訪問存在開銷,但 “虛擬代理” 及 “緩存代理” 均能提升性能

參考文章

Github,期待Star!
https://github.com/ZengLingYong/blog

作者:以樂之名
本文原創,有不當的地方歡迎指出。轉載請指明出處。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章