本文尝试主要从三个方面介绍 ES6 的特性 Proxy 和 Reflect: Proxy 是什么,Proxy 有什么以及 Proxy 能做什么。
介绍完 Proxy 之后,我们再简单探究一下 Proxy 和 Mobx 的联系,以及从 Proxy 的角度去尝试理解 Mobx v5。
推荐:★★★★
01.目录
02.Proxy是什么
02.01 MDN 定义
Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。
- 这里有三个关键词,已经分别加粗展示,我们一个个来理解。
目标对象
- Proxy 作为对象的代理,只能用在引用类型的对象上,例如数组,对象,函数等等。
- 代理不可以对基本类型进行处理,如果传入基本类型的参数,在调用构造函数的时候就会报错。
1 | const p1 = new Proxy(1, {}); // Uncaught TypeError: Cannot create proxy with a non-object as target or handler |
基本操作的拦截
- 例如针对对象的读取属性和写入属性操作进行拦截。
- 依次执行下面的代码,可以理解读写的基本操作拦截的简单实现。
1 | const myTarget = { name: "Jack" }; |
自定义操作
- 如定义所言,自定义的操作可以包括属性查找、赋值、枚举、函数调用。
- 这里先不展开,后面会详细说明这些处理函数。
02.02 基本使用
- Proxy 构造函数接收两个对象作为参数。第一个参数是目标对象,如上文所说,这个参数必须是对象格式的,否则调用时就会抛出 TypeError。或者可以我们可以理解为原始对象。
- Proxy 的第二个参数就是包含处理函数的对象,它也必须是一个对象。可以给它传入对应的处理函数,当然也可以什么都不做。如果传入的是基本类型的参数,则同样会抛出异常。
- 这是 TS 的定义。可以先关注 new 的部分,可撤销的部分后文会聊到。
1 | interface ProxyConstructor<T extends object> { |
- 下面我们创建一个什么都不做的 Proxy,并打印观察它的行为。
1 | const target = { foo: "bar" }; |
- 默认情况下,Proxy 和原始目标对象的行为是一致的。
03.Proxy有什么
- 上面的代码运行之后发现,代理对象的行为和原始目标对象的行为是一致的,但是这样似乎没有什么实际的意义。而让 Proxy 真正变得强大的,是它的处理函数
handlers
。
03.01 Proxy 的处理函数
- 下面是所有可以自定义的处理函数以及它们的 TS 定义,这些函数也被称之为 traps,通常翻译成捕获器,也可以理解成劫持。
1 | interface ProxyHandler<T extends object> { |
handler.get()
和 handler.set()
- 这两个方法比较接近,所以放在一起说。
- 它们分别会在获取属性值以及设置属性值的操作中被调用。
get()
get 捕获器的返回值可以是任意的。
具体的捕获场景有以下四种:
proxy.property
proxy[property]
Object.create(proxy)[property]
Reflect.get(proxy, property, receiver)
get 方法的参数:
1
2
3
4
5
6/**
* @param {T extends Object} target 目标对象
* @param {string|Symbol} property 引用的目标对象上的字符串键属性或 Symbol 键
* @param {T extends Object} receiver 代理对象或继承代理对象的对象
*/
get?(target: T, property: string | symbol, receiver: any): any;
set()
set 捕获器的返回值是布尔值,标识设置属性值是否成功,不过如果不返回布尔值,也不会抛出异常。
具体的捕获场景有以下四种:
proxy.property = value
proxy[property] = value
Object.create(proxy)[property] = value
Reflect.set(proxy, property, value, receiver)
set 方法的参数:
1
2
3
4
5
6
7/**
* @param {T extends Object} target 目标对象
* @param {string|Symbol} property 引用的目标对象上的字符串键属性或 Symbol 键
* @param {any} value 赋给属性的值
* @param {T extends Object} receiver 接收最初赋值的对象
*/
set?(target: T, property: string | symbol, value: any, receiver: any): boolean;
应用 get 和 set
- 下面是简单的使用,读取 proxy 中的属性会触发,但是直接操作目标对象 target,是不会触发代理的事件的。
1 | const myTarget = { name: "Bob" }; |
handler.has()
has()
捕获器会在 in 操作符中被调用。它会返回一个布尔值来标识属性是否存在于代理对象中,如果显式返回非布尔值会被隐式转换成布尔值。
具体的的捕获场景有以下四种:
property in proxy
property in Object.create(proxy)
with (proxy) { (property) }
Reflect.has(proxy, property)
has 方法的参数:
1
2
3
4
5/**
* @param {T extends Object} target 目标对象
* @param {string|Symbol} property 引用的目标对象上的字符串键属性或 Symbol 键
*/
has?(target: T, property: string | symbol): boolean;
handler.deleteProperty()
这个捕获器会在删除属性的时候被调用。
它会返回一个布尔值来标识属性是否删除成功,如果显式返回非布尔值会被隐式转换成布尔值。
具体的捕获场景有以下三种:
delete proxy.property
delete proxy[property]
Reflect.deleteProperty(proxy, property)
deleteProperty 方法的参数:
1
2
3
4
5/**
* @param {T extends Object} target 目标对象
* @param {string|Symbol} property 引用的目标对象上的字符串键属性或 Symbol 键
*/
deleteProperty?(target: T, property: string | symbol): boolean;
应用 has 和 deleteProperty
- 下面是简单使用
has
和deleteProperty
来判断属性的存在与否。
1 | const myTarget = { foo: "bar" }; |
- Proxy 的其他自定义函数因为相对使用较少暂且省略介绍,感兴趣的可以前往 MDN 继续了解。
03.02 Proxy 的撤销
- 在一般情况下,我们使用 Proxy 不太会想要撤销。而通过 new 运算符创建的 Proxy 对象在其生命周期内是始终和目标对象关联的,没有办法改变。
- 如果期望创建一个能够撤销的 Proxy 对象,可以通过 Proxy 的静态方法
revocable
来实现。 - 我们创建一个可以撤销的 Proxy 对象,它和用 new 运算符生成的对象没有什么不同。
1 | // revocable<T extends object>(target: T, handler: ProxyHandler<T>): { proxy: T; revoke: () => void; }; |
- 执行撤销方法之后,所有关于 proxy 的操作都会抛出异常。撤销之后,也是不可逆的。
1 | console.log(proxy.foo); // bar |
04.Reflect
- 在了解 Proxy 的过程中,必须要了解的另一个全局对象是 Reflect。
- 它相当于是把散落各处的全局 API 做一个收拢,可以统一处理并调用。
04.01 MDN 定义
Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与 proxy
handlers
的方法相同。
Reflect 不是一个函数对象,因此是不可构造的,不能通过 new 运算符来调用,它的所有属性和方法都是静态的。类似Math
对象的使用。
04.02 Reflect 的处理函数
- 下面是所有的处理函数的 TS 定义。我们通过定义来概览一下 Reflect。
1 | declare namespace Reflect { |
04.03 Reflect 结合 Proxy 使用
- Reflect 的处理函数和 Proxy 的处理函数是完全一致的,但显然它们肯定也有着自己的用途。
- 篇幅所限,我们这里着重关注 set 和 get 方法。先看下面的例子。
1 | const myTarget = { |
- 观察上一段代码并执行,我们的预期是在打印【读取 bar 属性】之后,继续打印出【读取 foo 属性】。
- 然而实际上并没有。
- 当我们加上打印语句或分析可以发现,实际上这里的访问器属性 this 指向的是目标对象
myTarget
而不是代理对象myProxy
.- 使用
Reflect.get
可以解决这个问题.
- 使用
1 | const myTarget = { |
根本原因是
Reflect.get
方法可以正确找到指向的对象。相当于执行了
target[property].call(target)
.如果 target 对象中指定了 getter,receiver 则为 getter 调用时的 this 值。
例如当target[property]
是 getter 函数时,receiver 就是对应的 this 值。把
Reflect.get
方法的定义单拎出来,可以发现函数参数的定义处有明确的注释说明。
1 | /** |
- 同理可以推导出
Reflect.set
的使用方式,这里不再详细说明。 - 也是因为 this 的指向问题, Proxy 通常会配合 Reflect 使用。
04.04 比较 Reflect 和 Object
- 从方法名来看,它的许多 API 和 Object 相同,但有一些细微的区别。
Reflect.defineProperty()
- 例如
Reflect.defineProperty
方法,通过 Reflect 调用会返回布尔值,而在 Object 上调用则会在不满足条件的时候直接抛出异常。 Object.definedProperty
实际上也可以操作函数或数组,但如果这样调用,语义上容易令人误解,通过 Reflect 调用就会清晰很多.Object.defineProperty([], {})
/Object.defineProperty(() => {}, {})
Reflect.defineProperty([], {})
/Reflect.defineProperty(() => {}, {})
Reflect.ownKeys()
- 而
Reflect.ownKeys
可以在获取对象的常规属性的同时,还可以获取到Symbol
类型的键,而Object.keys
只能获取到常规的字符串属性键。 - 实际效果等同于下面两个方法结合使用。
1 | const obj = {}; |
- 可以说,Reflect 是基于 Object、Function 等类型做了扩充和完善,在其行为上保持一致性。
- 可以把 Reflect 简单理解成是 Object 的扩展。
05.Proxy的限制
05.01 处理函数的使用遵从不变式
- 如果在处理函数中操作不可配置的属性,则可能会抛出异常。具体每个处理函数都有自己的不变式,出现违背时就会抛
TypeError
. 我们可以把它理解成是一种强制的约束。
05.02 this 的注意事项
- 通常情况下,this 可以执行得到预期的结果。
1 | const myTarget = { |
- 这里得到的 this 指向是符合预期的,指向具体的调用方。
- 但如果是另外一个例子。
1 | const wm = new WeakMap(); |
- 究其原因,this 在调用时指向当前上下文。在这个地方,指向的是构造函数。
- user 的指向是 User 构造函数,可以正常运行。
- userInstanceProxy 的指向是 Proxy, 就不符合预期了。
- 对其稍加改造一下,可以得到预期的结果。
1 | const wm = new WeakMap(); |
05.03 内置插槽(内置方法依赖 this)
1 | const myDate = new Date(); |
- 在 Reflect 的说明中,我们提到了期望 this 指向符合直觉的实现。然而在某些情况下,我们希望它指向时的上下文是原来的目标对象 target 而不是代理对象。
- 例如上面的
getDate
。因为在日期对象里有许多 Proxy 所没有的数据属性,Proxy 对象在执行时读取不存在的属性,就会导致报错。getDate
里对应的数据属性就是[[NumberDate]]
. - 在类似的实现中,需要手动绑定 this 到原来的目标对象中,才可以执行对应的方法。
- 例如上面的
1 | const myDate = new Date(); |
- 另一个可能比较常见的例子是代理 Map 对象,Proxy 没有 Map 的内部插槽
[[MapData]]
。因此也需要类似处理,否则会抛出异常。
05.04 Proxy 不向下兼容
- Proxy 唯一且可能最大的缺陷,就是不向下兼容,而且也没有 polyfill 能够实现完全一样的效果。
06.Proxy能做什么(应用场景)
- 下面我们通过两个具体的实践案例来更进一步了解 Proxy 的使用场景。
06.01 代理数组,实现通过负的索引访问数组元素
- 目前 JS 中的数组对象,还不能够像 python 一样通过负数索引来访问数组元素。如果直接设置,最终的结果是数组会像对象一样拥有一个负数作为键的属性,而不是数组元素。
- 这里我们对此加以扩展,得到一个可以通过负数读取和设置元素的代理数组。
1 | /** |
06.02 实现可观察对象
- 我们可以通过 Proxy,实现一个可观察的代理对象。
1 | /** |
- 和直接使用函数实现可观察对象的不同之处在于,通常函数实现会直接修改原对象,在原对象上注入监听方法。而使用代理则可以在不改变原始对象的情况下实现,因为它返回的是代理对象,监听的也是代理对象的变化。
React 中使用可观察对象
- 可观察对象的应用是随处可见的,假设我们把它带入到 React 中,则完全可以实现简版的修改属性值即触发组件更新的功能,从而略去手动更新 state 的步骤。我们只需要把组件变成可观察对象并注入对应的更新方法。
- 下面是伪代码。
1 | const comp = { state: {} }; // 想象这是一个 React 组件 |
- Proxy 的应用场景显然不止上面的这两者,开发者可以用它的能力实现跟踪属性访问,隐藏属性,阻止修改或删除属性、函数参数验证,构造函数参数验证,数据绑定和可观察对象等等。
- 而且在创建这些编码模式的时候,都可以通过 Proxy 把原始目标对象和代理对象区分开,在代理对象上实现各类效果而不影响原始对象的行为,真正做到高内聚低耦合。同时也符合设计模式中的单一职责原则。
07.Proxy的实践
07.01 Proxy 和 Mobx
- 在较早的版本 Mobx v4 中,Mobx 使用
Object.defineProperty
实现对数据的劫持。 - 但在实际中遇到以下问题:
- 在 Mobx 中操作的数组不是真正的数组,而是类数组对象,因此缺少数组的部分方法或其行为不一致。
- 例如需要返回真正的数组时要调用 slice 方法来拿到数组。
- 调用类数组对象的方法 sort 和 reverse 时,其行为和原始的数组不一致,并不会修改原数组顺序。
- 修改类数组对象的 length,其实是通过
Object.defineProperty
来劫持,得到和数组行为一致的返回值。
- 不能检测对象属性的增加和删除,因为
Object.defineProperty
是对已知的属性进行劫持,未知的属性无法预知。
- 在 Mobx 中操作的数组不是真正的数组,而是类数组对象,因此缺少数组的部分方法或其行为不一致。
- 下面是 Mobx v4 劫持类数组对象的 length 属性的实现。
1 | Object.defineProperty(ObservableArray.prototype, "length", { |
- 针对第一个问题,在 Mobx 5+ 的版本后,默认开启 Proxy。使用 Proxy 来实现对数组的劫持,本质上使用的是原生的数组,因此调用各个原生方法的行为也都能够和原生数组保持一致。
- 下面是 Mobx v5 劫持整个数组的实现,包括任意属性的读写。
1 | const arrayTraps = { |
- 针对第二个问题,只要通过 Proxy 劫持的是整个对象,对整个对象的属性的变化进行监听,也就能够解决无法检测属性新增/删除的问题。
08.小结
- 通过了解 Proxy 的基本自定义处理函数、全局 Reflect 对象,以及几个比较具体的 Proxy 实践的示例,来理解 Proxy 这个在现代开发里应用非常广泛的特性。
- 对于 Proxy 在 Mobx 中的应用,这里也仅仅是粗浅地简析小部分源码,来借此理解 Mobx 框架的一些原理,为后面进一步了解 Mobx 的实践打下部分基础。
- 当然,在本文中其实还有许多点可以深入,例如 Proxy 的限制一小节中,由内置插槽可以引申到 Proxy 的实现原理和 ES 规范中定义的异质对象;由 Proxy 的应用一小节中,可以实现一个完整的可观察对象以及探究观察者模式;还有就是刚才所提到的 Mobx 的部分。碍于自己的精力和能力有限,无法一次性深入聊完。
- 预计下一篇的主题,会是 Proxy 在 Mobx 中的应用,以及 Mobx v6(v7) 的最佳实践。
评论