下述内容翻译自 Professional JavaScript for Web Developes,4th Edition(JavaScript 高级程序设计第四版),323 页,Proxies and Reflect(代理和反射) 章节。
代理和反射
在 ECMAScript6 中引入的代理(proxy)和反射(reflect)是全新的结构,能够拦截并修改语言中基础操作的附加行为。更具体地说,你可以定义一个与目标对象关联的代理对象,代理对象可以看作一个抽象的目标对象,可以在代理对象中控制各种操作,完成之前实际在目标对象执行的操作。
对于第一次接触这个主题的开发者来说,这是一个相当模糊的概念,堪比一个完整的新术语库。通过多个实例将有助于巩固理解。
注意:在 ES6 之前的 ECMAScript 版本中没有类似的代理概念。因为这是一种完全新的语言能力,转换器无法将代理行为转换为早期的 ECMAScript 版本,因为实际上不可能复制代理的行为。因此,代理和反射只有在 100%提供原生支持的平台上才可以使用。可以检测对代理的支持情况,并在不支持时改用备用代码,但这会导致代码重复,因此不提倡这样做。
代理基础
正如在引言中提到的,代理作为目标对象的抽象对象存在。它与 C++的指针在许多方面都很类似,因为它可以作为它所指向的目标对象的替代字段使用,但实际上,代理与目标对象完全分离。目标对象既可以直接操作,也可以通过代理操作,但是直接操作将绕过代理定义的行为。
注意:ECMAScript 代理和 C++指针之间有一些关键的区别将在后面讨论,但是出于介绍的目的,指针是一个合适的概念性模块。
创建一个直通代理
在最简单的形式中,代理只能作为一个抽象的目标对象存在。默认情况下,对代理对象执行的所有操作都将透明地传递到目标对象。因此,能够以目标对象相同的方式和位置来使用代理对象。
使用 Proxy 构造函数创建一个代理。要求同时提供目标对象和处理器对象,否则将抛出 TypeError。对于简单的直通代理,对处理器对象使用简单的对象文本将允许所有操作畅通无阻地到达目标对象。
如下所示,在代理上执行的所有操作都将有效地应用于目标对象。唯一可以感受到的区别是代理对象的识别符。
1 | const target = { |
定义陷阱
代理的主要目的是允许自定义陷阱(trap),它们在处理器对象(handler object)中起到”基础操作拦截器”的作用。每个处理器对象由零个、一个或多个陷阱组成,每个陷阱对应于一个基础操作,该操作可以直接或间接地在代理上调用。当对代理对象调用这些基础操作时,在应用到目标对象之前,代理将调用陷阱函数,从而允许接收和修改行为。
注意:“陷阱(trap)”这个术语借用自操作系统的范畴,其中的陷阱是程序流中的一个同步中断,它转移处理器的执行,并在返回到原始程序流之前执行一个子程序。
例如,可以定义一个 get()陷阱,这个陷阱在每次 ECMAScript 以某种形式执行 get()时触发。陷阱可以定义如下:
1 | const target = { |
当这个代理对象调用 get()操作时,将调用为 get()定义的陷阱函数。当然,get()不是 ECMAScript 对象上可用的方法。被捕获的 get()操作在多个操作之间共享,这些操作可以在实际的 JavaScript 代码中找到。表单代理的操作 proxy[property]、proxy.property 或 Object.create(proxy)[property]都将使用基本操作 get()来检索属性,因此所有这些操作都将在调用它们时调用代理上的陷阱函数。只有代理对象会使用陷阱函数处理器,这些操作与目标对象一起使用时将正常运行。
1 | const target = { |
陷阱参数和反射 API
所有陷阱都具有访问参数的权限,这些参数允许完全重新创建被捕获方法的原始行为。例如,get()方法接收对捕获对象的引用、正在查找的 property 以及对代理对象的引用三个参数。
1 | const target = { |
因此,可以定义一个陷阱处理器来完全重建被捕获方法的行为:
1 | const target = { |
这样的策略可以应用于所有的陷阱,但并非所有的陷阱行为都像 get()那样容易重建;因此,这是一个不切实际的策略。与手动实现捕获方法的内容不同,捕获方法的原始行为包装在全局 Reflect 对象上的一个名称相同的方法中。
每个可以被捕获在处理器对象中的方法都有一个对应的 Reflect API 方法。这个方法具有相同的名称和函数签名,并执行被捕获的方法正在拦截的确切行为。因此,仅使用 Reflect API 就可以定义一个直通代理:
1 | const target = { |
或者使用更简洁的格式:
1 | const target = { |
如果希望创建一个真正的直通代理来捕获所有可用方法,并将每个方法转发给其对应的 Reflect API 函数,则不需要定义一个显式处理器对象:
1 | const target = { |
Reflect API 允许使用最少的模板代码修改捕获的方法。例如每当访问某个特定属性时,都会使用下面的修饰符来修饰返回值:
1 | const target = { |
陷阱不变量
陷阱能够广泛地改变几乎任何基本方法的行为,但它们并非没有限制。每个被捕获的方法都能获取到目标对象上下文和陷阱函数签名,陷阱处理函数的行为必须遵守 ECMAScript 规范中指定的“陷阱不变量(trap invariants)”。陷阱不变量因方法而异,但一般而言,它们将防止陷阱定义表现出任何严重意外的行为。
例如,如果目标对象具有不可配置(non-configurable)和不可写(non-writable)的数据属性,这时尝试从陷阱对象返回与目标对象的属性不同的值,则会抛出 TypeError:
1 | const target = {}; |
可撤销的代理
有时会需要禁用代理对象和目标对象之间的关联。对于使用 new Proxy()创建的普通代理,此关联将保持代理对象的生命周期。
Proxy 还公开了一个 revoable()方法,提供了一个附加的撤销函数(revoke function),可以调用该函数将代理对象与目标对象分离。撤销代理是不可逆的,此外,撤销函数是幂等的,如果调用多次,则不会产生更多效果。在代理被撤销后调用任何方法都会抛出一个 TypeError。
撤销函数可以在实例化时代理捕获:
1 | const target = { |
Reflect API 的实用性
在某些情况下有很多原因需要使用 Reflect API。
Reflect API 对比 Object API
当深入使用 Reflect API 时,请记住:
- Reflect API 不仅限于陷阱处理器;
- 大多数 Reflect API 方法与 Object 的类似。
通常,Object 方法适用于一般应用,而 Reflect 方法适用于微调对象控制和操作。
状态标志
许多 Reflect 方法返回一个布尔值,表示执行的操作是否成功。在某些情况下,这比其他 Reflect API 方法的行为(将返回修改后的对象或抛出错误)更有用。例如,可以使用 Reflect API 执行以下重构:
1 | // 初始代码 |
如果定义新属性出现问题,Reflect.defineProperty 将返回 false,而不是抛出错误,这样就可以执行以下操作:
1 | // 重构后的代码 |
下面的 Reflect 方法为提供了状态标志:
- Reflect.defineProperty
- Reflect.preventExtensions
- Reflect.setPrototypeOf
- Reflect.set
- Reflect.deleteProperty
使用头等函数取代操作符
一些 Reflect 方法提供了只能通过操作符获得的行为:
- Reflect.get()来访问只能通过对象属性访问获得的行为。
- Reflect.set()来访问只能通过赋值操作符访问的行为。
- Reflect.has()来访问只能通过 in 操作符或 with()访问的行为。
- Reflect.deleteProperty()来访问只能通过 delete 操作符访问的行为。
- Reflect.construct()来访问只能通过 new 操作符访问的行为。
安全的函数应用
在使用 apply 方法调用函数时,被调用的函数很可能定义了自己的 apply 属性。为了避免这个问题,可以从函数原型中提取 apply 方法,如下所示:
1 | Function.prototype.apply.call(myFunc, thisVal, argumentList); |
使用 Reflect.apply 可以避免并完全复制上述糟糕的代码:
1 | Reflect.apply(myFunc, thisVal, argumentsList); |
代理一个 Proxy 331
代理能够拦截 Reflect API 操作,这意味着完全可以创建一个代理的代理。允许在单一目标对象上构建多个间接层:
1 | const target = { |
代理的思考与缺陷
代理是建立在现有 ECMAScript 基础之上的一种新的 API,因此它们的实现是最有效的。在大多数情况下,代理作为对象的虚拟层来说工作得非常有效。然而,在某些场景中,代理并不总能与现有的 ECMAScript 结构无缝集成。
代理中的 this
代理的一个潜在问题来源是 this。正如所期望的那样,方法中的 this 值将取决于被调用的对象:
1 | const target = { |
从直观上看,这是正确的行为:在代理上调用的任何方法 proxy.outerMethod(),将在其函数体内调用另一个方法 this.innerMethod(),也就是应该有效地调用 proxy.innerMethod()。在大多数情况下,这当然是预期的行为;但是如果目标依赖于对象标识,则可能会遇到意想不到的问题。
回想一下集合引用类型(Collection Reference Types)章节中的 WeakMap 私有变量实现,简化版本显示如下:
1 | const wm = new WeakMap(); |
因为此实现依赖于 User 实例的对象标识,所以当 user 实例被代理时将遇到问题:
1 | const user = new User(123); |
user 实例最初被键入到带有目标对象的 weakmap 中,但是代理试图用代理对象检索该实例。解决这个问题的办法是重新确定代理的位置,这样初始键的插入就可以通过一个代理实例来完成——这个思路可以通过代理 User 类本身来完成,并实例化该类的一个代理:
1 | const UserClassProxy = new Proxy(User, {}); |
代理和内部插槽
通常,内置引用类型的实例可以无缝地与代理一起工作(比如 Array)。但是一些 ECMAScript 内置类型会依赖于代理无法控制的机制。结果就是,封装后实例上的某些方法将无法正常工作。
这方面的典型示例是 Date 类型。根据 ECMAScript 规范,在执行方法时,Date 类型依赖于此值上名为[[NumberData]]的“内部插槽(Internal Slots)”。因为代理上不存在内部插槽位,而且这些内部插槽值不能通过常规的 get 和 set 操作访问,代理可能会拦截并重定向到目标,且抛出一个 TypeError:
1 | const target = new Date(); |
代理陷阱和 Reflect 方法
代理能够捕获十三种不同的基本操作。每个都在 Reflect API、参数、相关的 ECMAScript 操作和不变量中有自己的一部分。
如前文所述,几个不同的 JavaScript 操作可能会调用同一个陷阱处理器。但是,对于在代理对象上执行的任何单个操作,只会调用一个陷阱处理器;陷阱覆盖范围不重叠。
如果在代理上调用陷阱,那么它们都将截获对应的 Reflect API 操作。
get()
get()陷阱在检索属性值的操作中调用,其对应的 Reflect API 方法是 Reflect.get()。
1 | const myTarget = {}; |
返回值
返回值不受限制。
被拦截的操作
proxy.property
proxy[property]
Object.create(proxy)[property]
Reflect.get(proxy, property, receiver)
陷阱处理器参数
target: 目标对象
property: 在目标对象上引用的字符串类型键名
receiver: 代理对象或其继承继承
陷阱不变量
如果 target.property 是不可写和不可配置的,处理器返回值必须匹配 target.property。
如果 target.property 是不可配置的,并且没有定义它的[[Get]]属性,那么处理器返回值也必须是 undefined。
set()
set()陷阱在分配属性值的操作中调用,其对应的 Reflect API 方法是 Reflect.set()。
1 | const myTarget = {}; |
返回值
返回值为 true 表示成功;返回值为 false 表示失败,并且在严格模式下将抛出 TypeError。
被拦截的操作
proxy.property = value
proxy[property] = value
Object.create(proxy)[property] = value
Reflect.set(proxy, property, value, receiver)
陷阱处理器参数
target:目标对象
property:在目标对象上引用的字符串类型键名
value:分配给属性的值
receiver:原始的赋值接收者对象
陷阱不变量
如果 target.property 是不可写和不可配置的,则不能更改目标属性值。
如果 target.property 不可配置且未定义为其[[Set]]属性,则不能更改目标属性值。
从处理器返回 false 将在严格模式下抛出 TypeError。
has()
在 in 操作符中将调用 has()陷阱,其对应的 Reflect API 方法是 Reflect.has()。
1 | const myTarget = {}; |
返回值
has()必须返回一个布尔值,指示该属性是否存在。非布尔值将强制转换为布尔值返回。
被拦截的操作
property in proxy
property in Object.create(proxy)
with(proxy) {(property);}
Reflect.has(proxy, property)
陷阱处理器参数
target:目标对象
property:在目标对象上引用的字符串类型键名
陷阱不变量
如果存在自己的 target.property 且不可配置,则处理器必须返回 true。
如果存在自己的 target.property 且目标对象不可扩展,则处理器必须返回 true。
defineProperty()
defineproperty()陷阱在 Object.defineProperty()内部调用,其对应的 Reflect API 方法是 Reflect.defineProperty()。
1 | const myTarget = {}; |
返回值
defineproperty()必须返回一个布尔值,指示该属性是否已成功定义。非布尔值将强制转换为布尔值返回。
被拦截的操作
Object.defineProperty(proxy, property, descriptor)
Reflect.defineProperty(proxy, property, descriptor)
陷阱处理器参数
target:目标对象
property:在目标对象上引用的字符串类型键名
descriptor:包含 enumerable、configurable、writable、value、get 或 set
陷阱不变量
如果目标对象是不可扩展的,则不能添加属性。
如果目标对象具有可配置属性,则不能添加同一键名的不可配置属性。
如果目标对象具有不可配置属性,则不能添加同一键名的可配置属性。
getOwnPropertyDescriptor()
getownpropertydescriptor()陷阱在 Object.getOwnPropertyDescriptor()内部调用。它对应的 Reflect API 方法是 Reflect.getownpropertydescriptor()。
1 | const myTarget = {}; |
返回值
getownpropertydescriptor()必须返回一个对象,如果属性不存在,则返回 undefined。
被拦截的操作
property in proxy
property in Object.create(proxy)
with(proxy) {(property);}
Reflect.has(proxy, property)
陷阱处理器参数
target:目标对象
property:在目标对象上引用的字符串类型键名
陷阱不变量
如果存在自己的 target.property 并且不可配置,处理器必须返回一个对象来指示该属性的存在。
如果存在自己的 target.property 是并且可配置的,则处理器不能返回一个对象表明该属性可配置。
如果存在自己的 target.property 并且 target 是不可扩展的,处理器必须返回一个对象来指示该属性的存在。
如果 target.property 不存在,而且 target 是不可扩展的,则处理器必须返回 undefined 以指示该属性不存在。
如果 target.property 不存在,则处理器无法返回一个对象指示该属性可配置。
deleteProperty()
deleteproperty()陷阱在 delete 运算符内部调用,其对应的 Reflect API 方法是 Reflect.deleteProperty()。
1 | const myTarget = {}; |
返回值
deleteproperty()必须返回一个布尔值,指示该属性是否被成功删除。非布尔值将强制转换为布尔值返回。
被拦截的操作
delete proxy.property
delete proxy[property]
Reflect.deleteProperty(proxy, property)
陷阱处理器参数
target:目标对象
property:在目标对象上引用的字符串类型键名
陷阱不变量
- 如果存在自己的 target.property 并且不可配置,则处理器不能删除该属性。
ownKeys()
ownkeys()陷阱在 Object.keys()和类似的方法中调用,其对应的 Reflect API 方法是 Reflect.ownKeys()。
1 | const myTarget = {}; |
返回值
ownkeys()必须返回包含字符串或 symbol 的可枚举对象。
被拦截的操作
Object.getOwnPropertyNames(proxy)
Object.getOwnPropertySymbols(proxy)
Object.keys(proxy)
Reflect.ownKeys(proxy)
陷阱处理器参数
- target:目标对象
陷阱不变量
返回的可枚举对象必须包含目标的所有不可配置的属性。
如果 target 是不可扩展的,则返回的可枚举对象必须完全包含 target 的属性键。
getPrototypeOf()
getprototypeof()陷阱在 Object.getPrototypeOf()内部调用,其对应的 Reflect API 方法是 Reflect.getPrototypeOf()。
1 | const myTarget = {}; |
返回值
getprototypeof()必须返回一个对象或 null。
被拦截的操作
Object.getPrototypeOf(proxy)
Reflect.getPrototypeOf(proxy)
proxy.
__proto__
Object.prototype.isPrototypeOf(proxy)
proxy instanceof Object
陷阱处理器参数
- target:目标对象
陷阱不变量
- 如果 target 是不可扩展的,那么 Object.getPrototypeOf(proxy)的唯一有效返回值是从 Object.getPrototypeOf(target)返回的值。
setPrototypeOf()
setPrototypeOf()陷阱在 Object.setPrototypeOf()内部调用,其对应的 Reflect API 方法是 Reflect.setPrototypeOf()。
1 | const myTarget = {}; |
返回值
setprototypeof()必须返回一个布尔值,指示原型分配是否成功。非布尔值将强制转换为布尔值返回。
被拦截的操作
Object.setPrototypeOf(proxy)
Reflect.setPrototypeOf(proxy)
陷阱处理器参数
target:目标对象
Prototype:目标的预期替换原型,如果这是一个顶级原型,则为 null
陷阱不变量
- 如果目标是不可扩展的,那么唯一有效的原型参数是从 Object.getPrototypeOf(target)返回的值。
isExtensible()
isextensible()陷阱被 Object.isExtensible()内部调用,其对应的 Reflect API 方法是 Reflect.isExtensible ()。
1 | const myTarget = {}; |
返回值
isextensible()必须返回一个布尔值,指示是否可扩展。非布尔值将强制转换为布尔值返回。
被拦截的操作
Object.isExtensible(proxy)
Reflect.isExtensible(proxy)
陷阱处理器参数
- target:目标对象
陷阱不变量
如果目标是可扩展的,则处理器必须返回 true。
如果目标是不可扩展的,处理器必须返回 false。
preventExtensions()
preventextensions()陷阱在 Object.preventExtensions()内部调用,其对应的反映 API 方法是 Reflect.preventExtensions()。
1 | const myTarget = {}; |
返回值
preventextensions()必须返回一个布尔值,指示目标是否成功被设置为不可扩展。
被拦截的操作
Object.preventExtensions(proxy)
Reflect.preventExtensions(proxy)
陷阱处理器参数
- target:目标对象
陷阱不变量
- 如果 Object.isExtensible(proxy)为 false,则处理器必须返回 true。
apply()
当函数调用时会调用 apply()陷阱,其对应的 Reflect API 方法是 Reflect.apply()。
1 | const myTarget = {}; |
返回值
返回值不受限制。
被拦截的操作
proxy(…argumentsList)
Function.prototype.apply(thisArg, argumentsList)
Function.prototype.call(thisArg, …argumentsList)
Reflect.apply(target, thisArgument, argumentsList)
陷阱处理器参数
target:目标对象
thisArg:函数调用的 this 参数
argumentsList:函数调用的参数列表
陷阱不变量
- target 必须是函数对象。
construct()
在 new 操作符内部调用 construct()陷阱,其对应的 Reflect API 方法是 Reflect.construct()。
1 | const myTarget = function () {}; |
返回值
construct()必须返回一个对象。
被拦截的操作
new proxy(…argumentsList)
Reflect.construct(target, argumentsList, newTarget)
陷阱处理器参数
target:目标构造函数
argumentsList:传递给目标构造函数的参数列表
newTarget:最初被调用的构造函数
陷阱不变量
- target 必须能够用作构造函数。
代理模式
Proxy API 允许在代码中使用一些非常有用的模式。
跟踪属性访问
get、set 和 has 使能够完全洞察对象属性何时被访问和检查。如果在应用程序中提供了一个含有陷阱的代理,将能够准确地看到这个对象被访问的时间和位置:
1 | const user = { |
隐藏属性
在远程代码中,代理的内部结构完全隐藏,因此很容易隐藏目标对象上属性的存在。例如:
1 | const hiddenProperties = ['foo', 'bar']; |
属性验证
因为所有赋值都必须经过 set()陷阱,所以可以基于期望值的内容来允许或拒绝赋值:
1 | const target = { |
函数和构造函数的参数验证
与对象属性被验证和保护的方式相同,函数和构造函数的参数也可被审查。例如,一个函数可以确保它只提供特定类型的值:
1 | function median(...nums) { |
类似地,构造函数可以强制构造函数的必填参数:
1 | class User { |
数据绑定和观察
代理允许将运行时的各个完全不同的部分缠绕在一起。这就导致了允许各种各样的模式,能使不同的代码位相互交互。
例如,代理类可以绑定到一个全局实例集合,这样每个创建的实例都会被添加到该集合中:
1 | const userList = []; |
或者,一个集合可以绑定到一个发射器,这个发射器会在每次插入一个新实例时发射:
1 | const userList = []; |
总结
代理是 ECMAScript6 规范中最令人兴奋和激动的新增部分之一。尽管不支持向后编译,但是它们支持一个以前不可用的全新的元语法和抽象领域。
在高层次上,代理是一个真实 JavaScript 对象的透明虚拟层。在创建代理时,可以定义一个包含陷阱的处理器对象,这些陷阱是几乎所有基本的 JavaScript 运算符和方法都会遇到的拦截点。尽管由陷阱不变量绑定,这些陷阱捕获器允许修改这些基本方法的操作方式。
与代理一起出现的是 Reflect API,它提供了一组方法,这些方法封装了每个陷阱拦截的行为。可以将 Reflect API 看作是基本操作的集合,这些操作是几乎所有 JavaScript 对象 API 的构建模块。
代理的实用性几乎是无界的,它允许开发者使用优雅的新模式,例如跟踪属性访问、隐藏属性、防止修改或删除属性、函数参数验证、构造函数参数验证、数据绑定和观察等。