ES2015 Proxy 簡介

Addy Osmani
Addy Osmani

ES2015 代理伺服器 (在 Chrome 49 以上版本中) 會為 JavaScript 提供中介 API,讓我們能夠擷取或攔截目標物件上的所有作業,並修改這個目標的運作方式。

代理伺服器的用途非常多,包括:

  • 攔截
  • 物件虛擬化
  • 資源管理
  • 進行剖析或記錄以便偵錯
  • 安全性和存取權控管
  • 物件使用合約

Proxy API 包含 Proxy 建構函式,可接收指定的目標物件和處理常式物件。

var target = { /* some properties */ };
var handler = { /* trap functions */ };
var proxy = new Proxy(target, handler);

代理程式行為由處理常式控制,後者可透過多種實用方式修改目標物件的原始行為。這個處理程序包含在 Proxy 上執行對應作業時呼叫的選用陷阱方法 (例如 .get().set().apply())。

攔截

我們先從使用 Proxy API 取得一般物件並加入一些攔截中介軟體開始。請注意,傳遞至建構函式的第一個參數是目標 (要建立 Proxy 的物件),第二個則是處理程序 (Proxy 本身)。我們可以在此處為 getter、setter 或其他行為新增鉤子。

var target = {};

var superhero = new Proxy(target, {
    get: function(target, name, receiver) {
        console.log('get was called for:', name);
        return target[name];
    }
});

superhero.power = 'Flight';
console.log(superhero.power);

在 Chrome 49 中執行上述程式碼,我們會得到以下結果:

get was called for: power  
"Flight"

如您在實際操作中所見,在 Proxy 物件上正確執行屬性取得或屬性設定作業,會導致在處理常式上對相應陷阱進行中繼層級呼叫。處理常式作業包括屬性讀取、屬性指派和函式應用,所有這些作業都會轉送至對應的陷阱。

陷阱函式可以自行決定是否要任意實作作業 (例如將作業轉送至目標物件)。如未指定陷阱,系統會預設執行這個動作。例如,以下是只執行此操作的無操作轉送 Proxy:

var target = {};

var proxy = new Proxy(target, {});
    // operation forwarded to the target
proxy.paul = 'irish';
// 'irish'. The operation has been  forwarded
console.log(target.paul);

我們剛剛討論了如何為一般物件建立 Proxy,但我們也可以輕鬆為函式物件建立 Proxy,其中函式就是我們的目標。這次我們會使用 handler.apply() 陷阱:

// Proxying a function object
function sum(a, b) {
    return a + b;
}

var handler = {
    apply: function(target, thisArg, argumentsList) {
        console.log(`Calculate sum: ${argumentsList}`);
        return target.apply(thisArg, argumentsList);
    }
};

var proxy = new Proxy(sum, handler);
proxy(1, 2);
// Calculate sum: 1, 2
// 3

識別 Proxy

您可以使用 JavaScript 相等運算子 (=====) 查看 Proxy 的 ID。如您所知,當這些運算子套用至兩個物件時,會比較物件 ID。下列範例說明瞭這項行為。比較兩個不同的 Proxy 會傳回 false,即使底層目標相同也一樣。同樣地,目標物件與任何 Proxy 都不同:

// Continuing previous example

var proxy2 = new Proxy (sum, handler);
(proxy==proxy2); // false
(proxy==sum); // false

理想情況下,您不應能將 Proxy 與非 Proxy 物件區分開,這樣 Proxy 就不會對應用程式結果造成影響。這也是 Proxy API 不提供檢查物件是否為 Proxy 的方法,也不會為物件上的所有作業提供陷阱的原因之一。

用途

如前所述,Proxy 的用途相當多元。上述許多功能 (例如存取權控制和剖析) 都屬於「一般包裝函式」:在相同位址「空間」中包裝其他物件的 Proxy。也提到了虛擬化。虛擬物件是模擬其他物件的 Proxy,但這些物件不必位於相同的位址空間。例如遠端物件 (模擬其他空間中的物件) 和透明的未來值 (模擬尚未計算的結果)。

Proxy 做為 Handler

代理程式處理常見用途之一,就是在對包裝物件執行作業前,先執行驗證或存取控制檢查。只有在檢查成功的情況下,作業才會轉送。以下驗證範例說明這一點:

var validator = {
    set: function(obj, prop, value) {
    if (prop === 'yearOfBirth') {
        if (!Number.isInteger(value)) {
        throw new TypeError('The yearOfBirth is not an integer');
        }

        if (value > 3000) {
        throw new RangeError('The yearOfBirth seems invalid');
        }
    }

    // The default behavior to store the value
    obj[prop] = value;
    }
};

var person = new Proxy({}, validator);

person.yearOfBirth = 1986;
console.log(person.yearOfBirth); // 1986
person.yearOfBirth = 'eighties'; // Throws an exception
person.yearOfBirth = 3030; // Throws an exception

這個模式的更複雜範例可能會考量所有代理程式處理常式可攔截的不同作業。您可以想像,實作時必須重複複製存取檢查模式,並在每個陷阱中轉發作業。

由於每個作業可能需要以不同方式轉送,因此這可能難以輕鬆抽象化。在理想情況下,如果所有作業都能透過單一陷阱進行統一篩選,則處理程序只需在單一陷阱中執行一次驗證檢查。您可以將 Proxy 處理常式本身做為 Proxy 來執行這項操作。很抱歉,這超出本文的討論範圍。

物件擴充資料

代理程式的另一個常見用途,是擴充或重新定義物件作業的語意。舉例來說,您可能希望處理程序記錄作業、通知觀察器、擲回例外狀況 (而非傳回未定義的值),或將作業重新導向至不同的儲存目標。在這種情況下,使用 Proxy 可能會導致與使用目標物件截然不同的結果。

function extend(sup,base) {

    var descriptor = Object.getOwnPropertyDescriptor(base.prototype,"constructor");

    base.prototype = Object.create(sup.prototype);

    var handler = {
    construct: function(target, args) {
        var obj = Object.create(base.prototype);
        this.apply(target,obj, args);
        return obj;
    },

    apply: function(target, that, args) {
        sup.apply(that,args);
        base.apply(that,args);
    }
    };

    var proxy = new Proxy(base, handler);
    descriptor.value = proxy;
    Object.defineProperty(base.prototype, "constructor", descriptor);
    return proxy;
}

var Vehicle = function(name){
    this.name = name;
};

var Car = extend(Vehicle, function(name, year) {
    this.year = year;
});

Car.prototype.style = "Saloon";

var Tesla = new Car("Model S", 2016);

console.log(Tesla.style); // "Saloon"
console.log(Tesla.name); // "Model S"
console.log(Tesla.year);  // 2016

存取控管

存取權控管是 Proxy 的另一個實用用途。您可以將代理物件傳遞給不受信任的程式碼,以便在某種保護層中傳遞代理物件。一旦應用程式判定不受信任的程式碼已完成特定工作,即可撤銷參照,將 Proxy 從目標中分離。膜會將這個分離作業遞迴地擴展至從已定義的原始目標可存取的所有物件。

搭配 Proxy 使用反射

Reflect 是新的內建物件,可提供可攔截的 JavaScript 作業方法,非常適合用於 Proxy。事實上,反射方法與Proxy 處理常式的運作方式相同。

Python 或 C# 等靜態型別語言早已提供反射 API,但 JavaScript 是動態語言,因此並不需要這類 API。有人認為 ES5 已經有許多反射功能,例如 Array.isArray()Object.getOwnPropertyDescriptor(),這些功能在其他語言中會被視為反射。ES2015 推出了 Reflection API,可用於收納這個類別的未來方法,讓這些方法更容易推理。這很合理,因為 Object 是用來做為基礎原型,而非用來分類反射方法。

使用 Reflect,我們可以改善先前的 Superhero 範例,以便在 get 和 set 陷阱上適當地攔截欄位,如下所示:

// Field interception with Proxy and the Reflect API

var pioneer = new Proxy({}, {
    get: function(target, name, receiver) {
        console.log(`get called for field: ${name}`);
        return Reflect.get(target, name, receiver);
    },

    set: function(target, name, value, receiver) {
        console.log(`set called for field: ${name} and value: ${value}`);
        return Reflect.set(target, name, value, receiver);
    }
});

pioneer.firstName = 'Grace';
pioneer.secondName = 'Hopper';
// Grace
pioneer.firstName

輸出內容:

set called for field: firstName and value: Grace
set called for field: secondName and value: Hopper
get called for field: firstName

另一個例子是使用者可能想要:

  • 在自訂建構函式中包裝 Proxy 定義,避免每次要使用特定邏輯時,都必須手動建立新的 Proxy。

  • 新增「儲存」變更的功能,但只有在資料確實已修改時才會執行 (假設儲存作業非常耗費資源)。

function Customer() {

    var proxy = new Proxy({
    save: function(){
        if (!this.dirty){
        return console.log('Not saving, object still clean');
        }
        console.log('Trying an expensive saving operation: ', this.changedProperties);
    },

    }, {

    set: function(target, name, value, receiver) {
        target.dirty = true;
        target.changedProperties = target.changedProperties || [];

        if(target.changedProperties.indexOf(name) == -1){
        target.changedProperties.push(name);
        }
        return Reflect.set(target, name, value, receiver);
    }

    });

    return proxy;
}


var customer = new Customer();

customer.name = 'seth';
customer.surname = 'thompson';
// Trying an expensive saving operation:  ["name", "surname"]
customer.save();

如需更多 Reflect API 範例,請參閱 Tagtree 的 ES6 Proxy

用 Object.observe() 進行 Polyfilling

雖然我們要向 Object.observe() 說「再見」,但現在可以使用 ES2015 代理程式為這些元素進行 polyfill。Simon Blackwell 最近撰寫了以 Proxy 為基礎的 Object.observe() shim,值得一試。Erik Arvidsson 在 2012 年也曾撰寫相當完整的規格版本。

瀏覽器支援

Chrome 49、Opera、Microsoft Edge 和 Firefox 支援 ES2015 代理程式。Safari 對這項功能的公開信號不一,但我們仍抱持樂觀態度。Reflect 已在 Chrome、Opera 和 Firefox 上推出,並且正在為 Microsoft Edge 開發中。

Google 已發布Proxy 的有限 polyfill。這項功能只能用於一般包裝函式,因為它只能代理 Proxy 建立時已知的屬性。

延伸閱讀