浅谈 ES6 中的 Proxy 用法

本文最后更新于:2022年7月8日 下午

WARNING

此处 Proxy 指的是ES6中的语法,而非 FireFox proxyChrome proxy WebExtensions。请注意,前者是标准语法,P大写;而后者是对应厂商的私有技术,p小写。

Proxy 介绍

MDN 中的描述:Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。其实通俗来讲,Proxy 相当于在原生方法(例如 get 访问Object的中的属性或方法、set 修改Object中的属性或方法、has 拦截”in操作符判断属性或方法是否存在Object或其原型链上”等等)之上的一层捕获器,可以进行拦截、修改等多种操作。
Proxy 常见用法有很多,包括 运算符重载对象模拟简洁而灵活的API创建对象更改事件 等等。
又比如在 Vue2 中是通过 Object.defineProperty() 来劫持各个属性的settergetter 来实现的双向绑定,而在 Vue3 中使用的正是 Proxy 来实现的双向绑定。

Proxy 用于修改某些操作的默认行为,也可以理解为在目标对象之前架设一层拦截,外部所有的访问都必须先通过这层拦截,因此提供了一种机制,可以对外部的访问进行过滤和修改。这个词的原理为代理,在这里可以表示由它来“代理”某些操作,译为“代理器”。

术语

  • handler 包含捕捉器(trap)的占位符对象,可译为处理器对象。
  • traps 提供属性访问的方法。这类似于操作系统中捕获器的概念。
  • targetProxy 代理虚拟化的对象。它常被作为代理的存储后端。根据目标验证关于对象不可扩展性或不可配置属性的不变量(保持不变的语义)。

语法

ES6 原生提供了 Proxy 构造函数,用来生成 Proxy 实例。

  • target 要使用 Proxy 包装的 目标对象 Object(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
  • handler 一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理的行为。
1
var proxy = new Proxy(target, handler);

注意: target 必须是 Object 类型,而不能是其他类型。

Proxy 对象的所有用法,都是上面的这种形式。不同的只是 handler 参数的写法。其中 new Proxy 用来生成 Proxy 实例,target 是表示所要拦截的对象(Object),handler 是用来定制拦截行为的对象。

例子

下面是 Proxy 最简单的例子是,这是一个有捕捉器的代理,一个 get 捕捉器,总是返回 42

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let target = {
x: 10,
y: 20
}

let handler = {
get: (obj, prop) => 42
}

target = new Proxy(target, handler)

target.x // 42
target.y // 42
target.x // 42

结果是一个对象将为任何属性访问操作都返回“42”。 这包括 target.xtarget['x']Reflect.get(target, 'x') 等。

handler 对象的方法

handler 对象是一个容纳一批特定属性的占位符对象。它包含有 Proxy 的各个捕获器(trap)。

所有的捕捉器是可选的。如果没有定义某个捕捉器,那么就会保留源对象的默认行为。

此外,一些不标准的捕捉器已经被废弃并且移除了。

Proxy 例子

默认值/“零值”

在 Go 语言中,有零值(nil)的概念,零值是特定于类型的隐式默认结构值。其思想是提供类型安全的默认基元值,或者用gopher的话说,给结构一个有用的零值。

虽然不同的创建模式支持类似的功能,但 Javascript 无法用隐式初始值包装对象。Javascript 中未设置属性的默认值是 undefined。但 Proxy 可以改变这种情况。

1
2
3
4
const withZeroValue = (target, zeroValue) => 
new Proxy(target, {
get: (obj, prop) => (prop in obj) ? obj[prop] : zeroValue
})

函数 withZeroValue 用来包装目标对象。 如果设置了属性,则返回属性值。 否则,它返回一个默认的 “零值” 。

从技术上讲,这种方法也不是隐含的,但如果我们扩展 withZeroValue,以 Boolean (false), Number (0), String (""), Object ({})Array ([])等对应的零值,则可能是隐含的。

1
2
3
4
5
6
7
8
9
10
let pos = {
x: 4,
y: 19
}

console.log(pos.x, pos.y, pos.z) // 4, 19, undefined

pos = withZeroValue(pos, 0)

console.log(pos.z, pos.y, pos.z) // 4, 19, 0

此功能可能有用的一个地方是坐标系。 绘图库可以基于数据的形状自动支持2D和3D渲染。 不是创建两个单独的模型,而是始终将z默认为 0 而不是 undefined,这可能是有意义的。

负索引数组

在JS中获取数组中的最后一个元素方式通过写的很冗长且重复,也容易出错。 这就是为什么有一个TC39提案定义了一个便利属性 Array.lastItem 来获取和设置最后一个元素。

其他语言,如 Python 和 Ruby,使用负组索引更容易访问最后面的元素。例如,可以简单地使用 arr[-1] 替代 arr[arr.length-1] 访问最后一个元素。

使用 Proxy 也可以在 Javascript 中使用负索引。

1
2
3
4
5
6
7
8
const negativeArray = (els) => new Proxy(els, {
get: (target, property, receiver) =>
Reflect.get(
target,
( +property < 0) ? String( target.length + +property ) : property,
receiver
)
});
  • target : 目标对象。
  • property : 被获取的属性名。
  • receiver : Proxy 或者继承 Proxy 的对象

Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与 Proxy handlers 的方法相同。Reflect 不是一个函数对象,因此它是不可构造的。
与大多数全局对象不同 Reflect 并非一个构造函数,所以不能通过 new 运算符对其进行调用,或者将 Reflect 对象作为一个函数来调用。Reflect 的所有属性和方法都是静态的(就像 Math 对象)。
点击可查看MDN解释

一个重要的注意事项是包含 get 的捕捉器(trap)字符串化所有属性。 对于数组访问,我们需要将属性名称强制转换为 Numbers,这样就可以使用加运算符(隐式转换)简洁地完成。

现在 arr[-1] 访问最后一个元素,arr[-2] 访问倒数第二个元素,以此类推。

1
2
3
4
5
const arr = negativeArray(['🚀', '📚', '🌈']);

console.log(arr[-1]) // '🌈'

console.log(arr[-2]) // '📚'

隐藏属性

众所周知 JS 没有私有属性。 Symbol 最初是为了启用私有属性而引入的,但后来使用像 Object.getOwnPropertySymbols 这样的反射方法进行了淡化,这使得它们可以被公开发现。长期以来的惯例是将私有属性命名为前下划线 _ ,有效地标记它们“不要访问”。Proxy 提供了一种稍微更好的方法来屏蔽这些属性。

1
2
3
4
5
6
const hide = (target, prefix = '_') => new Proxy(target, {
has: (obj, prop) => (!prop.startsWith(prefix) && prop in obj),
ownKeys: (obj) => Reflect.ownKeys(obj)
.filter(prop => (typeof prop !== "string" || !prop.startsWith(prefix))),
get: (obj, prop, rec) => (prop in rec) ? obj[prop] : undefined
})

hide 函数包装目标对象,并使得从 in 运算符和 Object.getOwnPropertyNames 等方法无法访问带有下划线的属性。

1
2
3
4
5
6
7
8
let userData = hide({
firstName: 'Tom',
mediumHandle: '@tbarrasso',
_favoriteRapper: 'Drake'
})

userData._favoriteRapper // undefined
('_favoriteRapper' in userData) // false

更完整的实现还包括诸如 deletePropertydefineProperty 之类的捕捉器。 除了闭包之外,这可能是最接近真正私有属性的方法,因为它们无法通过枚举,克隆,访问或修改来访问。

缓存

在客户端和服务器之间同步状态时遇到困难并不罕见。数据可能会随着时间的推移而发生变化,很难确切地知道何时重新同步的逻辑。

Proxy 启用了一种新方法:根据需要将对象包装为无效(和重新同步)属性。 所有访问属性的尝试都首先检查缓存策略,该策略决定返回当前在内存中的内容还是采取其他一些操作。

1
2
3
4
5
6
7
8
const ephemeral = (target, ttl = 60) => {
const CREATED_AT = Date.now()
const isExpired = () => (Date.now() - CREATED_AT) > (ttl * 1000)

return new Proxy(target, {
get: (obj, prop) => isExpired() ? undefined : Reflect.get(obj, prop)
})
}

这个函数过于简化了: 它使对象上的所有属性在一段时间后都无法访问。然而,将此方法扩展为根据每个属性设置生存时间(TTL),并在一定的持续时间或访问次数之后更新它并不困难。

1
2
3
4
5
6
7
8
9
let bankAccount = ephemeral({
balance: 14.93
}, 10)

console.log(bankAccount.balance) // 14.93

setTimeout(() => {
console.log(bankAccount.balance) // undefined
}, 10 * 1000)

这个示例简单地使银行帐户余额在10秒后无法访问。

运算符重载

也许从语法上讲,最吸引人的 Proxy 用例是重载操作符的能力,比如使用 handler.hasin 操作符。

in 操作符用于检查指定的属性是否位于指定的对象或其原型链中。但它也是语法上最优雅的重载操作符。这个例子定义了一个连续range函数来比较数字。

1
2
3
const range = (min, max) => new Proxy(Object.create(null), {
has: (_, prop) => (+prop >= min && +prop <= max)
})

与Python不同,Python使用生成器与有限的整数序列进行比较,这种方法支持十进制比较,可以扩展为支持其他数值范围。

1
2
3
4
5
6
7
8
const X = 10.5
const nums = [1, 5, X, 50, 100]

if (X in range(1, 100)) { // true
// ...
}

nums.filter(n => n in range(1, 10)) // [1, 5]

尽管这个用例不能解决复杂的问题,但它确实提供了干净、可读和可重用的代码。

除了 in 运算符,我们还可以重载 deletenew

总结

为什么要用 Proxy ?

就拿 Vue 举例来说,在 Vue2 中当一个 Vue 实例创建时,Vue 会遍历 data 中的属性,用 Object.defineProperty 将它们转为 getter/setter,并且在内部追踪相关依赖,在属性被访问和修改时通知变化。 每个组件实例都有相应的 watcher 程序实例,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的 setter 被调用时,会通知 watcher 重新计算,从而致使它关联的组件得以更新。

而这样一个个遍历对 data 进行修改显然是不高效的,所以在 Vue3 中使用了 Proxy 来进行数据劫持。

Vue3 使用 Proxy 来监控数据的变化。就本文以上所说,Proxy 是 ES6 中提供的功能,其作用为:用于定义基本操作的自定义行为(如属性查找,赋值,枚举,函数调用等)。相对于 Object.defineProperty(),其有以下特点:

  1. Proxy 直接代理整个对象而非对象属性,这样只需做一层代理就可以监听同级结构下的所有属性变化,包括新增属性和删除属性。
  2. Proxy 可以监听数组的变化。(在 Vue2 中是重写了数组中的各种方法才达到监听的目的,如 pushpopshiftunshiftsplicesortreverse 等)

在 Vue2 中,Object.defineProperty 会改变原始数据,而 Proxy 是创建对象的虚拟表示,并提供 setgetdeleteProperty 等处理器,这些处理器可在访问或修改原始对象上的属性时进行拦截,有以下特点∶

  • 不需用使用 Vue.$setVue.$delete 触发响应式。
  • 全方位的数组变化检测,消除了 Vue2 无效的边界情况。
  • 支持 MapSetWeakMapWeakSet

Vue3 中 Proxy 实现的响应式原理与 Vue2 的实现原理相同,实现方式大同小异∶

  • get 收集依赖;
  • setdelete 等触发依赖;
  • 对于集合类型,就是对集合对象的方法做一层包装:原方法执行后执行依赖相关的收集或触发逻辑;

THE END

Proxy 提供虚拟化接口来控制任何目标 Object 的行为。 这样做可以在简单性和实用性之间取得平衡,而不会牺牲兼容性。

也许使用 Proxy 的最令人信服的理由是,上面的许多示例只有几行,并且可以轻松组合以创建复杂的功能。

参考


浅谈 ES6 中的 Proxy 用法
https://toflying.com/2022/07/08/6-talk-about-es6-proxy/
作者
KingChen
发布于
2022年7月8日
更新于
2022年7月8日
许可协议