ES2015 Proxy 簡介

阿迪奧斯馬尼
Addy Osmani

ES2015 Proxy (Chrome 49 以上版本) 提供 JavaScript 存取中斷 API,讓我們能夠擷取或攔截目標物件的所有作業,並修改此目標的運作方式。

Proxy 的用途相當多元,包括:

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

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

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

Proxy 的行為是由處理常式控制,處理常式可透過一些實用的方式修改 target 物件的原始行為。處理常式包含在 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 物件上正確執行屬性 get 或屬性 (attribute),會導致中繼層級呼叫處理常式上對應的陷阱。處理常式作業包括屬性讀取、屬性指派和函式應用程式,這些作業都會轉送至對應的陷阱。

Trap 函式可以選擇性地實作運算 (例如將作業轉送至目標物件)。那麼,如果沒有指定陷阱預設會發生什麼事。舉例來說,以下是不需要人工管理的轉送 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 純物件,但也可以輕鬆代理函式物件,其中函式就是目標。這次我們將使用 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 的身分。如我們所知,將這兩個運算子套用至兩個物件時,這些運算子會比較物件身分。下一個範例將說明這個行為。即使基礎目標相同,比較兩個不同的 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,不需要位於同一個位址空間。例如遠端物件 (模擬其他空間中的物件) 和透明的 Future (模擬尚未計算的結果)。

將 Proxy 當做處理常式

Proxy 處理常式常見的用途是先執行驗證或存取控制檢查,然後再對包裝的物件執行作業。只有在檢查成功時,系統才會轉送作業。請參考下列驗證範例:

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 實作。很抱歉,這不在本文的討論範圍內。

物件額外資訊

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。明布將以遞迴方式延伸至可由定義原始目標可連線的所有物件。

透過 Proxy 使用反射

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

Python 或 C# 等靜態類型語言一直都有反射 API,但 JavaScript 其實不需要做為動態語言。據說,ES5 已有許多反射功能,例如 Array.isArray()Object.getOwnPropertyDescriptor(),但可視為其他語言的感受。ES2015 推出了 Reflection API,可用來存放此類別的未來方法,方便您進行推論。由於物件的用途是基本原型,而非反射方法的值區,因此這個做法很合理。

透過 Reflect,我們可以改善之前的超級英雄範例,為 get 和 traps 進行適當的場域攔截,如下所示:

// 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()

雖然我們向 Object.observe()goodbye,但現在可以使用 ES2015 Proxy 將 polyfill。Simon Blackwell 最近編寫了以 Proxy 為基礎的 Object.observe() 填充碼,值得一探究竟。Erik Arvidsson 也一直在 2012 年撰寫出相當規格完整的版本。

瀏覽器支援

Chrome 49、Opera、Microsoft Edge 和 Firefox 都支援 ES2015 Proxy。Safari 對這項功能已有多項公開信號,但我們依然樂觀其成。Reflect 目前採用 Chrome、Opera 和 Firefox,且正在開發 Microsoft Edge。

Google 發布 Proxy 專用的 polyfill。這個參數只能用於一般包裝函式,因為這個包裝函式只能在建立 Proxy 時知道的 Proxy 屬性。

其他資訊