28. 使用代理進行元程式編寫
目錄
請支持這本書:購買(PDF、EPUB、MOBI)捐款
(廣告,請不要封鎖。)

28. 使用代理進行元程式編寫



28.1 概觀

代理讓您可以攔截和自訂在物件上執行的操作(例如取得屬性)。它們是一種元程式編寫功能。

在以下範例中,proxy 是我們要攔截其操作的物件,而 handler 是處理攔截的物件。在這個案例中,我們只攔截一個操作,get(取得屬性)。

const target = {};
const handler = {
    get(target, propKey, receiver) {
        console.log('get ' + propKey);
        return 123;
    }
};
const proxy = new Proxy(target, handler);

當我們取得屬性 proxy.foo 時,處理常式會攔截該操作

> proxy.foo
get foo
123

參閱 完整 API 的參考,以取得可攔截操作的清單。

28.2 編程與元編程

在我們深入了解代理是什麼以及它們為何有用的之前,我們首先需要了解什麼是元編程

在編程中,有許多層級

基本層級和元層級可以使用不同的語言。在下列元程式中,元編程語言是 JavaScript,而基本編程語言是 Java。

const str = 'Hello' + '!'.repeat(3);
console.log('System.out.println("'+str+'")');

元編程可以採取不同的形式。在先前的範例中,我們已將 Java 程式碼列印到主控台。讓我們將 JavaScript 用作元編程語言和基本編程語言。這方面的經典範例是 eval() 函式,它讓您可以即時評估/編譯 JavaScript 程式碼。eval() 的實際使用案例並不多,請參閱 此處。在下列互動中,我們使用它來評估表達式 5 + 2

> eval('5 + 2')
7

其他 JavaScript 操作可能看起來不像元編程,但如果您仔細觀察,它們實際上是

// Base level
const obj = {
    hello() {
        console.log('Hello!');
    }
};

// Meta level
for (const key of Object.keys(obj)) {
    console.log(key);
}

程式在執行時會檢查自己的結構。這看起來不像元編程,因為在 JavaScript 中,編程結構和資料結構之間的區別很模糊。所有 Object.* 方法 都可以視為元編程功能。

28.2.1 元編程的種類

反射式元編程表示程式會處理它自己。 Kiczales 等人 [2] 區分出三種類型的反射式元編程

讓我們來看一些範例。

範例:內省。 Object.keys() 會執行內省(請參閱前一個範例)。

範例:自我修改。下列函式 moveProperty 將一個屬性從來源移動到目標。它透過屬性存取的方括號運算子、賦值運算子,以及 delete 運算子來執行自我修改。(在實際程式碼中,你可能會使用 屬性描述符 來執行此任務。)

function moveProperty(source, propertyName, target) {
    target[propertyName] = source[propertyName];
    delete source[propertyName];
}

使用 moveProperty()

> const obj1 = { prop: 'abc' };
> const obj2 = {};
> moveProperty(obj1, 'prop', obj2);

> obj1
{}
> obj2
{ prop: 'abc' }

ECMAScript 5 不支援中介;代理物件被建立來填補這個空缺。

28.3 代理物件說明

ECMAScript 6 代理物件為 JavaScript 帶來中介。它們的工作方式如下。你可以對物件 obj 執行許多作業。例如

代理物件是特殊的物件,允許你自訂其中一些作業。代理物件使用兩個參數建立

在以下範例中,處理函式攔截作業 gethas

const target = {};
const handler = {
    /** Intercepts: getting properties */
    get(target, propKey, receiver) {
        console.log(`GET ${propKey}`);
        return 123;
    },

    /** Intercepts: checking whether properties exist */
    has(target, propKey) {
        console.log(`HAS ${propKey}`);
        return true;
    }
};
const proxy = new Proxy(target, handler);

當我們取得屬性 foo 時,處理函式攔截該作業

> proxy.foo
GET foo
123

類似地,in 運算子會觸發 has

> 'hello' in proxy
HAS hello
true

處理函式沒有實作陷阱 set(設定屬性)。因此,設定 proxy.bar 會轉發到 target,並導致設定 target.bar

> proxy.bar = 'abc';
> target.bar
'abc'

28.3.1 函式特定陷阱

如果目標是函式,則可以攔截兩個額外的作業

僅對函式目標啟用這些陷阱的原因很簡單:否則,您將無法轉送運算 applyconstruct

28.3.2 攔截方法呼叫

如果您想透過代理攔截方法呼叫,有一個挑戰:您可以攔截運算 get(取得屬性值),也可以攔截運算 apply(呼叫函式),但沒有單一運算可以攔截方法呼叫。這是因為方法呼叫被視為兩個獨立的運算:首先是 get 來擷取函式,然後是 apply 來呼叫該函式。

因此,您必須攔截 get 並傳回攔截函式呼叫的函式。下列程式碼示範如何執行此操作。

function traceMethodCalls(obj) {
    const handler = {
        get(target, propKey, receiver) {
            const origMethod = target[propKey];
            return function (...args) {
                const result = origMethod.apply(this, args);
                console.log(propKey + JSON.stringify(args)
                    + ' -> ' + JSON.stringify(result));
                return result;
            };
        }
    };
    return new Proxy(obj, handler);
}

我沒有對後續任務使用 Proxy,我只是使用函式包裝原始方法。

讓我們使用下列物件來試用 traceMethodCalls()

const obj = {
    multiply(x, y) {
        return x * y;
    },
    squared(x) {
        return this.multiply(x, x);
    },
};

tracedObjobj 的追蹤版本。每個方法呼叫後的第一行是 console.log() 的輸出,第二行是方法呼叫的結果。

> const tracedObj = traceMethodCalls(obj);
> tracedObj.multiply(2,7)
multiply[2,7] -> 14
14
> tracedObj.squared(9)
multiply[9,9] -> 81
squared[9] -> 81
81

很棒的是,即使在 obj.squared() 內進行的呼叫 this.multiply() 也會被追蹤。這是因為 this 持續參照代理。

這不是最有效率的解決方案。例如,可以快取方法。此外,Proxy 本身會影響效能。

28.3.3 可撤銷代理

ECMAScript 6 讓您可以建立可以撤銷(關閉)的代理

const {proxy, revoke} = Proxy.revocable(target, handler);

在指定運算子 (=) 的左側,我們使用解構存取 Proxy.revocable() 傳回的物件的屬性 proxyrevoke

您第一次呼叫函式 revoke 後,您對 proxy 執行的任何運算都會導致 TypeError。後續呼叫 revoke 沒有進一步的效果。

const target = {}; // Start with an empty object
const handler = {}; // Don’t intercept anything
const {proxy, revoke} = Proxy.revocable(target, handler);

proxy.foo = 123;
console.log(proxy.foo); // 123

revoke();

console.log(proxy.foo); // TypeError: Revoked

28.3.4 代理作為原型

代理程式 proto 可以成為物件 obj 的原型。在 obj 中開始的一些操作可能會在 proto 中繼續進行。其中一項操作是 get

const proto = new Proxy({}, {
    get(target, propertyKey, receiver) {
        console.log('GET '+propertyKey);
        return target[propertyKey];
    }
});

const obj = Object.create(proto);
obj.bla;

// Output:
// GET bla

屬性 blaobj 中找不到,這就是為什麼搜尋會繼續在 proto 中進行,而且陷阱 get 會在那裡觸發。還有更多會影響原型的操作;它們列在本章節的最後面。

28.3.5 轉發攔截的操作

處理常式未執行的陷阱的操作會自動轉發到目標。有時您會想要執行一些任務,而且還要轉發操作。例如,攔截所有操作並記錄它們的處理常式,但不會阻止它們到達目標

const handler = {
    deleteProperty(target, propKey) {
        console.log('DELETE ' + propKey);
        return delete target[propKey];
    },
    has(target, propKey) {
        console.log('HAS ' + propKey);
        return propKey in target;
    },
    // Other traps: similar
}

對於每個陷阱,我們會先記錄操作的名稱,然後手動執行它來轉發它。ECMAScript 6 有類似模組的物件 Reflect,它有助於轉發:對於每個陷阱

handler.trap(target, arg_1, ···, arg_n)

Reflect 有方法

Reflect.trap(target, arg_1, ···, arg_n)

如果我們使用 Reflect,前一個範例如下所示。

const handler = {
    deleteProperty(target, propKey) {
        console.log('DELETE ' + propKey);
        return Reflect.deleteProperty(target, propKey);
    },
    has(target, propKey) {
        console.log('HAS ' + propKey);
        return Reflect.has(target, propKey);
    },
    // Other traps: similar
}

現在每個陷阱所做的都非常相似,我們可以透過代理程式來實作處理常式

const handler = new Proxy({}, {
    get(target, trapName, receiver) {
        // Return the handler method named trapName
        return function (...args) {
            // Don’t log args[0]
            console.log(trapName.toUpperCase()+' '+args.slice(1));
            // Forward the operation
            return Reflect[trapName](...args);
        }
    }
});

對於每個陷阱,代理程式會透過 get 操作要求處理常式方法,而我們會提供一個給它。也就是說,所有處理常式方法都可以透過單一元方法 get 來實作。讓這種虛擬化變得簡單是代理程式 API 的目標之一。

讓我們使用這個基於代理程式的處理常式

> const target = {};
> const proxy = new Proxy(target, handler);
> proxy.foo = 123;
SET foo,123,[object Object]
> proxy.foo
GET foo,[object Object]
123

下列互動確認 set 操作已正確轉發到目標

> target.foo
123

28.3.6 陷阱:並非所有物件都可以透過代理程式透明地包裝

代理程式物件可以視為攔截對其目標物件執行的操作,代理程式包裝目標。代理程式的處理常式物件就像代理程式的觀察者或監聽者。它指定應該攔截哪些操作,方法是實作對應的方法(get 用於讀取屬性等)。如果操作的處理常式方法遺失,則不會攔截該操作。它會簡單地轉發到目標。

因此,如果處理常式是空物件,代理程式應該透明地包裝目標。唉,這並不總是有效。

28.3.6.1 包裝物件會影響 this

在我們深入探討之前,讓我們快速回顧一下包裝目標如何影響 this

const target = {
    foo() {
        return {
            thisIsTarget: this === target,
            thisIsProxy: this === proxy,
        };
    }
};
const handler = {};
const proxy = new Proxy(target, handler);

如果你直接呼叫 target.foo()this 指向 target

> target.foo()
{ thisIsTarget: true, thisIsProxy: false }

如果你透過代理呼叫該方法,this 指向 proxy

> proxy.foo()
{ thisIsTarget: false, thisIsProxy: true }

這樣做是為了讓代理繼續在迴圈中,例如,如果目標呼叫 this 上的方法。

28.3.6.2 無法透明包裝的物件

通常,具有空處理常式的代理會透明包裝目標:你不會注意到它們在那裡,而且它們不會改變目標的行為。

但是,如果目標透過代理無法控制的機制將資訊與 this 關聯起來,你就會遇到問題:事情會失敗,因為根據目標是否包裝而關聯不同的資訊。

例如,以下類別 Person 將私人資訊儲存在 WeakMap _name 中(有關此技術的更多資訊,請參閱 類別章節

const _name = new WeakMap();
class Person {
    constructor(name) {
        _name.set(this, name);
    }
    get name() {
        return _name.get(this);
    }
}

Person 的執行個體無法透明包裝

> const jane = new Person('Jane');
> jane.name
'Jane'

> const proxy = new Proxy(jane, {});
> proxy.name
undefined

jane.name 與包裝的 proxy.name 不同。以下實作沒有這個問題

class Person2 {
    constructor(name) {
        this._name = name;
    }
    get name() {
        return this._name;
    }
}

const jane = new Person2('Jane');
console.log(jane.name); // Jane

const proxy = new Proxy(jane, {});
console.log(proxy.name); // Jane
28.3.6.3 包裝內建建構函式的執行個體

大多數內建建構函式的執行個體也有代理無法攔截的機制。因此,它們也無法透明包裝。我將展示 Date 執行個體的問題

const target = new Date();
const handler = {};
const proxy = new Proxy(target, handler);

proxy.getDate();
    // TypeError: this is not a Date object.

不受代理影響的機制稱為內部插槽。這些插槽是與執行個體關聯的類屬性儲存。規格將這些插槽視為名稱以方括號括起來的屬性。例如,以下方法是內部的,可以在所有物件 O 上呼叫

O.[[GetPrototypeOf]]()

但是,對內部插槽的存取並非透過正常的「取得」和「設定」操作。如果透過代理呼叫 getDate(),它無法在 this 上找到需要的內部插槽,並透過 TypeError 抱怨。

對於 Date 方法,語言規格指出

除非另有明確說明,否則以下定義的 Number 原型物件的方法並非一般性的,傳遞給它們的 this 值必須是 Number 值或具有已初始化為 Number 值的 [[NumberData]] 內部插槽的物件。

28.3.6.4 陣列可以透明包裝

與其他內建函數不同,陣列可以被透明地包裝

> const p = new Proxy(new Array(), {});
> p.push('a');
> p.length
1
> p.length = 0;
> p.length
0

陣列可以被包裝的原因是,儘管屬性存取已自訂為讓 length 運作,但陣列方法並未依賴內部插槽,它們是通用的。

28.3.6.5 解決方法

作為解決方法,你可以變更處理常式轉發方法呼叫的方式,並選擇性地將 this 設定為目標,而不是代理

const handler = {
    get(target, propKey, receiver) {
        if (propKey === 'getDate') {
            return target.getDate.bind(target);
        }
        return Reflect.get(target, propKey, receiver);
    },
};
const proxy = new Proxy(new Date('2020-12-24'), handler);
proxy.getDate(); // 24

這種方法的缺點是,方法對 this 執行的操作都不會經過代理。

致謝:感謝 Allen Wirfs-Brock 指出本節說明的陷阱。

28.4 代理的用例

本節說明代理的用途。這將讓你看到 API 的實際應用。

28.4.1 追蹤屬性存取 (getset)

假設我們有一個函數 tracePropAccess(obj, propKeys),當 obj 的屬性(其鍵位於陣列 propKeys 中)被設定或取得時,它會記錄下來。在下列程式碼中,我們將該函數套用至 Point 類別的執行個體

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    toString() {
        return `Point(${this.x}, ${this.y})`;
    }
}
// Trace accesses to properties `x` and `y`
const p = new Point(5, 7);
p = tracePropAccess(p, ['x', 'y']);

取得和設定追蹤物件 p 的屬性會產生下列效應

> p.x
GET x
5
> p.x = 21
SET x=21
21

有趣的是,當 Point 存取屬性時,追蹤也會運作,因為 this 現在是指向追蹤物件,而不是 Point 的執行個體。

> p.toString()
GET x
GET y
'Point(21, 7)'

在 ECMAScript 5 中,你可以如下實作 tracePropAccess()。我們以追蹤存取的 getter 和 setter 取代每個屬性。setter 和 getter 使用額外的物件 propData 來儲存屬性的資料。請注意,我們正在破壞性地變更原始實作,這表示我們正在進行元程式設計。

function tracePropAccess(obj, propKeys) {
    // Store the property data here
    const propData = Object.create(null);
    // Replace each property with a getter and a setter
    propKeys.forEach(function (propKey) {
        propData[propKey] = obj[propKey];
        Object.defineProperty(obj, propKey, {
            get: function () {
                console.log('GET '+propKey);
                return propData[propKey];
            },
            set: function (value) {
                console.log('SET '+propKey+'='+value);
                propData[propKey] = value;
            },
        });
    });
    return obj;
}

在 ECMAScript 6 中,我們可以使用更簡單的基於代理的解決方案。我們攔截屬性取得和設定,而且不必變更實作。

function tracePropAccess(obj, propKeys) {
    const propKeySet = new Set(propKeys);
    return new Proxy(obj, {
        get(target, propKey, receiver) {
            if (propKeySet.has(propKey)) {
                console.log('GET '+propKey);
            }
            return Reflect.get(target, propKey, receiver);
        },
        set(target, propKey, value, receiver) {
            if (propKeySet.has(propKey)) {
                console.log('SET '+propKey+'='+value);
            }
            return Reflect.set(target, propKey, value, receiver);
        },
    });
}

28.4.2 警告未知屬性 (getset)

在存取屬性方面,JavaScript 非常寬容。例如,如果你嘗試讀取屬性並拼錯其名稱,你不會收到例外,你會收到結果 undefined。你可以使用代理在這種情況下取得例外。其運作方式如下。我們讓代理成為物件的原型。

如果在物件中找不到屬性,則會觸發代理的 get 陷阱。如果在代理之後的原型鏈中甚至不存在該屬性,則它確實不存在,我們會擲回例外。否則,我們會傳回繼承屬性的值。我們透過將 get 操作轉發至目標來執行此操作(目標的原型也是代理的原型)。

const PropertyChecker = new Proxy({}, {
    get(target, propKey, receiver) {
        if (!(propKey in target)) {
            throw new ReferenceError('Unknown property: '+propKey);
        }
        return Reflect.get(target, propKey, receiver);
    }
});

讓我們對我們建立的物件使用 PropertyChecker

> const obj = { __proto__: PropertyChecker, foo: 123 };
> obj.foo  // own
123
> obj.fo
ReferenceError: Unknown property: fo
> obj.toString()  // inherited
'[object Object]'

如果我們將 PropertyChecker 變成建構函數,我們可以使用它透過 extends 來建立 ECMAScript 6 類別

function PropertyChecker() { }
PropertyChecker.prototype = new Proxy(···);

class Point extends PropertyChecker {
    constructor(x, y) {
        super();
        this.x = x;
        this.y = y;
    }
}

const p = new Point(5, 7);
console.log(p.x); // 5
console.log(p.z); // ReferenceError

如果您擔心意外建立屬性,您有兩個選項:您可以將代理程式封裝在攔截set的物件周圍。或者,您可以透過 Object.preventExtensions(obj) 使物件obj不可延伸,這表示 JavaScript 不允許您將新的(自有)屬性新增到obj

28.4.3 負陣列索引(get

有些陣列方法允許您透過-1參照最後一個元素,透過-2參照倒數第二個元素,等等。例如

> ['a', 'b', 'c'].slice(-1)
[ 'c' ]

唉,透過方括弧運算子 ([]) 存取元素時,這項功能無法運作。不過,我們可以使用代理程式新增該功能。下列函式createArray()建立支援負索引的陣列。它透過將代理程式封裝在陣列實例周圍來執行此動作。代理程式攔截由方括弧運算子觸發的get運算。

function createArray(...elements) {
    const handler = {
        get(target, propKey, receiver) {
            // Sloppy way of checking for negative indices
            const index = Number(propKey);
            if (index < 0) {
                propKey = String(target.length + index);
            }
            return Reflect.get(target, propKey, receiver);
        }
    };
    // Wrap a proxy around an Array
    const target = [];
    target.push(...elements);
    return new Proxy(target, handler);
}
const arr = createArray('a', 'b', 'c');
console.log(arr[-1]); // c

致謝:此範例的構想來自 hemanth.hm 的 部落格文章

28.4.4 資料繫結 (set)

資料繫結是關於在物件之間同步資料。一個常見的用例是基於 MVC(模型檢視控制器)模式的小工具:透過資料繫結,如果您變更模型(小工具視覺化的資料),檢視(小工具)會保持最新狀態。

若要實作資料繫結,您必須觀察並對對物件所做的變更做出反應。在下列程式碼片段中,我概述了觀察變更如何適用於陣列。

function createObservedArray(callback) {
    const array = [];
    return new Proxy(array, {
        set(target, propertyKey, value, receiver) {
            callback(propertyKey, value);
            return Reflect.set(target, propertyKey, value, receiver);
        }
    });    
}
const observedArray = createObservedArray(
    (key, value) => console.log(`${key}=${value}`));
observedArray.push('a');

輸出

0=a
length=1

28.4.5 存取 restful 網路服務(方法呼叫)

代理程式可用於建立可在其上呼叫任意方法的物件。在下列範例中,函式createWebService建立一個這樣的物件service。在service上呼叫方法會擷取具有相同名稱的網路服務資源的內容。擷取透過 ECMAScript 6 Promise 處理。

const service = createWebService('http://example.com/data');
// Read JSON data in http://example.com/data/employees
service.employees().then(json => {
    const employees = JSON.parse(json);
    ···
});

下列程式碼是 ECMAScript 5 中createWebService的快速且簡陋的實作。由於我們沒有代理程式,因此我們需要事先知道將在service上呼叫哪些方法。參數propKeys提供我們該資訊,它包含一個具有方法名稱的陣列。

function createWebService(baseUrl, propKeys) {
    const service = {};
    propKeys.forEach(function (propKey) {
        service[propKey] = function () {
            return httpGet(baseUrl+'/'+propKey);
        };
    });
    return service;
}

createWebService的 ECMAScript 6 實作可以使用代理程式,而且更為簡單

function createWebService(baseUrl) {
    return new Proxy({}, {
        get(target, propKey, receiver) {
            // Return the method to be called
            return () => httpGet(baseUrl+'/'+propKey);
        }
    });
}

這兩個實作都使用以下函式來進行 HTTP GET 要求(其運作方式說明於 Promise 章節 中。

function httpGet(url) {
    return new Promise(
        (resolve, reject) => {
            const request = new XMLHttpRequest();
            Object.assign(request, {
                onload() {
                    if (this.status === 200) {
                        // Success
                        resolve(this.response);
                    } else {
                        // Something went wrong (404 etc.)
                        reject(new Error(this.statusText));
                    }
                },
                onerror() {
                    reject(new Error(
                        'XMLHttpRequest Error: '+this.statusText));
                }
            });
            request.open('GET', url);
            request.send();
        });
}

28.4.6 可撤銷的參考

可撤銷的參考運作方式如下:不允許客戶端直接存取重要資源(物件),只能透過參考(中介物件,資源的包裝器)存取。通常,套用至參考的每個操作都會轉送至資源。客戶端完成後,資源會透過關閉參考(撤銷)來受到保護。從此以後,對參考套用操作會擲回例外,而且不再轉送任何內容。

在以下範例中,我們為資源建立可撤銷的參考。然後,我們透過參考讀取資源的其中一個屬性。這會運作,因為參考授予我們存取權。接下來,我們撤銷參考。現在,參考不再讓我們讀取屬性。

const resource = { x: 11, y: 8 };
const {reference, revoke} = createRevocableReference(resource);

// Access granted
console.log(reference.x); // 11

revoke();

// Access denied
console.log(reference.x); // TypeError: Revoked

Proxy 非常適合用來實作可撤銷的參考,因為它們可以攔截並轉送操作。以下是 createRevocableReference 的簡單 Proxy 實作

function createRevocableReference(target) {
    let enabled = true;
    return {
        reference: new Proxy(target, {
            get(target, propKey, receiver) {
                if (!enabled) {
                    throw new TypeError('Revoked');
                }
                return Reflect.get(target, propKey, receiver);
            },
            has(target, propKey) {
                if (!enabled) {
                    throw new TypeError('Revoked');
                }
                return Reflect.has(target, propKey);
            },
            ···
        }),
        revoke() {
            enabled = false;
        },
    };
}

可以透過前一節的 Proxy 作為處理常式的技巧來簡化程式碼。這次,處理常式基本上是 Reflect 物件。因此,get 陷阱通常會傳回適當的 Reflect 方法。如果參考已被撤銷,則會擲回 TypeError

function createRevocableReference(target) {
    let enabled = true;
    const handler = new Proxy({}, {
        get(dummyTarget, trapName, receiver) {
            if (!enabled) {
                throw new TypeError('Revoked');
            }
            return Reflect[trapName];
        }
    });
    return {
        reference: new Proxy(target, handler),
        revoke() {
            enabled = false;
        },
    };
}

不過,您不必自己實作可撤銷的參考,因為 ECMAScript 6 讓您可以建立可撤銷的 Proxy。這次,撤銷發生在 Proxy 中,而非處理常式中。處理常式必須做的就是將每個操作轉送至目標。正如我們所見,如果處理常式未實作任何陷阱,就會自動發生。

function createRevocableReference(target) {
    const handler = {}; // forward everything
    const { proxy, revoke } = Proxy.revocable(target, handler);
    return { reference: proxy, revoke };
}
28.4.6.1

建立在可撤銷的參考概念上:設計用來執行不受信任程式碼的環境會將膜包覆在該程式碼周圍,以隔離程式碼並保持系統的其他部分安全。物件會以兩個方向通過膜

在這兩種情況下,可撤銷的參考都會包覆在物件周圍。由包覆函式或方法傳回的物件也會被包覆。此外,如果將包覆的濕物件傳遞回膜中,則會將其解開。

不受信任的程式碼完成後,所有可撤銷的參考都會被撤銷。因此,它在外部的程式碼都不會再被執行,而且它所擁有的外部物件也會停止運作。Caja 編譯器 是「用於讓第三方 HTML、CSS 和 JavaScript 安全嵌入至您的網站」的工具。它使用膜來達成此任務。

28.4.7 在 JavaScript 中實作 DOM

瀏覽器的文件物件模型 (DOM) 通常實作為 JavaScript 和 C++ 的混合。在純 JavaScript 中實作它對於下列情況很有用

唉,標準 DOM 可以執行一些在 JavaScript 中不易複製的事情。例如,大多數 DOM 集合都是對 DOM 目前狀態的即時檢視,當 DOM 發生變更時會動態變更。因此,DOM 的純 JavaScript 實作並非十分有效率。將代理新增到 JavaScript 的原因之一,就是為了協助撰寫更有效率的 DOM 實作。

28.4.8 其他使用案例

代理還有更多使用案例。例如

28.5 代理 API 的設計

在本節中,我們將深入探討代理如何運作以及為何它們會這樣運作。

28.5.1 分層:保持基本層級和元層級分離

Firefox 允許您執行一些攔截式元程式設計一段時間:如果您定義一個名稱為 __noSuchMethod__ 的方法,則在呼叫不存在的方法時會通知它。以下是使用 __noSuchMethod__ 的範例。

const obj = {
    __noSuchMethod__: function (name, args) {
        console.log(name+': '+args);
    }
};
// Neither of the following two methods exist,
// but we can make it look like they do
obj.foo(1);    // Output: foo: 1
obj.bar(1, 2); // Output: bar: 1,2

因此,__noSuchMethod__ 的運作方式類似於代理陷阱。與代理不同的是,陷阱是我們要攔截其運作的物件的自有或繼承方法。這種方法的問題在於基本層級(一般方法)和元層級(__noSuchMethod__)會混在一起。基本層級程式碼可能會意外呼叫或看到元層級方法,而且有可能會意外定義元層級方法。

即使在標準 ECMAScript 5 中,基本層級和元層級有時也會混在一起。例如,下列元程式設計機制可能會失敗,因為它們存在於基本層級

現在,顯然可以看出將(基本層級)屬性金鑰設為特殊屬性會造成問題。因此,代理會分層 – 基本層級(代理物件)和元層級(處理常式物件)是分開的。

28.5.2 虛擬物件與包裝器

代理用於兩種角色

代理 API 的早期設計將代理視為純粹的虛擬物件。然而,結果證明,即使在該角色中,目標也很有用,用於強制不變式(稍後說明)和作為處理常式未實作的陷阱的後備。

28.5.3 透明虛擬化和處理常式封裝

代理以兩種方式受到保護

這兩個原則賦予代理相當大的能力,可以冒充其他物件。強制不變式(稍後說明)的一個原因是為了控制這種能力。

如果您確實需要一種方法來區分代理和非代理,則必須自行實作。下列程式碼是一個模組 lib.js,它會匯出兩個函式:其中一個會建立代理,另一個會判斷物件是否為其中一個代理。

// lib.js
const proxies = new WeakSet();

export function createProxy(obj) {
    const handler = {};
    const proxy = new Proxy(obj, handler);
    proxies.add(proxy);
    return proxy;
}

export function isProxy(obj) {
    return proxies.has(obj);
}

這個模組使用 ECMAScript 6 資料結構 WeakSet 來追蹤代理。WeakSet 非常適合這個目的,因為它不會阻止其元素被垃圾回收。

下一個範例顯示如何使用 lib.js

// main.js
import { createProxy, isProxy } from './lib.js';

const p = createProxy({});
console.log(isProxy(p)); // true
console.log(isProxy({})); // false

28.5.4 元物件通訊協定和代理陷阱

本節探討 JavaScript 的內部結構以及代理陷阱的選擇方式。

在程式語言和 API 設計的脈絡中,協定是一組介面加上使用它們的規則。ECMAScript 規格說明如何執行 JavaScript 程式碼。它包含一個處理物件的協定。此協定在元層級運作,有時稱為元物件協定 (MOP)。JavaScript MOP 包含所有物件具有的內部方法。“內部”表示它們只存在於規格中(JavaScript 引擎可能會有或沒有它們),而且無法從 JavaScript 存取。內部方法的名稱以雙中括號撰寫。

取得屬性的內部方法稱為[[Get]]。如果我們假裝方括號中的屬性名稱是合法的,此方法將大致在 JavaScript 中實作如下。

// Method definition
[[Get]](propKey, receiver) {
    const desc = this.[[GetOwnProperty]](propKey);
    if (desc === undefined) {
        const parent = this.[[GetPrototypeOf]]();
        if (parent === null) return undefined;
        return parent.[[Get]](propKey, receiver); // (A)
    }
    if ('value' in desc) {
        return desc.value;
    }
    const getter = desc.get;
    if (getter === undefined) return undefined;
    return getter.[[Call]](receiver, []);
}

此程式碼中呼叫的 MOP 方法為

在 A 行中,您可以看到為什麼原型鏈中的代理會在「較早」的物件中找不到屬性時找出 get:如果沒有金鑰為 propKey 的自有屬性,搜尋將在 this 的原型 parent 中繼續。

基本運算與衍生運算。您可以看到 [[Get]] 會呼叫其他 MOP 運算。執行此動作的運算稱為衍生。不依賴其他運算的運算稱為基本

28.5.4.1 代理的 MOP

代理的元物件協定不同於一般物件。對於一般物件,衍生運算會呼叫其他運算。對於代理,每個運算(不論是基本或衍生)都會由處理常式攔截或轉送至目標。

哪些操作應透過代理攔截?一種可能性是僅提供基本操作的陷阱。另一種選擇是包含一些衍生操作。這樣做的優點是它可以提升效能且更方便。例如,如果沒有針對 get 的陷阱,您必須透過 getOwnPropertyDescriptor 來實作其功能。衍生陷阱的一個問題是它們可能導致代理行為不一致。例如,get 可能會傳回一個與 getOwnPropertyDescriptor 傳回的描述符中值不同的值。

28.5.4.2 選擇性攔截:哪些操作應可攔截?

透過代理的攔截是選擇性的:您無法攔截每個語言操作。為什麼有些操作會被排除?讓我們來看兩個原因。

首先,穩定的操作不適合攔截。如果一個操作總是針對相同的引數產生相同的結果,則該操作就是穩定的。如果一個代理可以攔截一個穩定的操作,它可能會變得不穩定,因此不可靠。嚴格相等 (===) 就是這樣一個穩定的操作。它無法被攔截,而且其結果是透過將代理本身視為另一個物件來計算的。維持穩定的另一種方式是對目標套用一個操作,而不是對代理套用。如後續說明,當我們探討如何對代理強制不變式時,這會在將 Object.getPrototypeOf() 套用到目標不可擴充的代理時發生。

不讓更多操作可攔截的第二個原因是,攔截表示在通常不可能的情況下執行自訂程式碼。這種程式碼交錯發生的次數越多,就越難理解和除錯程式。它也會對效能造成負面影響。

28.5.4.3 陷阱:getinvoke

如果您想透過 ECMAScript 6 代理建立虛擬方法,您必須從 get 陷阱傳回函式。這引發了一個問題:為什麼不針對方法呼叫(例如 invoke)引入一個額外的陷阱?這將使我們能夠區分

有兩個原因不這麼做。

首先,並非所有實作都能區分 getinvoke。例如,Apple 的 JavaScriptCore 無法區分

其次,透過 call()apply() 擷取方法並稍後呼叫它,應與透過 dispatch 呼叫方法具有相同效果。換句話說,下列兩個變體應以相同方式運作。如果有一個額外的陷阱 invoke,則較難維持這種等效性。

// Variant 1: call via dynamic dispatch
const result = obj.m();

// Variant 2: extract and call directly
const m = obj.m;
const result = m.call(obj);
28.5.4.3.1 invoke 的使用案例

有些事情只有在您能夠區分 getinvoke 時才能執行。因此,這些事情在目前的代理程式 API 中是不可能的。兩個範例是:自動繫結和攔截遺失的方法。讓我們探討如果代理程式支援 invoke,將如何實作這些範例。

自動繫結。透過將代理程式設為物件 obj 的原型,您可以自動繫結方法

自動繫結有助於將方法用作回呼。例如,前一個範例中的變體 2 會變得更簡單

const boundMethod = obj.m;
const result = boundMethod();

攔截遺失的方法。 invoke 讓代理程式能夠模擬 Firefox 支援的先前提到的 __noSuchMethod__ 機制。代理程式將再次成為物件 obj 的原型。它會根據如何存取未知的屬性 foo 而產生不同的反應

28.5.5 強制執行代理程式的限制

在我們探討什麼是限制以及如何對代理程式強制執行這些限制之前,讓我們回顧一下如何透過不可擴充性和不可設定性來保護物件。

28.5.5.1 保護物件

有兩種保護物件的方法

不可擴充性。如果一個物件不可擴充,則您無法新增屬性,也無法變更其原型

'use strict'; // switch on strict mode to get TypeErrors

const obj = Object.preventExtensions({});
console.log(Object.isExtensible(obj)); // false
obj.foo = 123; // TypeError: object is not extensible
Object.setPrototypeOf(obj, null); // TypeError: object is not extensible

不可設定性。屬性的所有資料都儲存在屬性中。屬性就像記錄,而屬性就像該記錄的欄位。屬性的範例

因此,如果一個屬性同時不可寫入且不可設定,則它為唯讀且會保持這種狀態

'use strict'; // switch on strict mode to get TypeErrors

const obj = {};
Object.defineProperty(obj, 'foo', {
    value: 123,
    writable: false,
    configurable: false
});
console.log(obj.foo); // 123
obj.foo = 'a'; // TypeError: Cannot assign to read only property

Object.defineProperty(obj, 'foo', {
    configurable: true
}); // TypeError: Cannot redefine property

有關這些主題的更多詳細資訊(包括 Object.defineProperty() 的運作方式),請參閱「Speaking JavaScript」中的下列章節

28.5.5.2 強制不變式

傳統上,不可擴充性和不可組態性是

這些和在語言操作中保持不變的其他特性稱為不變式。使用代理時,很容易違反不變式,因為它們本質上不受不可擴充性等約束。

代理 API 透過檢查處理常式方法的參數和結果,防止代理違反不變式。以下是四個不變式的範例(對於任意物件 obj),以及它們如何對代理強制執行的說明(本章的結尾提供了詳盡的清單)。

前兩個不變式涉及不可擴充性和不可組態性。這些透過使用目標物件進行簿記來強制執行:處理常式方法傳回的結果必須大部分與目標物件同步。

其餘兩個不變式透過檢查回傳值來強制執行

強制執行不變式具有以下好處

接下來的兩個區段提供了強制執行不變式的範例。

28.5.5.3 範例:不可擴充目標的原型必須忠實表示

回應 getPrototypeOf 陷阱時,如果目標不可擴充,代理程式必須傳回目標的原型。

為了示範此不變式,我們來建立一個處理常式,傳回與目標原型不同的原型

const fakeProto = {};
const handler = {
    getPrototypeOf(t) {
        return fakeProto;
    }
};

如果目標可擴充,偽造原型會成功

const extensibleTarget = {};
const ext = new Proxy(extensibleTarget, handler);
console.log(Object.getPrototypeOf(ext) === fakeProto); // true

不過,如果我們偽造不可擴充物件的原型,就會發生錯誤。

const nonExtensibleTarget = {};
Object.preventExtensions(nonExtensibleTarget);
const nonExt = new Proxy(nonExtensibleTarget, handler);
Object.getPrototypeOf(nonExt); // TypeError
28.5.5.4 範例:不可寫入的不可設定目標屬性必須忠實呈現

如果目標有不可寫入的不可設定屬性,則處理常式必須在回應 get 陷阱時傳回該屬性的值。為了示範此不變式,我們來建立一個處理常式,總是傳回屬性的相同值。

const handler = {
    get(target, propKey) {
        return 'abc';
    }
};
const target = Object.defineProperties(
    {}, {
        foo: {
            value: 123,
            writable: true,
            configurable: true
        },
        bar: {
            value: 456,
            writable: false,
            configurable: false
        },
    });
const proxy = new Proxy(target, handler);

屬性 target.foo 不是不可寫入且不可設定,這表示處理常式可以假裝它有不同的值

> proxy.foo
'abc'

不過,屬性 target.bar 是不可寫入且不可設定。因此,我們無法偽造它的值

> proxy.bar
TypeError: Invariant check failed

28.6 常見問答:代理程式

28.6.1 enumerate 陷阱在哪裡?

ES6 原本有一個陷阱 enumerate,會由 for-in 迴圈觸發。但它最近被移除,以簡化代理程式。Reflect.enumerate() 也被移除。(來源:TC39 備忘錄

28.7 參考:代理程式 API

本節提供代理程式 API 的快速參考:全域物件 ProxyReflect

28.7.1 建立代理程式

有兩種方式可以建立代理程式

28.7.2 處理器方法

本小節說明處理器可以實作哪些陷阱以及哪些操作會觸發這些陷阱。幾個陷阱會傳回布林值。對於陷阱 hasisExtensible,布林值是操作的結果。對於所有其他陷阱,布林值表示操作是否成功。

所有物件的陷阱

函數的陷阱(如果 target 是函數,則可用)

28.7.2.1 基本操作與衍生操作

下列操作是基本操作,它們不會使用其他操作來執行工作:applydefinePropertydeletePropertygetOwnPropertyDescriptorgetPrototypeOfisExtensibleownKeyspreventExtensionssetPrototypeOf

所有其他操作都是衍生的,它們可透過基本操作來實作。例如,對於資料屬性,get 可透過 getPrototypeOf 迭代原型鏈,並對每個鏈成員呼叫 getOwnPropertyDescriptor,直到找到自己的屬性或鏈結束。

28.7.3 處理常式方法的常數

常數是處理常式的安全約束。此小節說明代理 API 如何強制執行常數以及如何執行。每當您在下方讀到「處理常式必須執行 X」時,表示如果它沒有執行,就會擲回 TypeError。有些常數會限制回傳值,有些則會限制參數。陷阱回傳值的正確性有兩種確保方式:通常,非法值表示會擲回 TypeError。但每當預期為布林值時,就會使用強制轉換將非布林值轉換為合法值。

以下是強制執行的常數完整清單

28.7.4 影響原型鏈的運算

一般物件的下列運算會對原型鏈中的物件執行運算。因此,如果該鏈中的某個物件是代理,則會觸發其陷阱。規範將運算實作為內部自有方法(JavaScript 程式碼看不到)。但在本區段中,我們假裝它們是與陷阱同名的一般方法。參數 target 會變成方法呼叫的接收器。

所有其他運算只會影響自有屬性,它們不會對原型鏈產生影響。

28.7.5 Reflect

全域物件 Reflect 將 JavaScript 元物件通訊協定的所有可攔截運算實作為方法。這些方法的名稱與處理常式方法相同,如我們所見,這有助於將運算從處理常式轉送至目標。

數個方法有布林結果。對於 hasisExtensible,它們是運算的結果。對於其餘的方法,它們表示運算是否成功。

28.7.5.1 除了轉發之外,Reflect 的使用案例

除了轉發運算之外,為什麼 Reflect 會有用 [4]

28.7.5.2 Object.*Reflect.*

未來,Object 將會承載一般應用程式感興趣的操作,而 Reflect 將會承載較低層級的操作。

28.8 結論

這結束了我們對代理 API 的深入探討。對於每個應用程式,你必須考量效能,並在必要時進行衡量。代理可能並不總是夠快。另一方面,效能通常不是關鍵,而代理所提供的元程式設計能力是很棒的。正如我們所見,它們可以協助處理許多使用案例。

28.9 進一步閱讀

[1] “ECMAScript Reflection API 的設計” by Tom Van Cutsem 和 Mark Miller。技術報告,2012。[本章的重要來源。]

[2] “元物件協定的藝術” by Gregor Kiczales、Jim des Rivieres 和 Daniel G. Bobrow。書籍,1991。

[3] “發揮元類別的功用:物件導向程式設計的新面向” by Ira R. Forman 和 Scott H. Danforth。書籍,1999。

[4] “Harmony-reflect:我為什麼應該使用這個函式庫?” by Tom Van Cutsem。[說明為什麼 Reflect 很實用。]

下一章:29. ECMAScript 6 的程式設計風格提示