JavaScript設計模式與實踐--代理模式

小平果118發表於2018-08-14

1.什麼是代理模式

代理模式(Proxy Pattern)是程式設計中的一種設計模式。

在現實生活中,proxy是一個被授權代表其他人的人。比如,許多州允許代理投票,這意味著你可以授權他人在選舉中代表你投票。

你很可能聽說過proxy伺服器,它會接收來自你這的所有流量,代表你傳送給另一端,並把響應返回給你。當你不希望請求的目的地知道你請求的具體來源時,使用proxy伺服器就很有用了。所有的目標伺服器看到的只是來自proxy伺服器的請求。

再接近本文的主題一些,這種型別的代理和ES6 proxy要做的就很類似了,涉及到使用類(B)去包裝類(A)並攔截/控制對(A)的訪問。

當你想進行以下操作時proxy模式通常會很有用:

  • 攔截或控制對某個物件的訪問
  • 通過隱藏事務或輔助邏輯來減小方法/類的複雜性
  • 防止在未經驗證/準備的情況下執行重度依賴資源的操作

當一個複雜物件的多份副本須存在時,代理模式可以結合享元模式以減少記憶體用量。典型作法是建立一個複雜物件及多個代理者,每個代理者會引用到原本的複雜物件。而作用在代理者的運算會轉送到原本物件。一旦所有的代理者都不存在時,複雜物件會被移除。

上面是維基百科中對代理模式的一個整體的定義.而在JavaScript中代理模式的具體表現形式就是ES6中的新增物件---Proxy

2 ES6中的代理模式

ES6所提供Proxy建構函式能夠讓我們輕鬆的使用代理模式:

let proxy = new Proxy(target, handler);
複製程式碼

Proxy建構函式傳入兩個引數,第一個引數target表示所要代理的物件,第二個引數handler也是一個物件用來設定對所代理的物件的行為。如果想知道Proxy的具體使用方法,可參考阮一峰的《 ECMAScript入門 - Proxy 》

Proxy構造器可以在全域性物件上訪問到。通過它,你可以有效的攔截針對物件的各種操作,收集訪問的資訊,並按你的意願返回任何值。從這個角度來說,proxy和中介軟體有很多相似之處。

let dataStore = {
  name: 'Billy Bob',
  age: 15
};

let handler = {
  get(target, key, proxy) {
    const today = new Date();
    console.log(`GET request made for ${key} at ${today}`);
    return Reflect.get(target, key, proxy);
  }
}

dataStore = new Proxy(dataStore, handler);

// 這會執行我們的攔截邏輯,記錄請求並把值賦給`name`變數
const name = dataStore.name;
複製程式碼

具體來說,proxy允許你攔截許多物件上常用的方法和屬性,最常見的有getsetapply(針對函式)和construct(針對使用new關鍵字呼叫的建構函式)。關於使用proxy可以攔截的方法的完整列表,請參考規範。Proxy還可以配置成隨時停止接受請求,有效的取消所有針對被代理的目標物件的訪問。這可以通過一個revoke方法實現。

3 代理模式常用場景

3.1 剝離驗證邏輯

一個把Proxy用於驗證的例子,驗證一個資料來源中的所有屬性都是同一型別。下面的例子中我們要確保每次給numericDataStore資料來源設定一個屬性時,它的值必須是數字。

let numericDataStore = {
  count: 0,
  amount: 1234,
  total: 14
};

numericDataStore = new Proxy(numericDataStore, {
  set(target, key, value, proxy) {
    if (typeof value !== 'number') {
      throw Error("Properties in numericDataStore can only be numbers");
    }
    return Reflect.set(target, key, value, proxy);
  }
});

// 這會丟擲異常
numericDataStore.count = "foo";

// 這會設定成功
numericDataStore.count = 333;
複製程式碼

這很有意思,但有多大的可能性你會建立一個這樣的物件呢?肯定不會。。。

如果你想為一個物件上的部分或全部屬性編寫自定義的校驗規則,程式碼可能會更復雜一些,但我非常喜歡Proxy可以幫你把校驗程式碼與核心程式碼分離開這一點。難道只有我討厭把校驗程式碼和方法或類混在一起嗎?

// 定義一個接收自定義校驗規則並返回一個proxy的校驗器
function createValidator(target, validator) {
  return new Proxy(target, {
    _validator: validator,
    set(target, key, value, proxy) {
      if (target.hasOwnProperty(key)) {
        let validator = this._validator[key];
        if (!!validator(value)) {
          return Reflect.set(target, key, value, proxy);
        } else {
          throw Error(`Cannot set ${key} to ${value}. Invalid.`);
        }
      } else {
        // 防止建立一個不存在的屬性
        throw Error(`${key} is not a valid property`)
      }
    }
  });
}

// 定義每個屬性的校驗規則
const personValidators = {
  name(val) {
    return typeof val === 'string';
  },
  age(val) {
    return typeof age === 'number' && age > 18;
  }
}
class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
    return createValidator(this, personValidators);
  }
}

const bill = new Person('Bill', 25);

// 以下的操作都會丟擲異常
bill.name = 0;
bill.age = 'Bill';
bill.age = 15;
複製程式碼

通過這種方式,你就可以無限的擴充套件校驗規則而不用修改類或方法。

再說一個和校驗有關的點子。假設你想檢查傳給一個方法的引數並在傳入的引數與函式簽名不符時輸出一些有用的幫助資訊。你可以通過Proxy實現此功能,而不用修改該方法的程式碼。

let obj = {
  pickyMethodOne: function(obj, str, num) { /* ... */ },
  pickyMethodTwo: function(num, obj) { /*... */ }
};

const argTypes = {
  pickyMethodOne: ["object", "string", "number"],
  pickyMethodTwo: ["number", "object"]
};

obj = new Proxy(obj, {
  get: function(target, key, proxy) {
    var value = target[key];
    return function(...args) {
      var checkArgs = argChecker(key, args, argTypes[key]);
      return Reflect.apply(value, target, args);
    };
  }
});

function argChecker(name, args, checkers) {
  for (var idx = 0; idx < args.length; idx++) {
    var arg = args[idx];
    var type = checkers[idx];
    if (!arg || typeof arg !== type) {
      console.warn(`You are incorrectly implementing the signature of ${name}. Check param ${idx + 1}`);
    }
  }
}

obj.pickyMethodOne();
// > You are incorrectly implementing the signature of pickyMethodOne. Check param 1
// > You are incorrectly implementing the signature of pickyMethodOne. Check param 2
// > You are incorrectly implementing the signature of pickyMethodOne. Check param 3

obj.pickyMethodTwo("wopdopadoo", {});
// > You are incorrectly implementing the signature of pickyMethodTwo. Check param 1

// 不會輸出警告資訊
obj.pickyMethodOne({}, "a little string", 123);
obj.pickyMethodOne(123, {});
複製程式碼

在看一個表單驗證的例子。Proxy建構函式第二個引數中的set方法,可以很方便的驗證向一個物件的傳值。我們以一個傳統的登陸表單舉例,該表單物件有兩個屬性,分別是accountpassword,每個屬性值都有一個簡單和其屬性名對應的驗證方法,驗證規則如下:

// 表單物件
const userForm = {
  account: '',
  password: '',
}

// 驗證方法
const validators = {
  account(value) {
    // account 只允許為中文
    const re = /^[\u4e00-\u9fa5]+$/;
    return {
      valid: re.test(value),
      error: '"account" is only allowed to be Chinese'
    }
  },

  password(value) {
    // password 的長度應該大於6個字元
    return {
      valid: value.length >= 6,
      error: '"password "should more than 6 character'
    }
  }
}
複製程式碼

下面我們來使用Proxy實現一個通用的表單驗證器

const getValidateProxy = (target, validators) => {
  return new Proxy(target, {
    _validators: validators,
    set(target, prop, value) {
      if (value === '') {
        console.error(`"${prop}" is not allowed to be empty`);
        return target[prop] = false;
      }
      const validResult = this._validators[prop](value);
      if(validResult.valid) {
        return Reflect.set(target, prop, value);
      } else {
        console.error(`${validResult.error}`);
        return target[prop] = false;
      }
    }
  })
}

const userFormProxy = getValidateProxy(userForm, validators);
userFormProxy.account = '123'; // "account" is only allowed to be Chinese
userFormProxy.password = 'he'; // "password "should more than 6 character
複製程式碼

我們呼叫getValidateProxy方法去生成了一個代理物件userFormProxy,該物件在設定屬性的時候會根據validators的驗證規則對值進行校驗。這我們使用的是console.error丟擲錯誤資訊,當然我們也可以加入對DOM的事件來實現頁面中的校驗提示。

3.2 真正的私有屬性

在JavaScript中常見的做法是在屬性名之前或之後放一個下劃線來標識該屬性僅供內部使用。但這並不能阻止其他人讀取或修改它。

在下面的例子中,有一個我們想在api物件內部訪問的apiKey變數,但我們並不想該變數可以在物件外部訪問到。

var api = {
  _apiKey: '123abc456def',
  /* mock methods that use this._apiKey */
  getUsers: function(){}, 
  getUser: function(userId){}, 
  setUser: function(userId, config){}
};

// logs '123abc456def';
console.log("An apiKey we want to keep private", api._apiKey);

// get and mutate _apiKeys as desired
var apiKey = api._apiKey;  
api._apiKey = '987654321'; 
複製程式碼

通過使用ES6 Proxy,你可以通過若干方式來實現真實,完全的私有屬性。

首先,你可以使用一個proxy來截獲針對某個屬性的請求並作出限制或是直接返回undefined

var api = {  
  _apiKey: '123abc456def',
  /* mock methods that use this._apiKey */
  getUsers: function(){ }, 
  getUser: function(userId){ }, 
  setUser: function(userId, config){ }
};

// Add other restricted properties to this array
const RESTRICTED = ['_apiKey'];

api = new Proxy(api, {  
    get(target, key, proxy) {
        if(RESTRICTED.indexOf(key) > -1) {
            throw Error(`${key} is restricted. Please see api documentation for further info.`);
        }
        return Reflect.get(target, key, proxy);
    },
    set(target, key, value, proxy) {
        if(RESTRICTED.indexOf(key) > -1) {
            throw Error(`${key} is restricted. Please see api documentation for further info.`);
        }
        return Reflect.get(target, key, value, proxy);
    }
});

// throws an error
console.log(api._apiKey);

// throws an error
api._apiKey = '987654321'; 
複製程式碼

你還可以使用hastrap來掩蓋這個屬性的存在。

var api = {  
  _apiKey: '123abc456def',
  /* mock methods that use this._apiKey */
  getUsers: function(){ }, 
  getUser: function(userId){ }, 
  setUser: function(userId, config){ }
};

// Add other restricted properties to this array
const RESTRICTED = ['_apiKey'];

api = new Proxy(api, {  
  has(target, key) {
    return (RESTRICTED.indexOf(key) > -1) ?
      false :
      Reflect.has(target, key);
  }
});

// these log false, and `for in` iterators will ignore _apiKey

console.log("_apiKey" in api);

for (var key in api) {  
  if (api.hasOwnProperty(key) && key === "_apiKey") {
    console.log("This will never be logged because the proxy obscures _apiKey...")
  }
} 
複製程式碼

3.3 默默的記錄物件訪問

針對那些重度依賴資源,執行緩慢或是頻繁使用的方法或介面,你可能喜歡統計它們的使用或是效能。Proxy可以很容易的悄悄在後臺做到這一點。

注意:你不能僅僅使用applytrap來攔截方法。任何使用當你要執行某個方法時,你首先需要get這個方法。因此,如果你要攔截一個方法呼叫,你需要先攔截對該方法的get操作,然後攔截apply操作。

let api = {  
  _apiKey: '123abc456def',
  getUsers: function() { /* ... */ },
  getUser: function(userId) { /* ... */ },
  setUser: function(userId, config) { /* ... */ }
};

api = new Proxy(api, {  
  get: function(target, key, proxy) {
    var value = target[key];
    return function(...arguments) {
      logMethodAsync(new Date(), key);
      return Reflect.apply(value, target, arguments);
    };
  }
});

// executes apply trap in the background
api.getUsers();

function logMethodAsync(timestamp, method) {  
  setTimeout(function() {
    console.log(`${timestamp} - Logging ${method} request asynchronously.`);
  }, 0)
} 
複製程式碼

這很酷,因為你可以記錄各種各樣的資訊而不用修改應用程式的程式碼或是阻塞程式碼執行。並且只需要在這些程式碼的基礎上稍事修改就可以記錄特性函式的執行效能了。

3.4 給出提示資訊或是阻止特定操作

假設你想阻止其他人刪除noDelete屬性,想讓呼叫oldMethod方法的人知道該方法已經被廢棄,或是想阻止其他人修改doNotChange屬性。以下是一種快捷的方法。

let dataStore = {
  noDelete: 1235,
  oldMethod: function() {/*...*/ },
  doNotChange: "tried and true"
};

const NODELETE = ['noDelete'];
const DEPRECATED = ['oldMethod'];
const NOCHANGE = ['doNotChange'];

dataStore = new Proxy(dataStore, {
  set(target, key, value, proxy) {
    if (NOCHANGE.includes(key)) {
      throw Error(`Error! ${key} is immutable.`);
    }
    return Reflect.set(target, key, value, proxy);
  },
  deleteProperty(target, key) {
    if (NODELETE.includes(key)) {
      throw Error(`Error! ${key} cannot be deleted.`);
    }
    return Reflect.deleteProperty(target, key);

  },
  get(target, key, proxy) {
    if (DEPRECATED.includes(key)) {
      console.warn(`Warning! ${key} is deprecated.`);
    }
    var val = target[key];

    return typeof val === 'function' ?
      function(...args) {
        Reflect.apply(target[key], target, args);
      } :
      val;
  }
});

// these will throw errors or log warnings, respectively
dataStore.doNotChange = "foo";  
delete dataStore.noDelete;  
dataStore.oldMethod(); 
複製程式碼

3.5 防止不必要的資源消耗操作--快取代理

假設你有一個伺服器介面返回一個巨大的檔案。當前一個請求還在處理中,或是檔案正在被下載,又或是檔案已經被下載之後你不想該介面被再次請求。代理在這種情況下可以很好的緩衝對伺服器的訪問並在可能的時候讀取快取,而不是按照使用者的要求頻繁請求伺服器。快取代理可以將一些開銷很大的方法的運算結果進行快取,再次呼叫該函式時,若引數一致,則可以直接返回快取中的結果,而不用再重新進行運算。例如在採用後端分頁的表格時,每次頁碼改變時需要重新請求後端資料,我們可以將頁碼和對應結果進行快取,當請求同一頁時就不用在進行ajax請求而是直接返回快取中的資料。在這裡我會跳過大部分程式碼,但下面的例子還是足夠向你展示它的工作方式。

let obj = {  
  getGiantFile: function(fileId) {/*...*/ }
};

obj = new Proxy(obj, {  
  get(target, key, proxy) {
    return function(...args) {
      const id = args[0];
      let isEnroute = checkEnroute(id);
      let isDownloading = checkStatus(id);      
      let cached = getCached(id);

      if (isEnroute || isDownloading) {
        return false;
      }
      if (cached) {
        return cached;
      }
      return Reflect.apply(target[key], target, args);
    }
  }
}); 
複製程式碼

再列舉一個比較好理解的例子

下面我們以沒有經過任何優化的計算斐波那契數列的函式來假設為開銷很大的方法,這種遞迴呼叫在計算40以上的斐波那契項時就能明顯的感到延遲感。

const getFib = (number) => {
  if (number <= 2) {
    return 1;
  } else {
    return getFib(number - 1) + getFib(number - 2);
  }
}
複製程式碼

現在我們來寫一個建立快取代理的工廠函式:

const getCacheProxy = (fn, cache = new Map()) => {
  return new Proxy(fn, {
    apply(target, context, args) {
      const argsString = args.join(' ');
      if (cache.has(argsString)) {
        // 如果有快取,直接返回快取資料
        console.log(`輸出${args}的快取結果: ${cache.get(argsString)}`);
        return cache.get(argsString);
      }
      const result = fn(...args);
      cache.set(argsString, result);
      return result;
    }
  })
}

const getFibProxy = getCacheProxy(getFib);
getFibProxy(40); // 102334155
getFibProxy(40); // 輸出40的快取結果: 102334155
複製程式碼

當我們第二次呼叫getFibProxy(40)時,getFib函式並沒有被呼叫,而是直接從cache中返回了之前被快取好的計算結果。通過加入快取代理的方式,getFib只需要專注於自己計算斐波那契數列的職責,快取的功能使由Proxy物件實現的。這實現了我們之前提到的單一職責原則

3.6. 即時撤銷對敏感資料的訪問

Proxy支援隨時撤銷對目標物件的訪問。當你想徹底封鎖對某些資料或API的訪問時(比如,出於安全,認證,效能等原因),這可能會很有用。以下是一個使用revocable方法的簡單例子。注意當你使用它時,你不需要對Proxy方法使用new關鍵字。

let sensitiveData = {  
  username: 'devbryce'
};

const {sensitiveData, revokeAccess} = Proxy.revocable(sensitiveData, handler);

function handleSuspectedHack(){  
  // Don't panic
  // Breathe
  revokeAccess();
}

// logs 'devbryce'
console.log(sensitiveData.username);

handleSuspectedHack();

// TypeError: Revoked
console.log(sensitiveData.username); 
複製程式碼

好吧,以上就是所有我要講的內容。我很希望能聽到你在工作中是如何使用Proxy的。

4 總結

在物件導向的程式設計中,代理模式的合理使用能夠很好的體現下面兩條原則:

  • 單一職責原則: 物件導向設計中鼓勵將不同的職責分佈到細粒度的物件中,Proxy 在原物件的基礎上進行了功能的衍生而又不影響原物件,符合鬆耦合高內聚的設計理念。

  • 開放-封閉原則:代理可以隨時從程式中去掉,而不用對其他部分的程式碼進行修改,在實際場景中,隨著版本的迭代可能會有多種原因不再需要代理,那麼就可以容易的將代理物件換成原物件的呼叫

對於代理模式 Proxy 的作用主要體現在三個方面:

1、 攔截和監視外部對物件的訪問

2、 降低函式或類的複雜度

3、 在複雜操作前對操作進行校驗或對所需資源進行管理

參考:

相關文章