代理使我们能够拦截和自定义对对象执行的操作(例如获取属性)。它们是一种*元编程*特性。
在以下示例中
`proxy` 是一个空对象。
`handler` 可以通过实现某些方法来拦截对 `proxy` 执行的操作。
如果处理器没有拦截操作,则该操作将转发到 `target`。
我们只拦截一个操作 - `get`(获取属性)
const logged = [];
const target = {size: 0};
const handler = {
get(target, propKey, receiver) {
logged.push('GET ' + propKey);
return 123;
}
};
const proxy = new Proxy(target, handler);当我们获取属性 `proxy.size` 时,处理器会拦截该操作
有关可以拦截的操作列表,请参阅完整 API 参考。
在我们深入了解代理是什么以及它们为什么有用之前,我们首先需要了解什么是*元编程*。
在编程中,存在多个级别
基础级别和元级别可以使用不同的语言。在以下元程序中,元编程语言是 JavaScript,基础编程语言是 Java。
元编程可以采取不同的形式。在上一个示例中,我们将 Java 代码打印到控制台。让我们同时使用 JavaScript 作为元编程语言和基础编程语言。这方面的经典示例是 `eval()` 函数,它允许我们动态地评估/编译 JavaScript 代码。在下面的交互中,我们使用它来评估表达式 `5 + 2`。
其他 JavaScript 操作可能看起来不像元编程,但如果我们仔细观察,它们实际上是
// Base level
const obj = {
hello() {
console.log('Hello!');
},
};
// Meta level
for (const key of Object.keys(obj)) {
console.log(key);
}程序在运行时检查自身的结构。这看起来不像元编程,因为 JavaScript 中编程结构和数据结构之间的界限很模糊。所有 `Object.*` 方法都可以被视为元编程功能。
反射式元编程意味着程序处理自身。 Kiczales 等人 [2] 区分了三种反射式元编程
让我们看一些例子。
**示例:内省。** `Object.keys()` 执行内省(参见前面的示例)。
**示例:自我修改。** 以下函数 `moveProperty` 将属性从源移动到目标。它通过用于属性访问的方括号运算符、赋值运算符和 `delete` 运算符执行自我修改。(在生产代码中,我们可能会为此任务使用 属性描述符。)
function moveProperty(source, propertyName, target) {
target[propertyName] = source[propertyName];
delete source[propertyName];
}以下是 `moveProperty()` 的使用方法
const obj1 = { color: 'blue' };
const obj2 = {};
moveProperty(obj1, 'color', obj2);
assert.deepEqual(
obj1, {});
assert.deepEqual(
obj2, { color: 'blue' });ECMAScript 5 不支持拦截;创建代理是为了填补这一空白。
代理为 JavaScript 带来了拦截。它们的工作原理如下。我们可以对对象 `obj` 执行许多操作 - 例如
代理是允许我们自定义其中一些操作的特殊对象。代理使用两个参数创建
注意:“拦截”的动词形式是“进行拦截”。拦截本质上是双向的。拦截本质上是单向的。
在以下示例中,处理器拦截 `get` 和 `has` 操作。
const logged = [];
const target = {};
const handler = {
/** Intercepts: getting properties */
get(target, propKey, receiver) {
logged.push(`GET ${propKey}`);
return 123;
},
/** Intercepts: checking whether properties exist */
has(target, propKey) {
logged.push(`HAS ${propKey}`);
return true;
}
};
const proxy = new Proxy(target, handler);如果我们获取属性(A 行)或使用 `in` 运算符(B 行),则处理器会拦截这些操作
assert.equal(proxy.age, 123); // (A)
assert.equal('hello' in proxy, true); // (B)
assert.deepEqual(
logged, [
'GET age',
'HAS hello',
]);处理器没有实现陷阱 `set`(设置属性)。因此,设置 `proxy.age` 会转发到 `target`,并导致设置 `target.age`
如果目标是函数,则可以拦截另外两个操作
proxy(···)proxy.call(···)proxy.apply(···)new proxy(···)仅对函数目标启用这些陷阱的原因很简单:否则,我们将无法转发 `apply` 和 `construct` 操作。
如果我们想通过代理拦截方法调用,我们将面临一个挑战:没有针对方法调用的陷阱。相反,方法调用被视为两个操作的序列
因此,如果我们想拦截方法调用,我们需要拦截两个操作
以下代码演示了如何做到这一点。
const traced = [];
function traceMethodCalls(obj) {
const handler = {
get(target, propKey, receiver) {
const origMethod = target[propKey];
return function (...args) { // implicit parameter `this`!
const result = origMethod.apply(this, args);
traced.push(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);
},
};
const tracedObj = traceMethodCalls(obj);
assert.equal(
tracedObj.squared(9), 81);
assert.deepEqual(
traced, [
'multiply[9,9] -> 81',
'squared[9] -> 81',
]);甚至 `obj.squared()` 中的调用 `this.multiply()` 也被跟踪了!这是因为 `this` 一直引用代理。
这不是最有效的解决方案。例如,可以缓存方法。此外,代理本身也会影响性能。
代理可以被*撤销*(关闭)
在我们第一次调用函数 `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` works as if it were the object `target`:
proxy.city = 'Paris';
assert.equal(proxy.city, 'Paris');
revoke();
assert.throws(
() => proxy.prop,
/^TypeError: Cannot perform 'get' on a proxy that has been revoked$/
);代理 `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.weight;
// Output:
// 'GET weight'在 `obj` 中找不到属性 `weight`,这就是为什么搜索在 `proto` 中继续,并在那里触发陷阱 `get` 的原因。还有更多影响原型的操作;它们列在本节的末尾。
处理器没有实现其陷阱的操作会自动转发到目标。有时,除了转发操作之外,我们还想执行一些任务。例如,拦截和记录所有操作,而不会阻止它们到达目标
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
}对于每个陷阱,我们首先记录操作的名称,然后通过手动执行操作来转发它。JavaScript 具有类似模块的对象 `Reflect`,可以帮助转发。
对于每个陷阱
`Reflect` 都有一个方法
如果我们使用 `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 (...args) => {
console.log(trapName.toUpperCase() + ' ' + args[1]);
// Forward the operation
return Reflect[trapName](...args);
};
},
});对于每个陷阱,代理都通过 `get` 操作请求一个处理器方法,我们给它一个。也就是说,所有处理器方法都可以通过单个元方法 `get` 来实现。使这种虚拟化变得简单是代理 API 的目标之一。
让我们使用这个基于代理的处理器
const target = {};
const proxy = new Proxy(target, handler);
proxy.distance = 450; // set
assert.equal(proxy.distance, 450); // get
// Was `set` operation correctly forwarded to `target`?
assert.equal(
target.distance, 450);
// Output:
// 'SET distance'
// 'GETOWNPROPERTYDESCRIPTOR distance'
// 'DEFINEPROPERTY distance'
// 'GET distance'代理对象可以看作是拦截对其目标对象执行的操作 - 代理包装目标。代理的处理器对象就像代理的观察者或侦听器。它通过实现相应的方法(`get` 用于读取属性等)来指定应拦截哪些操作。如果缺少某个操作的处理器方法,则不会拦截该操作。它只是被转发到目标。
因此,如果处理器是空对象,则代理应该透明地包装目标。唉,这并不总是有效。
在我们深入探讨之前,让我们快速回顾一下包装目标如何影响 `this`
const target = {
myMethod() {
return {
thisIsTarget: this === target,
thisIsProxy: this === proxy,
};
}
};
const handler = {};
const proxy = new Proxy(target, handler);如果我们直接调用 `target.myMethod()`,`this` 指向 `target`
如果我们通过代理调用该方法,`this` 指向 `proxy`
也就是说,如果代理将方法调用转发到目标,则 `this` 不会更改。因此,如果目标使用 `this`(例如,进行方法调用),则代理将继续处于循环中。
通常,具有空处理器的代理会透明地包装目标:我们不会注意到它们的存在,并且它们不会更改目标的行为。
但是,如果目标通过代理无法控制的机制将信息与 `this` 相关联,则会出现问题:事情会失败,因为关联的信息会因目标是否被包装而异。
例如,下面的类 Person 将私有信息存储在 WeakMap _name 中(关于此技术的更多信息,请参阅JavaScript for impatient programmers)。
const _name = new WeakMap();
class Person {
constructor(name) {
_name.set(this, name);
}
get name() {
return _name.get(this);
}
}Person 的实例不能被透明地包装。
const jane = new Person('Jane');
assert.equal(jane.name, 'Jane');
const proxy = new Proxy(jane, {});
assert.equal(proxy.name, undefined);jane.name 与包装后的 proxy.name 不同。以下实现没有这个问题。
class Person2 {
constructor(name) {
this._name = name;
}
get name() {
return this._name;
}
}
const jane = new Person2('Jane');
assert.equal(jane.name, 'Jane');
const proxy = new Proxy(jane, {});
assert.equal(proxy.name, 'Jane');大多数内置构造函数的实例也使用一种不被代理拦截的机制。因此,它们也不能被透明地包装。如果我们使用 Date 的实例,我们可以看到这一点。
const target = new Date();
const handler = {};
const proxy = new Proxy(target, handler);
assert.throws(
() => proxy.getFullYear(),
/^TypeError: this is not a Date object\.$/
);不受代理影响的机制称为*内部插槽*。这些插槽是与实例关联的类似属性的存储。规范将这些插槽视为具有方括号名称的属性。例如,以下方法是内部的,可以在所有对象 O 上调用。
与属性不同,访问内部插槽不是通过普通的“获取”和“设置”操作完成的。如果通过代理调用 .getFullYear(),它将无法在 this 上找到所需的内部插槽,并通过 TypeError 报错。
对于 Date 方法,语言规范指出:
除非另有明确定义,否则下面定义的 Date 原型对象的方法不是泛型的,并且传递给它们的
this值必须是具有已初始化为时间值的[[DateValue]]内部插槽的对象。
作为一种解决方法,我们可以更改处理程序转发方法调用的方式,并有选择地将 this 设置为目标而不是代理。
const handler = {
get(target, propKey, receiver) {
if (propKey === 'getFullYear') {
return target.getFullYear.bind(target);
}
return Reflect.get(target, propKey, receiver);
},
};
const proxy = new Proxy(new Date('2030-12-24'), handler);
assert.equal(proxy.getFullYear(), 2030);这种方法的缺点是,方法在 this 上执行的任何操作都不会通过代理进行。
与其他内置类型不同,数组可以被透明地包装。
const p = new Proxy(new Array(), {});
p.push('a');
assert.equal(p.length, 1);
p.length = 0;
assert.equal(p.length, 0);数组可以包装的原因是,即使属性访问被自定义以使 .length 工作,数组方法也不依赖于内部插槽——它们是泛型的。
本节演示代理的用途。这将使我们有机会看到 API 的实际应用。
get、set)假设我们有一个函数 tracePropertyAccesses(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 point = new Point(5, 7);
const tracedPoint = tracePropertyAccesses(point, ['x', 'y']);获取和设置被跟踪对象 p 的属性具有以下效果。
有趣的是,每当 Point 访问属性时,跟踪也会起作用,因为 this 现在指的是被跟踪的对象,而不是 Point 的实例。
tracePropertyAccesses()如果没有代理,我们将按如下方式实现 tracePropertyAccesses()。我们将每个属性替换为一个跟踪访问的 getter 和 setter。setter 和 getter 使用一个额外的对象 propData 来存储属性的数据。请注意,我们正在破坏性地更改原始实现,这意味着我们正在进行元编程。
function tracePropertyAccesses(obj, propKeys, log=console.log) {
// 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 () {
log('GET '+propKey);
return propData[propKey];
},
set: function (value) {
log('SET '+propKey+'='+value);
propData[propKey] = value;
},
});
});
return obj;
}参数 log 使得对该函数进行单元测试更容易。
const obj = {};
const logged = [];
tracePropertyAccesses(obj, ['a', 'b'], x => logged.push(x));
obj.a = 1;
assert.equal(obj.a, 1);
obj.c = 3;
assert.equal(obj.c, 3);
assert.deepEqual(
logged, [
'SET a=1',
'GET a',
]);tracePropertyAccesses()代理为我们提供了一种更简单的解决方案。我们拦截属性的获取和设置,而不必更改实现。
function tracePropertyAccesses(obj, propKeys, log=console.log) {
const propKeySet = new Set(propKeys);
return new Proxy(obj, {
get(target, propKey, receiver) {
if (propKeySet.has(propKey)) {
log('GET '+propKey);
}
return Reflect.get(target, propKey, receiver);
},
set(target, propKey, value, receiver) {
if (propKeySet.has(propKey)) {
log('SET '+propKey+'='+value);
}
return Reflect.set(target, propKey, value, receiver);
},
});
}get、set)在访问属性方面,JavaScript 非常宽容。例如,如果我们尝试读取一个属性并拼写错误,我们不会得到异常——我们得到结果 undefined。
在这种情况下,我们可以使用代理来获取异常。其工作原理如下。我们将代理设为对象的原型。如果在对象中找不到属性,则会触发代理的 get 陷阱。
get 操作转发给目标来做到这一点(代理从目标获取其原型)。这是这种方法的实现。
const propertyCheckerHandler = {
get(target, propKey, receiver) {
// Only check string property keys
if (typeof propKey === 'string' && !(propKey in target)) {
throw new ReferenceError('Unknown property: ' + propKey);
}
return Reflect.get(target, propKey, receiver);
}
};
const PropertyChecker = new Proxy({}, propertyCheckerHandler);让我们将 PropertyChecker 用于一个对象。
const jane = {
__proto__: PropertyChecker,
name: 'Jane',
};
// Own property:
assert.equal(
jane.name,
'Jane');
// Typo:
assert.throws(
() => jane.nmae,
/^ReferenceError: Unknown property: nmae$/);
// Inherited property:
assert.equal(
jane.toString(),
'[object Object]');PropertyChecker 作为类如果我们将 PropertyChecker 转换为构造函数,我们可以通过 extends 将其用于类。
// We can’t change .prototype of classes, so we are using a function
function PropertyChecker2() {}
PropertyChecker2.prototype = new Proxy({}, propertyCheckerHandler);
class Point extends PropertyChecker2 {
constructor(x, y) {
super();
this.x = x;
this.y = y;
}
}
const point = new Point(5, 7);
assert.equal(point.x, 5);
assert.throws(
() => point.z,
/^ReferenceError: Unknown property: z/);这是 point 的原型链。
const p = Object.getPrototypeOf.bind(Object);
assert.equal(p(point), Point.prototype);
assert.equal(p(p(point)), PropertyChecker2.prototype);
assert.equal(p(p(p(point))), Object.prototype);如果我们担心意外*创建*属性,我们有两个选择。
set 的对象包装一个代理。Object.preventExtensions(obj) 使对象 obj 不可扩展,这意味着 JavaScript 不允许我们向 obj 添加新的(自有)属性。get)一些数组方法允许我们通过 -1 引用最后一个元素,通过 -2 引用倒数第二个元素,等等。例如:
唉,这在通过方括号运算符 ([]) 访问元素时不起作用。但是,我们可以使用代理来添加此功能。以下函数 createArray() 创建支持负索引的数组。它通过围绕数组实例包装代理来做到这一点。代理拦截由方括号运算符触发的 get 操作。
function createArray(...elements) {
const handler = {
get(target, propKey, receiver) {
if (typeof propKey === 'string') {
const index = Number(propKey);
if (index < 0) {
propKey = String(target.length + index);
}
}
return Reflect.get(target, propKey, receiver);
}
};
// Wrap a proxy around the Array
return new Proxy(elements, handler);
}
const arr = createArray('a', 'b', 'c');
assert.equal(
arr[-1], 'c');
assert.equal(
arr[0], 'a');
assert.equal(
arr.length, 3);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(
`${JSON.stringify(key)} = ${JSON.stringify(value)}`));
observedArray.push('a');
// Output:
// '"0" = "a"'
// '"length" = 1'代理可用于创建一个可以调用任意方法的对象。在以下示例中,函数 createWebService() 创建了一个这样的对象 service。在 service 上调用方法将检索具有相同名称的 Web 服务资源的内容。检索是通过 Promise 处理的。
const service = createWebService('http://example.com/data');
// Read JSON data in http://example.com/data/employees
service.employees().then((jsonStr) => {
const employees = JSON.parse(jsonStr);
// ···
});以下代码是在没有代理的情况下快速而粗略地实现 createWebService 的方法。我们需要事先知道将在 service 上调用哪些方法。参数 propKeys 为我们提供了这些信息;它包含一个包含方法名称的数组。
function createWebService(baseUrl, propKeys) {
const service = {};
for (const propKey of propKeys) {
service[propKey] = () => {
return httpGet(baseUrl + '/' + propKey);
};
}
return service;
}使用代理,createWebService() 更简单。
function createWebService(baseUrl) {
return new Proxy({}, {
get(target, propKey, receiver) {
// Return the method to be called
return () => httpGet(baseUrl + '/' + propKey);
}
});
}这两种实现都使用以下函数来发出 HTTP GET 请求(其工作原理在 JavaScript for impatient programmers 中有解释)。
function httpGet(url) {
return new Promise(
(resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onload = () => {
if (xhr.status === 200) {
resolve(xhr.responseText); // (A)
} else {
// Something went wrong (404, etc.)
reject(new Error(xhr.statusText)); // (B)
}
}
xhr.onerror = () => {
reject(new Error('Network error')); // (C)
};
xhr.open('GET', url);
xhr.send();
});
}*可撤销引用*的工作原理如下:不允许客户端直接访问重要资源(对象),只能通过引用(中间对象,资源的包装器)访问。通常,应用于引用的每个操作都会转发到资源。客户端完成后,通过*撤销*引用(将其关闭)来保护资源。此后,对引用应用操作会引发异常,并且不再转发任何内容。
在以下示例中,我们为资源创建了一个可撤销引用。然后,我们通过引用读取资源的属性之一。这是可行的,因为引用授予了我们访问权限。接下来,我们撤销引用。现在,引用不再允许我们读取该属性。
const resource = { x: 11, y: 8 };
const {reference, revoke} = createRevocableReference(resource);
// Access granted
assert.equal(reference.x, 11);
revoke();
// Access denied
assert.throws(
() => reference.x,
/^TypeError: Cannot perform 'get' on a proxy that has been revoked/
);代理非常适合实现可撤销引用,因为它们可以拦截和转发操作。这是一个简单的基于代理的 createRevocableReference 实现。
function createRevocableReference(target) {
let enabled = true;
return {
reference: new Proxy(target, {
get(target, propKey, receiver) {
if (!enabled) {
throw new TypeError(
`Cannot perform 'get' on a proxy that has been revoked`);
}
return Reflect.get(target, propKey, receiver);
},
has(target, propKey) {
if (!enabled) {
throw new TypeError(
`Cannot perform 'has' on a proxy that has been revoked`);
}
return Reflect.has(target, propKey);
},
// (Remaining methods omitted)
}),
revoke: () => {
enabled = false;
},
};
}可以使用上一节中的代理作为处理程序技术来简化代码。这一次,处理程序基本上是 Reflect 对象。因此,get 陷阱通常返回适当的 Reflect 方法。如果引用已被撤销,则会抛出 TypeError。
function createRevocableReference(target) {
let enabled = true;
const handler = new Proxy({}, {
get(_handlerTarget, trapName, receiver) {
if (!enabled) {
throw new TypeError(
`Cannot perform '${trapName}' on a proxy`
+ ` that has been revoked`);
}
return Reflect[trapName];
}
});
return {
reference: new Proxy(target, handler),
revoke: () => {
enabled = false;
},
};
}但是,我们不必自己实现可撤销引用,因为代理可以被撤销。这一次,撤销发生在代理中,而不是在处理程序中。处理程序要做的就是将每个操作转发给目标。正如我们所见,如果处理程序没有实现任何陷阱,这会自动发生。
function createRevocableReference(target) {
const handler = {}; // forward everything
const { proxy, revoke } = Proxy.revocable(target, handler);
return { reference: proxy, revoke };
}*膜*建立在可撤销引用的基础上:用于安全运行不受信任代码的库在该代码周围包裹了一层膜,以隔离它并确保系统其余部分的安全。对象在两个方向上传递膜。
在这两种情况下,可撤销引用都包装在对象周围。包装函数或方法返回的对象也被包装。此外,如果将包装的湿对象传递回膜中,则会将其解包。
一旦不受信任的代码完成,所有可撤销引用都将被撤销。结果,它在外部的任何代码都无法再执行,并且它引用的外部对象也将停止工作。Caja 编译器 是“一种使第三方 HTML、CSS 和 JavaScript 安全嵌入到您的网站中的工具”。它使用膜来实现这一目标。
浏览器的文档对象模型 (DOM) 通常以 JavaScript 和 C++ 的混合方式实现。在纯 JavaScript 中实现它对于以下方面很有用:
唉,标准 DOM 可以做一些在 JavaScript 中不容易复制的事情。例如,大多数 DOM 集合都是 DOM 当前状态的实时视图,每当 DOM 发生更改时,这些视图都会动态更改。结果,纯 JavaScript 实现的 DOM 效率不高。将代理添加到 JavaScript 的原因之一是启用更高效的 DOM 实现。
代理还有更多用例。例如:
远程处理:本地占位符对象将方法调用转发到远程对象。此用例类似于 Web 服务示例。
数据库的数据访问对象:对对象的读取和写入会读取和写入数据库。此用例类似于 Web 服务示例。
分析:拦截方法调用以跟踪每个方法花费的时间。此用例类似于跟踪示例。
Immer(作者:Michel Weststrate) 帮助非破坏性地更新数据。应应用的更改通过调用方法、设置属性、设置数组元素等(可能嵌套的)*草稿状态*来指定。草稿状态是通过代理实现的。
MobX 允许您观察对数据结构(如对象、数组和类实例)的更改。这是通过代理实现的。
Alpine.js(作者:Caleb Porzio) 是一个通过代理实现数据绑定的前端库。
on-change(作者:Sindre Sorhus) 观察对对象的更改(通过代理)并报告它们。
Env 实用程序(作者:Nicholas C. Zakas) 允许您通过属性访问环境变量,如果它们不存在则抛出异常。这是通过代理实现的。
LDflex(作者:Ruben Verborgh 和 Ruben Taelman) 为链接数据(想想语义网)提供了一种查询语言。流畅的查询 API 是通过代理实现的。
在本节中,我们将更深入地探讨代理的工作原理以及它们为何以这种方式工作。
Firefox 曾经支持一种有限形式的介入式元编程:如果一个对象 O 有一个名为 __noSuchMethod__ 的方法,那么每当在 O 上调用一个不存在的方法时,它都会收到通知。以下代码演示了它是如何工作的
const calc = {
__noSuchMethod__: function (methodName, args) {
switch (methodName) {
case 'plus':
return args.reduce((a, b) => a + b);
case 'times':
return args.reduce((a, b) => a * b);
default:
throw new TypeError('Unsupported: ' + methodName);
}
}
};
// All of the following method calls are implemented via
// .__noSuchMethod__().
assert.equal(
calc.plus(3, 5, 2), 10);
assert.equal(
calc.times(2, 3, 4), 24);
assert.equal(
calc.plus('Parts', ' of ', 'a', ' string'),
'Parts of a string');因此,__noSuchMethod__ 的工作方式类似于 Proxy 陷阱。与 Proxies 不同的是,陷阱是我们想要拦截其操作的对象的自有方法或继承方法。这种方法的问题在于基础级别(普通方法)和元级别(__noSuchMethod__)是混合的。基础级别代码可能会意外调用或看到元级别方法,并且有可能意外定义元级别方法。
即使在标准 ECMAScript 中,基础级别和元级别有时也会混合在一起。例如,以下元编程机制可能会失败,因为它们存在于基础级别
obj.hasOwnProperty(propKey):如果原型链中的属性覆盖了内置实现,则此调用可能会失败。例如,在以下代码中,obj 会导致失败
const obj = { hasOwnProperty: null };
assert.throws(
() => obj.hasOwnProperty('width'),
/^TypeError: obj.hasOwnProperty is not a function/
);以下是调用 .hasOwnProperty() 的安全方法
func.call(···)、func.apply(···):对于这两种方法,问题和解决方案与 .hasOwnProperty() 相同。
obj.__proto__:在普通对象中,__proto__ 是一个特殊属性,允许我们获取和设置接收器的原型。因此,当我们使用普通对象作为字典时,我们必须避免将 __proto__ 作为属性键。
到目前为止,应该很明显,使(基础级别)属性键特殊化是有问题的。因此,Proxies 是分层的:基础级别(Proxy 对象)和元级别(处理程序对象)是分开的。
Proxies 用于两种角色
作为包装器,它们包装其目标,控制对它们的访问。包装器的例子有:可撤销资源和通过 Proxies 进行跟踪。
作为虚拟对象,它们只是具有特殊行为的对象,其目标无关紧要。例如,将方法调用转发到远程对象的 Proxy。
Proxy API 的早期设计将 Proxies 视为纯粹的虚拟对象。然而,事实证明,即使在该角色中,目标也是有用的,可以强制执行不变量(稍后解释)并作为处理程序未实现的陷阱的回退。
Proxies 以两种方式屏蔽
这两个原则赋予 Proxies 相当大的能力来模拟其他对象。强制执行不变量(稍后解释)的原因之一是控制这种能力。
如果我们确实需要一种方法来区分 Proxies 和非 Proxies,我们必须自己实现它。以下代码是一个模块 lib.mjs,它导出两个函数:一个函数创建 Proxies,另一个函数确定对象是否是这些 Proxies 之一。
// lib.mjs
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);
}此模块使用数据结构 WeakSet 来跟踪 Proxies。WeakSet 非常适合此目的,因为它不会阻止其元素被垃圾回收。
下一个示例显示了如何使用 lib.mjs。
// main.mjs
import { createProxy, isProxy } from './lib.mjs';
const proxy = createProxy({});
assert.equal(isProxy(proxy), true);
assert.equal(isProxy({}), false);在本节中,我们将研究 JavaScript 的内部结构以及如何选择 Proxy 陷阱集。
在编程语言和 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 方法是
[[GetOwnProperty]](陷阱 getOwnPropertyDescriptor)[[GetPrototypeOf]](陷阱 getPrototypeOf)[[Get]](陷阱 get)[[Call]](陷阱 apply)在 A 行,我们可以看到为什么原型链中的 Proxies 在“早期”对象中找不到属性时会找出 get:如果没有自己的键为 propKey 的属性,则搜索将继续在 this 的原型 parent 中进行。
基本操作与派生操作。我们可以看到 .[[Get]]() 调用了其他 MOP 操作。执行此操作的操作称为派生操作。不依赖于其他操作的操作称为基本操作。
Proxies 的元对象协议与普通对象的元对象协议不同。对于普通对象,派生操作会调用其他操作。对于 Proxies,每个操作(无论它是基本操作还是派生操作)要么被处理程序方法拦截,要么转发到目标。
哪些操作应该可以通过 Proxies 进行拦截?
后者的优点是它提高了性能并且更方便。例如,如果没有 get 的陷阱,我们必须通过 getOwnPropertyDescriptor 实现其功能。
包含派生陷阱的一个缺点是,这会导致 Proxies 的行为不一致。例如,get 返回的值可能与 getOwnPropertyDescriptor 返回的描述符中的值不同。
Proxies 的拦截是选择性的:我们不能拦截所有语言操作。为什么排除了一些操作?让我们看看两个原因。
首先,稳定操作不适合拦截。如果一个操作对于相同的参数总是产生相同的结果,那么它就是稳定的。如果 Proxy 可以捕获稳定操作,则它可能会变得不稳定,从而变得不可靠。严格相等 (===) 就是这样一种稳定操作。它不能被捕获,并且它的结果是通过将 Proxy 本身视为另一个对象来计算的。保持稳定性的另一种方法是对目标而不是 Proxy 应用操作。如后文所述,当我们查看如何对 Proxies 强制执行不变量时,当 Object.getPrototypeOf() 应用于目标不可扩展的 Proxy 时,就会发生这种情况。
不使更多操作可拦截的第二个原因是,拦截意味着在通常不可能的情况下执行自定义代码。这种代码交织发生的越多,程序就越难理解和调试。它也会对性能产生负面影响。
get 与 invoke如果我们想通过 Proxies 创建虚拟方法,我们必须从 get 陷阱返回函数。这就提出了一个问题:为什么不为方法调用引入一个额外的陷阱(例如 invoke)?这将使我们能够区分
obj.prop 获取属性(陷阱 get)obj.prop() 调用方法(陷阱 invoke)不这样做有两个原因。
首先,并非所有实现都区分 get 和 invoke。例如,Apple 的 JavaScriptCore 就没有。
其次,提取一个方法并稍后通过 .call() 或 .apply() 调用它应该与通过调度调用该方法具有相同的效果。换句话说,以下两种变体应该等效地工作。如果有一个额外的陷阱 invoke,那么这种等价性将更难维护。
// Variant 1: call via dynamic dispatch
const result1 = obj.m();
// Variant 2: extract and call directly
const m = obj.m;
const result2 = m.call(obj);invoke 的用例有些事情只有在我们能够区分 get 和 invoke 时才能完成。因此,使用当前的 Proxy API 无法做到这些事情。两个例子是:自动绑定和拦截丢失的方法。让我们看看如果 Proxies 支持 invoke,人们将如何实现它们。
自动绑定。通过使 Proxy 成为对象 obj 的原型,我们可以自动绑定方法
obj.m 检索方法 m 的值会返回一个函数,其 this 绑定到 obj。obj.m() 执行方法调用。自动绑定有助于将方法用作回调。例如,上一个示例中的变体 2 变得更简单
拦截丢失的方法。 invoke 允许 Proxy 模拟前面提到的 __noSuchMethod__ 机制。Proxy 将再次成为对象 obj 的原型。它会根据访问未知属性 prop 的方式做出不同的反应
obj.prop 读取该属性,则不会发生拦截,并返回 undefined。obj.prop(),则 Proxy 会进行拦截,例如通知回调。在我们查看不变量是什么以及如何对 Proxies 强制执行它们之前,让我们回顾一下如何通过不可扩展性和不可配置性来保护对象。
有两种保护对象的方法
不可扩展性保护对象:如果对象不可扩展,我们不能添加属性,也不能更改其原型。
不可配置性保护属性(或者更确切地说,保护它们的属性)
writable 控制是否可以更改属性的值。configurable 控制是否可以更改属性的属性。有关此主题的更多信息,请参阅§10“保护对象不被更改”。
传统上,不可扩展性和不可配置性是
这些和其他在面对语言操作时保持不变的特性称为不变量。通过 Proxies 很容易违反不变量,因为它们本质上不受不可扩展性等的约束。Proxy API 通过检查目标对象和处理程序方法的结果来防止这种情况发生。
接下来的两个小节描述了四个不变量。本章末尾给出了不变量的完整列表。
以下两个不变量涉及不可扩展性和不可配置性。这些是通过使用目标对象进行簿记来强制执行的:处理程序方法返回的结果必须与目标对象基本同步。
Object.preventExtensions(obj) 返回 true,则所有未来的调用都必须返回 false,并且 obj 现在必须是不可扩展的。true 但目标对象是可扩展的,则对 Proxies 强制执行此操作会引发 TypeError。Object.isExtensible(obj) 必须始终返回 false。Object.isExtensible(target)(强制转换后)不同,则对 Proxies 强制执行此操作会引发 TypeError。以下两个不变量是通过检查返回值来强制执行的
Object.isExtensible(obj) 必须返回一个布尔值。Object.getOwnPropertyDescriptor(obj, ···) 必须返回一个对象或 undefined。TypeError。强制执行不变量具有以下好处
接下来的两节将给出强制执行不变量的示例。
响应 getPrototypeOf 陷阱时,如果目标不可扩展,则代理必须返回目标的原型。
为了演示此不变量,让我们创建一个处理程序,该处理程序返回与目标原型不同的原型
如果目标是可扩展的,则伪造原型有效
const extensibleTarget = {};
const extProxy = new Proxy(extensibleTarget, handler);
assert.equal(
Object.getPrototypeOf(extProxy), fakeProto);但是,如果我们为不可扩展的对象伪造原型,则会收到错误消息。
const nonExtensibleTarget = {};
Object.preventExtensions(nonExtensibleTarget);
const nonExtProxy = new Proxy(nonExtensibleTarget, handler);
assert.throws(
() => Object.getPrototypeOf(nonExtProxy),
{
name: 'TypeError',
message: "'getPrototypeOf' on proxy: proxy target is"
+ " non-extensible but the trap did not return its"
+ " actual prototype",
});如果目标具有不可写、不可配置的属性,则处理程序必须响应 get 陷阱返回该属性的值。为了演示此不变量,让我们创建一个处理程序,该处理程序始终为属性返回相同的值。
const handler = {
get(target, propKey) {
return 'abc';
}
};
const target = Object.defineProperties(
{}, {
manufacturer: {
value: 'Iso Autoveicoli',
writable: true,
configurable: true
},
model: {
value: 'Isetta',
writable: false,
configurable: false
},
});
const proxy = new Proxy(target, handler);属性 target.manufacturer 不是不可写且不可配置的,这意味着允许处理程序假装它具有不同的值
但是,属性 target.model 是不可写且不可配置的。因此,我们无法伪造它的值
assert.throws(
() => proxy.model,
{
name: 'TypeError',
message: "'get' on proxy: property 'model' is a read-only and"
+ " non-configurable data property on the proxy target but"
+ " the proxy did not return its actual value (expected"
+ " 'Isetta' but got 'abc')",
});enumerate 陷阱在哪里?ECMAScript 6 最初有一个由 for-in 循环触发的陷阱 enumerate。但最近为了简化代理而将其删除。Reflect.enumerate() 也被删除了。(来源:TC39 笔记)
本节是代理 API 的快速参考
ProxyReflect该参考使用以下自定义类型
创建代理有两种方法
const proxy = new Proxy(target, handler)
使用给定的目标和处理程序创建一个新的代理对象。
const {proxy, revoke} = Proxy.revocable(target, handler)
创建一个可以通过函数 revoke 撤销的代理。revoke 可以被多次调用,但只有第一次调用有效,并将 proxy 关闭。之后,对 proxy 执行的任何操作都会导致抛出 TypeError。
本小节解释了处理程序可以实现哪些陷阱以及哪些操作会触发它们。一些陷阱会返回布尔值。对于陷阱 has 和 isExtensible,布尔值是操作的结果。对于所有其他陷阱,布尔值指示操作是否成功。
所有对象的陷阱
defineProperty(target, propKey, propDesc): boolean
Object.defineProperty(proxy, propKey, propDesc)deleteProperty(target, propKey): boolean
delete proxy[propKey]delete proxy.somePropget(target, propKey, receiver): any
receiver[propKey]receiver.somePropgetOwnPropertyDescriptor(target, propKey): undefined|PropDesc
Object.getOwnPropertyDescriptor(proxy, propKey)getPrototypeOf(target): null|object
Object.getPrototypeOf(proxy)has(target, propKey): boolean
propKey in proxyisExtensible(target): boolean
Object.isExtensible(proxy)ownKeys(target): Array<PropertyKey>
Object.getOwnPropertyPropertyNames(proxy)(仅使用字符串键)Object.getOwnPropertyPropertySymbols(proxy)(仅使用符号键)Object.keys(proxy)(仅使用可枚举的字符串键;可枚举性通过 Object.getOwnPropertyDescriptor 检查)preventExtensions(target): boolean
Object.preventExtensions(proxy)set(target, propKey, value, receiver): boolean
receiver[propKey] = valuereceiver.someProp = valuesetPrototypeOf(target, proto): boolean
Object.setPrototypeOf(proxy, proto)函数的陷阱(如果目标是函数,则可用)
apply(target, thisArgument, argumentsList): any
proxy.apply(thisArgument, argumentsList)proxy.call(thisArgument, ...argumentsList)proxy(...argumentsList)construct(target, argumentsList, newTarget): object
new proxy(..argumentsList)以下操作是*基本的*,它们不使用其他操作来完成其工作:apply、defineProperty、deleteProperty、getOwnPropertyDescriptor、getPrototypeOf、isExtensible、ownKeys、preventExtensions、setPrototypeOf
所有其他操作都是*派生的*,它们可以通过基本操作来实现。例如,get 可以通过 getPrototypeOf 迭代原型链并为每个链成员调用 getOwnPropertyDescriptor 来实现,直到找到自己的属性或链结束。
不变量是处理程序的安全约束。本小节记录了代理 API 强制执行的不变量以及如何执行。每当我们在下面读到“处理程序必须执行 X”时,这意味着如果不这样做,就会抛出 TypeError。一些不变量限制返回值,而另一些不变量限制参数。陷阱返回值的正确性通过两种方式确保
TypeError。这是强制执行的不变量的完整列表
apply(target, thisArgument, argumentsList): any
construct(target, argumentsList, newTarget): object
null 或任何其他原始值)。defineProperty(target, propKey, propDesc): boolean
propDesc 将属性 configurable 设置为 false,则目标必须具有一个不可配置的自有属性,其键为 propKey。propDesc 将属性 configurable 和 writable 都设置为 false,则目标必须具有一个键为 propKey 的自有属性,该属性是不可配置且不可写的。propKey 的自有属性,则 propDesc 必须与该属性兼容:如果我们使用描述符重新定义目标属性,则不得抛出异常。deleteProperty(target, propKey): boolean
propKey 的不可配置的自有属性。propKey 的自有属性。get(target, propKey, receiver): any
propKey 的自有、不可写、不可配置的数据属性,则处理程序必须返回该属性的值。undefined。getOwnPropertyDescriptor(target, propKey): undefined|PropDesc
undefined 或一个对象。getPrototypeOf(target): null|object
null 或一个对象。has(target, propKey): boolean
isExtensible(target): boolean
target.isExtensible() 相同。ownKeys(target): Array<PropertyKey>
preventExtensions(target): boolean
target.isExtensible() 为 false 时,处理程序才必须返回真值(表示更改成功)。set(target, propKey, value, receiver): boolean
propKey 的不可写、不可配置的数据属性,则无法更改该属性。在这种情况下,value 必须是该属性的值,否则将抛出 TypeError。setPrototypeOf(target, proto): boolean
proto 必须与目标的原型相同。否则,将抛出 TypeError。 ECMAScript 规范中的不变量
在规范中,不变量列在“代理对象内部方法和内部插槽”一节中。
普通对象的以下操作对原型链中的对象执行操作。因此,如果该链中的某个对象是代理,则会触发其陷阱。规范将操作实现为内部自有方法(JavaScript 代码不可见)。但在本节中,我们假设它们是与陷阱同名的普通方法。参数 target 成为方法调用的接收者。
target.get(propertyKey, receiver)target 没有具有给定键的自有属性,则在 target 的原型上调用 get。target.has(propertyKey)get 类似,如果 target 没有具有给定键的自有属性,则在 target 的原型上调用 has。target.set(propertyKey, value, receiver)get 类似,如果 target 没有具有给定键的自有属性,则在 target 的原型上调用 set。所有其他操作仅影响自有属性,对原型链没有影响。
ECMAScript 规范中的内部操作
在规范中,这些操作(和其他操作)在“普通对象内部方法和内部插槽”一节中进行了描述。
全局对象 Reflect 将 JavaScript 元对象协议的所有可拦截操作实现为方法。这些方法的名称与处理程序方法的名称相同,正如我们所见,这有助于将操作从处理程序转发到目标。
Reflect.apply(target, thisArgument, argumentsList): any
类似于 Function.prototype.apply()。
Reflect.construct(target, argumentsList, newTarget=target): objectnew 运算符作为函数。target 是要调用的构造函数,可选参数 newTarget 指向启动当前构造函数调用链的构造函数。
Reflect.defineProperty(target, propertyKey, propDesc): boolean
类似于 Object.defineProperty()。
Reflect.deleteProperty(target, propertyKey): booleandelete 运算符作为函数。但是,它的工作方式略有不同:如果它成功删除了属性或该属性从未存在,则返回 true。如果属性无法删除并且仍然存在,则返回 false。保护属性不被删除的唯一方法是使它们不可配置。在草率模式下,delete 运算符返回相同的结果。但在严格模式下,它会抛出 TypeError 而不是返回 false。
Reflect.get(target, propertyKey, receiver=target): any
获取属性的函数。可选参数 receiver 指向获取开始的对象。当 get 在原型链的后面到达 getter 时需要它。然后,它为 this 提供值。
Reflect.getOwnPropertyDescriptor(target, propertyKey): undefined|PropDesc
与 Object.getOwnPropertyDescriptor() 相同。
Reflect.getPrototypeOf(target): null|object
与 Object.getPrototypeOf() 相同。
Reflect.has(target, propertyKey): booleanin 运算符的函数形式。
Reflect.isExtensible(target): boolean
与 Object.isExtensible() 相同。
Reflect.ownKeys(target): Array<PropertyKey>
返回一个包含所有自身属性键的数组:所有自身可枚举和不可枚举属性的字符串键和符号键。
Reflect.preventExtensions(target): boolean
类似于 Object.preventExtensions()。
Reflect.set(target, propertyKey, value, receiver=target): boolean
用于设置属性的函数。
Reflect.setPrototypeOf(target, proto): boolean
设置对象原型的新的标准方法。当前的非标准方法(在大多数引擎中有效)是设置特殊属性 __proto__。
一些方法返回布尔值。对于 .has() 和 .isExtensible(),它们是操作的结果。对于其余方法,它们指示操作是否成功。
Reflect 用例除了转发操作之外,为什么 Reflect 很有用 [4]?
不同的返回值:Reflect 复制了 Object 的以下方法,但其方法返回布尔值,指示操作是否成功(而 Object 方法返回已修改的对象)。
Object.defineProperty(obj, propKey, propDesc): objectObject.preventExtensions(obj): objectObject.setPrototypeOf(obj, proto): object运算符作为函数:以下 Reflect 方法实现了只能通过运算符使用的功能
Reflect.construct(target, argumentsList, newTarget=target): objectReflect.deleteProperty(target, propertyKey): booleanReflect.get(target, propertyKey, receiver=target): anyReflect.has(target, propertyKey): booleanReflect.set(target, propertyKey, value, receiver=target): booleanapply() 的简短版本:如果我们想完全安全地调用函数上的 apply() 方法,我们不能通过动态调度来实现,因为该函数可能具有键为 'apply' 的自身属性
func.apply(thisArg, argArray) // not safe
Function.prototype.apply.call(func, thisArg, argArray) // safe使用 Reflect.apply() 比安全版本更短
删除属性时没有异常:如果我们尝试删除不可配置的自身属性,则 delete 运算符在严格模式下会抛出异常。在这种情况下,Reflect.deleteProperty() 返回 false。
Object.* 与 Reflect.*展望未来,Object 将托管对普通应用程序感兴趣的操作,而 Reflect 将托管更底层的操作。
至此,我们对 Proxy API 的深入研究就结束了。需要注意的一件事是,代理会降低代码的速度。如果性能至关重要,这可能很重要。
另一方面,性能通常并不重要,并且拥有代理提供的元编程能力是很好的。
致谢
Allen Wirfs-Brock 指出了 §18.3.7 “陷阱:并非所有对象都可以被代理透明地包装” 中解释的陷阱。
§18.4.3 “负数组索引 (get)” 的想法来自 博客文章,作者是 Hemanth.HM。
André Jaenisch 为使用代理的库列表做出了贡献。
[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 很有用。]