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);
}

我没有为后一项任务使用代理,我只是用一个函数包装了原始方法。

让我们使用以下对象来试用 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 继续引用代理。

这不是最有效的解决方案。例如,可以缓存方法。此外,代理本身也会影响性能。

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

obj 中找不到属性 bla,这就是为什么搜索在 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 原型对象的方法不是通用的,并且传递给它们的值必须是 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 Web 服务(方法调用)

代理可用于创建可以调用任意方法的对象。在以下示例中,函数 createWebService 创建了一个这样的对象 service。在 service 上调用方法将检索具有相同名称的 Web 服务资源的内容。检索是通过 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;
}

ECMAScript 6 中 createWebService 的实现可以使用代理,并且更简单

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

代理非常适合实现可撤销引用,因为它们可以拦截和转发操作。这是 createRevocableReference 的简单基于代理的实现

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;
        },
    };
}

可以使用上一节中的代理作为处理程序技术来简化代码。这一次,处理程序基本上是 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 允许您创建可以撤销的代理。这一次,撤销发生在代理中,而不是在处理程序中。处理程序要做的就是将每个操作转发到目标。正如我们所见,如果处理程序没有实现任何陷阱,这会自动发生。

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() 调用它应该与通过调度调用方法具有相同的效果。换句话说,以下两种变体应该等效地工作。如果有一个额外的陷阱 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 最初有一个由 for-in 循环触发的陷阱 enumerate。但它最近被删除了,以简化代理。Reflect.enumerate() 也被删除了。(来源:TC39 笔记

28.7 参考:代理 API

本节简要介绍了代理 API:全局对象 ProxyReflect

28.7.1 创建代理

有两种方法可以创建代理

28.7.2 处理程序方法

本小节解释了处理程序可以实现哪些陷阱以及哪些操作会触发它们。一些陷阱会返回布尔值。对于陷阱 hasisExtensible,布尔值是操作的结果。对于所有其他陷阱,布尔值指示操作是否成功。

所有对象的陷阱

函数的陷阱(如果目标是函数,则可用)

28.7.2.1 基本操作与派生操作

以下操作是*基本的*,它们不使用其他操作来完成它们的工作:applydefinePropertydeletePropertygetOwnPropertyDescriptorgetPrototypeOfisExtensibleownKeyspreventExtensionssetPrototypeOf

所有其他操作都是*派生的*,它们可以通过基本操作来实现。例如,对于数据属性,可以通过 getPrototypeOf 迭代原型链并为每个链成员调用 getOwnPropertyDescriptor 来实现 get,直到找到自己的属性或链结束。

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] Tom Van Cutsem 和 Mark Miller 的“ECMAScript 反射 API 的设计”。技术报告,2012 年。[本章的重要来源。]

[2] Gregor Kiczales、Jim des Rivieres 和 Daniel G. Bobrow 的“元对象协议的艺术”。书籍,1991 年。

[3] Ira R. Forman 和 Scott H. Danforth 的“让元类发挥作用:面向对象编程的新维度”。书籍,1999 年。

[4] Tom Van Cutsem 的“Harmony-reflect:我为什么要使用这个库?”。[解释了为什么 Reflect 很有用。]

下一篇:29. ECMAScript 6 的代码风格提示