Well in Time
一起來了解 Javascript 中的 Proxy 與 Reflect
05-27-201815 Min Read

『強者是不會待在同一個地方太久的。』

前言

在眾多 ES6 提供的新功能上,Proxy 與 Reflect 算是最少被提及的,主要原因我想還是因為瀏覽器的支援度較低,不過在我前陣子看到 Frameworkless JavaScript Part 3: One-Way Data Binding 這篇文章時(好文推薦!很有趣),特意去查了一下才發現目前支援度已經越來越好:

Can I Use Proxy

Can I Use Reflect

常用的瀏覽器幾乎都支援,我想也是可以來好好了解一下這兩個神奇的物件了!

最後有個參考該篇文章實作的 Todo app 範例,如果懶得看介紹的可以先 跳下去 玩玩,但若是對 Proxy 與 Reflect 不了解的人還是建議先看一下。

Proxy

Proxy 物件被使用於定義基本操作的自定行為(例如:尋找屬性、賦值、列舉、函式調用等等)。 - MDN

不知道為什麼唸起來有點饒口,但基本上跟其字面意思相同,就是代理(代為管理)物件行為。

Proxy 是一個函式物件(可被建構),他提供一個機會讓你能介入一般物件的基本操作行為,像是在你 assign 一個值給某個物件時,可以透過 Proxy 先進行一些 validation 等等,藉此讓使用被代理過後的物件之開發者可以專注在其他核心功能上。

咦?聽起來很像許多 framework 或 helper library 會做的事情?有趣!讓我們繼續看下去。

使用方法如下:

const proxyObj = new Proxy(target, handler);

target 就是你想要代理的對象;而 handler 則是一個物件,其中定義了所有你想替 target 代為管理的操作定義,包含了:

  • construct(target, args) - 代理 Object 的 new operator
  • get(target, prop, receiver) - 代理 Object getting properties 時的行為
  • set(target, prop, value, receiver) - 代理 Object setting properties 時的行為
  • apply(target, object, args) - 代理 function call,像是 f.apply()
  • has(target, prop) - 代理 in operator
  • defineProperty(target, propKey, propDesc) - 代理 Object.defineProperty.
  • deleteProperty(target, prop) - 代理 delete operator
  • getOwnPropertyDescriptor(target, prop) - 代理 Object.getOwnPropertyDescriptor.
  • getPrototypeOf(target) - 代理 Object.getPrototypeOf.
  • setPrototypeOf(target, proto) - 代理 Object.setPrototypeOf.
  • ownKeys(target) - 代理 Object.getOwnPropertyNamesObject.getOwnPropertySymbols.
  • isExtensible(target) - 代理 Object.isExtensible.
  • preventExtensions(target) - 代理 Object.preventExtensions.

handler object 所包含的 method 定義可以從 MDN 看到更多範例與描述。

說了這麼多,我們到底能拿 Proxy 來做什麼呢?直接來點範例吧!

直觀的私有變數

以往在 Javascript 中,我們可能需要透過 closure 來實現物件的私有變數,像是:

const FooBar = function() {
  this.closeTime = 'never';
  this.setSecretDrink = (secret) => { secretDrink = secret; }
  this.getSecretDrink = () => { return secretDrink; }
}

const fooBar = new FooBar();
fooBar.setSecretDrink('Jäger Bom');
console.log(fooBar.getSecretDrink()); // 'Jäger Bom'
console.log(fooBar.closeTime); // never
console.log(fooBar.secretDrink); // undefined

但透過 Proxy,我們可以很直觀地在一個 Object 內達成類似效果:

let FooBar = {
  _secretDrink: 'Jäger Bom',
  closeTime: 'never'
};

FooBarProxy = new Proxy(FooBar, {
  get: function(target, prop) {
    // 以底線開頭的作為私有變數
    if (prop.startsWith('_')) {
      console.log('不能存取私有變數!');
      return false;
    }
    return target[prop]; // 非私有變數,那就回傳原物件的原屬性值
  },
  set: function(target, prop, value) {
    if (prop.startsWith('_')) {
      console.log('不能修改私有變數!');
      return false;
    }
    target[prop] = value;
  },
  has: function(target, prop) {
    return prop.startsWith('_') ? false : (prop in target);
  }
});

FooBarProxy._secretDrink; // 不能存取私有變數!
console.log(FooBarProxy.closeTime); // never
FooBarProxy._secretDrink = 'Cola'; // 不能修改私有變數!
console.log('_secretDrink' in FooBarProxy); // false
console.log('closeTime' in FooBarProxy); // true

眼尖一點的讀者可能會發現,這邊 handler 裡面的 getset 好像跟上面定義中的參數不同,少了 receiver 這個參數?

沒錯,這個神奇的第三個參數其實是指向你產生的 Proxy 實例,以上面例子來看就是 FooBarProxy 本身,由於範例中用不到,所以不宣告也沒關係,不過晚點在 Reflect 的介紹會再度提起。

另外,若是沒有被你代理到的操作,則會直接 fallback 回原始 target 物件的操作上。

在設置物件屬性前進行 Validation

延續剛剛的例子,我們的 FooBar 除了秘密飲料外,也需要紀錄一下基本資訊,像是電話、地址等等,這時候 Proxy 就能為我們帶來另一個好處:驗證屬性值:

let FooBar = {
  _secretDrink: 'Jäger Bom',
  closeTime: 'never',
  phoneNumber: '02-2849-2839'
};

FooBar = new Proxy(FooBar, {
  set: function(target, prop, value) {
    if (prop === 'phoneNumber') {
      // phone number validation
      var re = /^\(?\d{2}\)?[\s\-]?\d{4}\-?\d{4}$/;
      if (!re.test(value)) {
        throw Error(`Cannot set ${prop} to ${value}. Wrong format. Should be xx-xxxx-xxxx`);
      }
    }
    target[prop] = value;
  },
  //..
});

proxy example validation

只要你設置的 phoneNumber 不符合 regex 的規則,就會拋出一個 Error 告訴開發者,此物件的 phoneNumber 屬性值是有固定 format 的。

當然,javascript 充滿彈性,你也可以有彈性一點的寫法,把 validator 抽離出來:

var BarValidator = {
  _secretDrink: function (value) {
    if (value === 'cola') {
      throw Error('lame...');
    }
  },
  phoneNumber: function(value) {
    var re = /^\(?\d{2}\)?[\s\-]?\d{4}\-?\d{4}$/;
    if (!re.test(value)) {
      throw Error(`Cannot set phoneNumber to ${value}. Wrong format. Should be xx-xxxx-xxxx`);
    }
  }
};

FooBar = new Proxy(FooBar, {
  set: function(target, prop, value) {
    BarValidator[prop](value);
    target[prop] = value;
  },
  //..
});

用來設定屬性預設值

// 實際上沒有這個屬性
console.log(FooBar.revenue); // undefined

// 但經過 Proxy 後
FooBar = new Proxy(FooBar, {
  get: function(target, prop, value) {
    if(prop === 'revenue') {
      return 'None of your business';
    }
    return target[prop];
  },
  //..
});
// 可以讀取到我們設定的預設值
console.log(FooBar.revenue); // 'None of your business'

複寫原有物件,讓測試更加順利(mock object)

寫測試的時候很常會需要 mock object,像是 function 中若有讀取 document.location.href 的部分,在你開發機上基本上都會是 localhost,這時候就會需要把這個值 mock 掉。

這時我們就可以將 document.location 委託給 proxy 代理:

const mockDocument = {
  location: new Proxy(document.location, {
      get: function(target, prop) {
          if (prop == "href")
              return "your-website-com";
          return target[prop];
      }
  })
};

console.log("location href: ", mockLocation.location.href); // https://blog.arvinh.info

看到這邊想必很多人都會想到,我們可以實作 Observe function!

function observe(o, callback) {
  return new Proxy(o, {
    set(target, property, value) {
      callback(property, value);
      target[property] = value;
    },
  });
}

const FooBar = { open: false };
const FooBarObserver = observe(FooBar, (property, value) => {
  property === 'open' && value ? alert('FooBar is open!!!') : console.log('keep waiting');
});
FooBarObserver.open = true;

不是什麼都可以被代理的

不知道大家會不會有個疑問,難道所有物件都能被 proxy 代理嗎?有沒有辦法限制我的某個物件就是不希望被他人代理?

當然有!

如果你的物件擁有 configurable: falsewritable: false 的屬性,那該物件就無法被 proxy 代理:

const target = Object.defineProperties({}, {
  FooBar: {
    writable: false,
    configurable: false
  },
});

const handler = {
  get(target, propKey) {
    return '???';
  }
};

const proxy = new Proxy(target, handler);

proxy.FooBar
// Uncaught TypeError: 'get' on proxy: property 'FooBar' is a read-only and non-configurable data property on the proxy target but the proxy did not return its actual value (expected 'undefined' but got '???')

小結論

這邊我只列了幾個我覺得比較能凸顯 Proxy 用途的範例,而其他 handler 可以介入的操作如果大家也想了解並看看例子的話,阮一峰的 ECMAScript 6 入門 中有針對每個操作給予例子做解析,可以參考。

Reflect

接著我們來看看 Reflect。Reflect 不能建構實例,就像 Math 一樣,單純包含了一系列的靜態方法。

Reflect 與 Proxy 的完美搭配

網路上許多文章都說 Reflect 是因應 Proxy 才增加的規範,最明確的連結是,Reflect 所定義的靜態方法包含了 Proxy Handler 能處理的所有代理操作,但他提供的是呼叫原始物件的操作,舉例來說:

Reflect.get(target, name); 效果等同於 target[name];

所以我們在 Proxy 中,如果需要 target 物件的預設操作,使用 Reflect 會更合理更清楚:

const loggedObj = new Proxy(obj, {
  get: function(target, name) {
    console.log("get", target, name);
    return Reflect.get(target, name);
  }
});

主要的理由在於,Reflect 讓我們對物件的操作可以用函數來處理

例如在判斷物件有無特定屬性,或是刪除物件屬性時,以往我們會這樣做:

'_secretDrink' in FooBar;

delete Object._secretDrink;

有了 Reflect 我們可以這樣做:

Reflect.has(FooBar, '_secretDrink');

Reflect.deleteProperty(FooBar, '_secretDrink');

因此,在 Proxy 中,比起使用 delete target[name], Reflect.deleteProperty 更能保持一制性:

const loggedObj = new Proxy(obj, {
  deleteProperty: function(target, name) {
    // instead of `delete target[name]...
    return Reflect.deleteProperty(target, name);
  }
});

控制被 Proxy 代理的函數之 this 參考對象

這個例子比較難懂,但這是說明為何 Reflect 是因應 Proxy 而生的好例子(source):

const target = {
    get foo() {
        return this.bar;
    },
    bar: 3
};
const handler = {
    get(target, propertyKey, receiver) {
        if (propertyKey === 'bar') return 2;
        console.log('Reflect.get ', Reflect.get(target, propertyKey, receiver)); // this in foo getter references Proxy instance; logs 2
        console.log('target[propertyKey] ', target[propertyKey]); // this in foo getter references "target" - logs 3
    }
};
const obj = new Proxy(target, handler);
console.log(obj.bar);
// 2
obj.foo;
// Reflect.get  2
// target[propertyKey]  3

JS Bin on jsbin.com

假設你的 object target 有一個 getter 函數 foo(),現在你透過 Proxy 代理 get 函數,當今天你呼叫 obj.bar 時,會印出 2,因為 Proxy handler 攔截並代理了原始 target 物件的 get 函數;接著,若你呼叫 obj.foo,會出現兩個結果: Reflect.get 2target[propertyKey] 3

為什麼?

這是因為只有透過 Reflect.get() 的第三個參數 receiver,將指向 Proxy 本身的實例傳進去原始物件的 get 呼叫,才能夠真的呼叫到 Proxy.get。

若是直接透過 target['foo'],則原本在 foo 中的 this,就會指向原始的 target 本身,而不會觸發 Proxy 的 get。

這邊概念真的比較難懂,若我有任何錯誤地方歡迎指正,我相信大家多看幾次範例後都能悟道的。

除了與 Proxy 匹配的優勢外,Reflect 還帶來了一些好處(source: Benefits of ES6 Reflect API, Harmony-reflect):

更優雅、更好用的回傳值

以往使用 Object.defineProperty(obj, name, desc); 時,若成功,會回傳 obj,失敗則有可能會拋出 Error。而使用 Reflect.defineProperty(obj, name, desc) 的話,則會回傳 boolean 值,讓失敗或成功的結果有統一的格式。

更可靠的 apply

在 es5 時,大家都很習慣透過 f.apply(obj, args) 的方式來 apply 函數到物件上頭,但很有可能在某些情況下,f.apply 被串改了,這時候就會有不預期的結果。

Senior 一點的會知道可以利用 Function.prototype.apply.call(f, obj, args) 來呼叫,至少 prototype 不會騙你,但這種方式總是不夠優雅。

現在有了 Reflect 後,就不需要擔心這種事情,透過 Reflact.apply(obj, args) 就能輕鬆達到一樣效果。

接受可變參數的 Constructor

這個優點只有跟 ES5 比較時才有優勢。主要是讓你能透過:const obj = Reflect.construct(FooBar, args) 來在建構物件實例時,傳遞可變參數;若是在 ES5 的世界,只有 FooBar.applyFooBar.call 能夠接受變動參數,但是在 new 物件實例時,並沒有 applycall 可以使用。

而現在透過 ES6 的 spread syntax,我們可以在建構物件實例時,直接傳遞可變參數:const obj = new FooBar(...args)

最終範例:利用 Proxy 與 Reflect 完成 one way data binding

在了解完 Proxy 與 Reflect 的基本使用方式後,想分享一個很有趣的應用,也就是我開頭提到,激發我研究 Proxy 的範例:frameworkless js one way data binding.

結合先前提過的 Observe function,來實作一個簡單 Todo App:

See the Pen Oneway-data-binding-js-proxy-reflect by Arvin (@arvin0731) on CodePen.

基本上是結合了上述介紹的 Proxy 與 Reflect 特性,並融合[這篇文章]((https://jack.ofspades.com/frameworkless-javascript-part-3-one-way-data-binding/)的範例所製作的,如果大家有看完前面的介紹,對於這段 code 應該不難理解。

主要是透過 Proxy 來代理 Object 的 setdeleteProperty 操作,讓 Todo list 的變動能夠被代理。

此外,在先前的介紹中,都是以 Object 為主,但 todo app 範例中被 Proxy 代理的是 Array。

最大的差別在於,當你 push 一個新 item 進入 Array 時,set 會被呼叫兩次,一次是新的 item 被塞入陣列時,一次是 Array 的 length property 加一時。所以要特別濾掉 length 更動的那次代理操作。

最後,只要在代理的操作中,想辦法把 DOM 做對應的修改,如同上面程式中的 line 22 ~ line 40,定義一些 render template 的 function 來更新 DOM 即可。

結論

Javascript 的變動總是快於瀏覽器支援度,所以常常造成一些新的 Spec 我們不熟悉、不知如何運用,這次的研究學習了不少,而上面的 Todo App 是個簡陋不嚴謹的範例,不過也足以展現 Proxy 與 Reflect 在實際運用上的情境,並帶給我們另一種思考方向,很多時候不用一開始就套用 Framework,透過越來越進步的瀏覽器與 ES 版本,我們也能達到一樣目的。雖然比不上 framework 包山包海的優化,但或許能讓我們更了解實際要解決的問題是什麼,以及解決方法背後的概念。

資料來源

  1. Frameworkless JavaScript Part 3: One-Way Data Binding
  2. MDN Proxy
  3. ES6 之 Proxy 介绍
  4. ECMAScript 6 入门
© by Arvin Huang. All rights reserved.
theme inspired by @mhadaily
Last build: 09-08-2024