代理使我们能够拦截和自定义对对象执行的操作(例如获取属性)。它们是一种*元编程*特性。
在以下示例中
`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 的快速参考
Proxy
Reflect
该参考使用以下自定义类型
创建代理有两种方法
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.someProp
get(target, propKey, receiver): any
receiver[propKey]
receiver.someProp
getOwnPropertyDescriptor(target, propKey): undefined|PropDesc
Object.getOwnPropertyDescriptor(proxy, propKey)
getPrototypeOf(target): null|object
Object.getPrototypeOf(proxy)
has(target, propKey): boolean
propKey in proxy
isExtensible(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] = value
receiver.someProp = value
setPrototypeOf(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): object
new
运算符作为函数。target
是要调用的构造函数,可选参数 newTarget
指向启动当前构造函数调用链的构造函数。
Reflect.defineProperty(target, propertyKey, propDesc): boolean
类似于 Object.defineProperty()
。
Reflect.deleteProperty(target, propertyKey): boolean
delete
运算符作为函数。但是,它的工作方式略有不同:如果它成功删除了属性或该属性从未存在,则返回 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): boolean
in
运算符的函数形式。
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): object
Object.preventExtensions(obj): object
Object.setPrototypeOf(obj, proto): object
运算符作为函数:以下 Reflect
方法实现了只能通过运算符使用的功能
Reflect.construct(target, argumentsList, newTarget=target): object
Reflect.deleteProperty(target, propertyKey): boolean
Reflect.get(target, propertyKey, receiver=target): any
Reflect.has(target, propertyKey): boolean
Reflect.set(target, propertyKey, value, receiver=target): boolean
apply()
的简短版本:如果我们想完全安全地调用函数上的 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
很有用。]