get,set)get,set)get)set)enumerate 陷阱在哪里?代理使您能够拦截和自定义对对象执行的操作(例如获取属性)。它们是一种*元编程*特性。
在以下示例中,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 参考。
在我们深入了解代理是什么以及它们为什么有用之前,我们首先需要了解什么是*元编程*。
在编程中,存在不同的级别
基础级别和元级别可以使用不同的语言。在以下元程序中,元编程语言是 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.* 方法都可以被视为元编程功能。
反射式元编程意味着程序处理自身。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 不支持拦截;创建代理是为了填补这一空白。
ECMAScript 6 代理为 JavaScript 带来了拦截。它们的工作原理如下。您可以对对象 obj 执行许多操作。例如
obj 的属性 prop(obj.prop)obj 是否具有属性 prop('prop' in obj)代理是允许您自定义其中一些操作的特殊对象。代理使用两个参数创建
handler:对于每个操作,都有一个相应的处理器方法,如果存在,则执行该操作。这种方法*拦截*操作(在到达目标的途中),并被称为*陷阱*(从操作系统领域借用的术语)。target:如果处理器没有拦截操作,则在目标上执行该操作。也就是说,它充当处理器的后备。在某种程度上,代理包装了目标。在以下示例中,处理器拦截了 get 和 has 操作。
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'
如果目标是函数,则可以拦截另外两个操作
apply:进行函数调用,通过以下方式触发proxy(···)proxy.call(···)proxy.apply(···)construct:进行构造函数调用,通过以下方式触发new proxy(···)仅对函数目标启用这些陷阱的原因很简单:否则,您将无法转发 apply 和 construct 操作。
如果要通过代理拦截方法调用,则存在一个挑战:您可以拦截操作 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);
},
};
tracedObj 是 obj 的跟踪版本。每次方法调用后的第一行是 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 继续引用代理。
这不是最有效的解决方案。例如,可以缓存方法。此外,代理本身也会影响性能。
ECMAScript 6 允许您创建可以*撤销*(关闭)的代理
const {proxy, revoke} = Proxy.revocable(target, handler);
在赋值运算符 (=) 的左侧,我们使用解构来访问 Proxy.revocable() 返回的对象的属性 proxy 和 revoke。
首次调用函数 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
代理 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 的原因。还有更多影响原型的操作;它们列在本章末尾。
处理器没有实现其陷阱的操作会自动转发到目标。有时,除了转发操作之外,您还想执行一些任务。例如,一个拦截所有操作并记录它们的处理器,但不会阻止它们到达目标
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
代理对象可以看作是拦截对其目标对象执行的操作——代理包装了目标。代理的处理器对象就像代理的观察者或侦听器。它通过实现相应的方法(get 用于读取属性等)来指定应拦截哪些操作。如果缺少某个操作的处理器方法,则不会拦截该操作。它只是被转发到目标。
因此,如果处理器是空对象,则代理应该透明地包装目标。唉,这并不总是有效。
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 上的方法时继续处于循环中。
通常情况下,带有空处理程序的代理可以透明地包装目标:您不会注意到它们的存在,并且它们不会改变目标的行为。
但是,如果目标通过代理无法控制的机制将信息与 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
大多数内置构造函数的实例也具有代理无法拦截的机制。因此,它们也不能透明地包装。我将演示 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]]内部插槽的对象。
与其他内置函数相比,数组可以透明包装
> const p = new Proxy(new Array(), {});
> p.push('a');
> p.length
1
> p.length = 0;
> p.length
0
数组可包装的原因是,即使属性访问已自定义以使 length 工作,但数组方法也不依赖于内部插槽 - 它们是通用的。
作为解决方法,您可以更改处理程序转发方法调用的方式,并有选择地将 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 指出本节中解释的陷阱。
本节演示代理的用途。这将使您有机会看到 API 的实际应用。
get、set) 假设我们有一个函数 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);
},
});
}
get、set) 在访问属性时,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 添加新的(自身)属性。
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 的博客文章。
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
代理可用于创建可以调用任意方法的对象。在以下示例中,函数 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();
});
}
*可撤销引用*的工作原理如下:不允许客户端直接访问重要资源(对象),只能通过引用(中间对象,资源的包装器)访问。通常,应用于引用的每个操作都会转发到资源。客户端完成后,通过*撤销*引用(关闭引用)来保护资源。此后,对引用应用操作会引发异常,并且不再转发任何内容。
在以下示例中,我们为资源创建了一个可撤销的引用。然后,我们通过引用读取资源的属性之一。这是可行的,因为引用授予我们访问权限。接下来,我们撤销引用。现在,引用不再让我们读取属性了。
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 };
}
*膜*建立在可撤销引用的基础上:设计用于运行不受信任代码的环境会在该代码周围包裹一层膜,以隔离代码并确保系统其余部分的安全。对象在两个方向上传递膜
在这两种情况下,可撤销引用都包装在对象周围。包装函数或方法返回的对象也被包装。此外,如果将包装的湿对象传递回膜中,则会将其解包。
不受信任的代码完成后,所有可撤销的引用都将被撤销。结果,它在外部的任何代码都无法再执行,并且它拥有的外部对象也将停止工作。Caja 编译器是“一种用于使第三方 HTML、CSS 和 JavaScript 安全地嵌入到您的网站中的工具”。它使用膜来完成这项任务。
浏览器文档对象模型 (DOM) 通常作为 JavaScript 和 C++ 的混合实现。在纯 JavaScript 中实现它对于以下方面很有用
唉,标准 DOM 可以做一些在 JavaScript 中不容易复制的事情。例如,大多数 DOM 集合都是 DOM 当前状态的实时视图,每当 DOM 发生更改时,这些视图都会动态更改。结果,DOM 的纯 JavaScript 实现效率不高。将代理添加到 JavaScript 的原因之一是帮助编写更高效的 DOM 实现。
代理还有更多用例。例如
在本节中,我们将深入探讨代理的工作原理以及它们为何以这种方式工作。
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 中,基础级别和元级别有时也会混合。例如,以下元编程机制可能会失败,因为它们存在于基础级别
obj.hasOwnProperty(propKey):如果原型链中的属性覆盖了内置实现,则此调用可能会失败。例如,如果 obj 是 { hasOwnProperty: null }
调用此方法的安全方法是
Object.prototype.hasOwnProperty.call(obj, propKey)
// Abbreviated version:
{}.hasOwnProperty.call(obj, propKey)
func.call(···)、func.apply(···):对于这两种方法,问题和解决方案与 hasOwnProperty 相同。obj.__proto__:在大多数 JavaScript 引擎中,__proto__ 是一个特殊属性,允许您获取和设置 obj 的原型。因此,当您将对象用作字典时,必须小心 避免将 __proto__ 用作属性键。到目前为止,应该很明显,使(基础级别)属性键特殊是有问题的。因此,代理是*分层的*——基础级别(代理对象)和元级别(处理程序对象)是分开的。
代理在两个角色中使用
代理 API 的早期设计将代理视为纯粹的虚拟对象。但是,事实证明,即使在该角色中,目标也是有用的,可以强制执行不变量(稍后解释)并作为处理程序未实现的陷阱的回退。
代理以两种方式屏蔽
这两个原则都赋予代理模拟其他对象的强大能力。强制执行*不变量*(稍后解释)的一个原因是控制这种能力。
如果您确实需要一种方法来区分代理和非代理,则必须自己实现。以下代码是一个模块 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
本节将检查 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 方法是
[[GetOwnProperty]](陷阱 getOwnPropertyDescriptor)[[GetPrototypeOf]](陷阱 getPrototypeOf)[[Get]](陷阱 get)[[Call]](陷阱 apply)在 A 行中,您可以看到为什么原型链中的代理在“早期”对象中找不到属性时会找出 get:如果没有键为 propKey 的自有属性,则搜索将继续在 this 的原型 parent 中进行。
**基本操作与派生操作。**您可以看到 [[Get]] 调用了其他 MOP 操作。执行此操作的操作称为*派生的*。不依赖于其他操作的操作称为*基本的*。
代理的元对象协议 与普通对象的元对象协议不同。对于普通对象,派生操作调用其他操作。对于代理,每个操作(无论它是基本的还是派生的)要么被处理程序方法拦截,要么转发到目标。
哪些操作应该可以通过代理进行拦截?一种可能性是仅为基本操作提供陷阱。另一种方法是包括一些派生操作。这样做的好处是可以提高性能并且更加方便。例如,如果没有 get 的陷阱,则必须通过 getOwnPropertyDescriptor 实现其功能。派生陷阱的一个问题是它们可能导致代理行为不一致。例如,get 返回的值可能与 getOwnPropertyDescriptor 返回的描述符中的值不同。
代理的拦截是*选择性的*:您不能拦截所有语言操作。为什么排除了一些操作?让我们看看两个原因。
首先,稳定的操作不适合拦截。如果一个操作对于相同的参数总是产生相同的结果,那么它就是*稳定的*。如果代理可以捕获稳定的操作,它就会变得不稳定,从而变得不可靠。严格相等 (===) 就是这样一种稳定的操作。它不能被捕获,并且它的结果是通过将代理本身视为另一个对象来计算的。保持稳定性的另一种方法是对目标而不是代理应用操作。如后文所述,当我们查看如何对代理强制执行不变量时,当 Object.getPrototypeOf() 应用于目标不可扩展的代理时,就会发生这种情况。
不使更多操作可拦截的第二个原因是,拦截意味着在通常不可能的情况下执行自定义代码。代码的这种交织越多,程序就越难理解和调试。它还会对性能产生负面影响。
get 与 invoke 如果要通过 ECMAScript 6 代理创建虚拟方法,则必须从 get 陷阱返回函数。这就提出了一个问题:为什么不为方法调用引入额外的陷阱(例如 invoke)?这将使我们能够区分
obj.prop 获取属性(陷阱 get)obj.prop() 调用方法(陷阱 invoke)不这样做有两个原因。
首先,并非所有实现都区分 get 和 invoke。例如,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);
invoke 的用例 有些事情只有在您能够区分 get 和 invoke 时才能完成。因此,使用当前的代理 API 不可能做到这些事情。两个例子是:自动绑定和拦截丢失的方法。让我们看看如果代理支持 invoke,将如何实现它们。
**自动绑定。**通过使代理成为对象 obj 的原型,您可以自动绑定方法
obj.m 检索方法 m 的值将返回一个函数,其 this 绑定到 obj。obj.m() 执行方法调用。自动绑定有助于将方法用作回调。例如,上一个示例中的变体 2 变得更简单
const boundMethod = obj.m;
const result = boundMethod();
**拦截丢失的方法。**invoke 允许代理模拟前面提到的 Firefox 支持的 __noSuchMethod__ 机制。代理将再次成为对象 obj 的原型。它会根据如何访问未知属性 foo 而做出不同的反应
obj.foo 读取该属性,则不会发生拦截,并且返回 undefined。obj.foo(),则代理会拦截并例如通知回调。在我们查看不变量是什么以及如何对代理强制执行它们之前,让我们回顾一下如何通过不可扩展性和不可配置性来保护对象。
有两种保护对象的方法
**不可扩展性。**如果对象是不可扩展的,则不能添加属性,也不能更改其原型
'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
**不可配置性。**属性的所有数据都存储在*属性*中。属性就像一条记录,而属性就像该记录的字段。属性示例
value 保存属性的值。writable 控制是否可以更改属性的值。configurable 控制是否可以更改属性的属性。因此,如果一个属性既不可写也不可配置,那么它是只读的,并且保持这种状态
'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”中的以下部分
传统上,不可扩展性和不可配置性是
这些和其他在面对语言操作时保持不变的特性称为*不变量*。使用代理,很容易违反不变量,因为它们本质上不受不可扩展性等的约束。
代理 API 通过检查处理程序方法的参数和结果来防止代理违反不变式。以下是四个不变式示例(针对任意对象 obj)以及如何对代理强制执行它们(本章末尾提供了完整列表)。
前两个不变式涉及不可扩展性和不可配置性。这些是通过使用目标对象进行簿记来强制执行的:处理程序方法返回的结果必须与目标对象基本同步。
Object.preventExtensions(obj) 返回 true,则所有未来的调用都必须返回 false,并且 obj 现在必须是不可扩展的。true 但目标对象不可扩展,则通过抛出 TypeError 来对代理强制执行。Object.isExtensible(obj) 必须始终返回 false。Object.isExtensible(target)(强制转换后)不同,则通过抛出 TypeError 来对代理强制执行。其余两个不变式通过检查返回值来强制执行
Object.isExtensible(obj) 必须返回一个布尔值。Object.getOwnPropertyDescriptor(obj, ···) 必须返回一个对象或 undefined。TypeError 来对代理强制执行。强制执行不变式具有以下好处
接下来的两节将举例说明如何强制执行不变式。
为了响应 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
如果目标具有不可写不可配置的属性,则处理程序必须在响应 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
enumerate 陷阱在哪里? ES6 最初有一个由 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.foo // propKey = 'foo'get(target, propKey, receiver) : any
receiver[propKey]receiver.foo // propKey = 'foo'getOwnPropertyDescriptor(target, propKey) : PropDesc|Undefined
Object.getOwnPropertyDescriptor(proxy, propKey)getPrototypeOf(target) : Object|Null
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.foo = value // propKey = 'foo'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
所有其他操作都是*派生的*,它们可以通过基本操作来实现。例如,对于数据属性,可以通过 getPrototypeOf 迭代原型链并为每个链成员调用 getOwnPropertyDescriptor 来实现 get,直到找到自己的属性或链结束。
不变式是处理程序的安全约束。本小节记录了代理 API 强制执行的不变式以及如何强制执行。每当您在下面阅读“处理程序必须执行 X”时,都意味着如果它没有执行,则会抛出 TypeError。一些不变式限制返回值,而另一些则限制参数。陷阱返回值的正确性通过两种方式确保:通常,非法值意味着抛出 TypeError。但是,每当需要布尔值时,都会使用强制转换将非布尔值转换为合法值。
这是强制执行的不变式的完整列表
apply(target, thisArgument, argumentsList)
construct(target, argumentsList, newTarget)
null 或原始值)。defineProperty(target, propKey, propDesc)
propKey 必须是目标的自有键之一。propDesc 将属性 configurable 设置为 false,则目标必须具有一个不可配置的自有属性,其键为 propKey。propDesc 为目标(重新)定义自有属性,则不得导致异常。如果属性 writable 和 configurable 禁止更改,则会抛出异常(不可扩展性由第一条规则处理)。deleteProperty(target, propKey)
get(target, propKey, receiver)
propKey,则处理程序必须返回该属性的值。undefined。getOwnPropertyDescriptor(target, propKey)
undefined。writable 和 configurable 不允许更改,则会抛出异常(不可扩展性由第三条规则处理)。因此,处理程序不能将不可配置的属性报告为可配置的,并且不能为不可配置的不可写属性报告不同的值。getPrototypeOf(target)
null。has(target, propKey)
isExtensible(target)
target.isExtensible() 相同。ownKeys(target)
preventExtensions(target)
target.isExtensible() 之后必须为 false。set(target, propKey, value, receiver)
propKey,则 value 必须与该属性的值相同(即,该属性不能被更改)。TypeError(即,不能设置此类属性)。setPrototypeOf(target, proto)
proto 必须与目标的原型相同。否则,将抛出 TypeError。普通对象的以下操作会在原型链中的对象上执行操作。因此,如果该链中的某个对象是代理,则会触发其陷阱。规范将这些操作实现为内部自身方法(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。所有其他操作仅影响自身属性,它们对原型链没有影响。
全局对象 Reflect 将 JavaScript 元对象协议的所有可拦截操作实现为方法。这些方法的名称与处理程序方法的名称相同,正如我们所见,这有助于将操作从处理程序转发到目标。
Reflect.apply(target, thisArgument, argumentsList) : anyFunction.prototype.apply() 相同。Reflect.construct(target, argumentsList, newTarget=target) : Objectnew 运算符作为函数。target 是要调用的构造函数,可选参数 newTarget 指向启动当前构造函数调用链的构造函数。有关 ES6 中如何链接构造函数调用的更多信息,请参阅关于类的章节。Reflect.defineProperty(target, propertyKey, propDesc) : booleanObject.defineProperty() 类似。Reflect.deleteProperty(target, propertyKey) : booleandelete 运算符作为函数。不过,它的工作方式略有不同:如果它成功删除了属性或该属性从未存在,则返回 true。如果无法删除该属性并且该属性仍然存在,则返回 false。保护属性不被删除的唯一方法是使其不可配置。在松散模式下,delete 运算符返回相同的结果。但在严格模式下,它会抛出 TypeError 而不是返回 false。Reflect.get(target, propertyKey, receiver=target) : anyget 在原型链的后面到达 getter 时,需要可选参数 receiver。然后它为 this 提供值。Reflect.getOwnPropertyDescriptor(target, propertyKey) : PropDesc|UndefinedObject.getOwnPropertyDescriptor() 相同。Reflect.getPrototypeOf(target) : Object|NullObject.getPrototypeOf() 相同。Reflect.has(target, propertyKey) : booleanin 运算符作为函数。Reflect.isExtensible(target) : booleanObject.isExtensible() 相同。Reflect.ownKeys(target) : Array<PropertyKey>Reflect.preventExtensions(target) : booleanObject.preventExtensions() 类似。Reflect.set(target, propertyKey, value, receiver=target) : booleanReflect.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) : ObjectReflect 方法实现的功能只能通过运算符获得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() 更短
Reflect.apply(func, thisArg, argArray)
delete 运算符会在严格模式下抛出异常。在这种情况下,Reflect.deleteProperty() 返回 false。Object.* 与 Reflect.* 展望未来,Object 将托管对普通应用程序感兴趣的操作,而 Reflect 将托管更底层的操作。
至此,我们对代理 API 的深入研究就结束了。对于每个应用程序,您都必须考虑性能,并在必要时进行测量。代理可能并不总是足够快。另一方面,性能通常并不重要,并且拥有代理提供的元编程能力是很好的。正如我们所见,它们可以帮助解决许多用例。
[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 很有用。]