🎪 JavaScript Proxy 和 Reflect
Vue 的新版中 响应式 数据实现原理。
ECMA 关于 Proxy 的规范: ECMAScript® 2023 Language Specification (tc39.es) (opens new window)
MDN 参考:Proxy - JavaScript | MDN (mozilla.org) (opens new window)
# Proxy
使用 Proxy
对象可以 包装另一个对象 并且 拦截读写操作,然后在 自行处理 读写操作、或者允许该对象处理读写操作。
使用语法:
let proxy = new Proxy(target, handler)
target
: 要包装的对象,可以是任意对象(包括函数)。handler
:代理的配置。(带有 拦截操作的方法 的对象),get
拦截读操作,set
拦截写操作。
对 proxy
进行操作,如果在 handler
中存在相应的 拦截方法,则运行该拦截方法,并且 Proxy
有机会对它进行处理。如果没有则对 target
直接处理。
🌰 例子:
let target = {}
let proxy = new Proxy(target, {})
proxy.test = 5
console.log(target.test)
console.log(proxy.test)
2
3
4
5
6
上面的例子,
handler
为空,则对target
直接进行读写操作,对于proxy
的操作直接转发给了target
。
提示
Proxy
是一个特殊的对象,没有自己的属性,如果 handler
为空,则透明地将操作转发给 target
。
能给 Proxy
添加的 handler
捕捉器:
handler
能拦截 JavaScript 中的内部工作方法,例如[[get]]
读取属性的内部方法、[[set]]
写入属性的内部方法。因为这些方法名称仅在规范中使用,不能通过直接调用使用。所以要通过
Proxy
的handler
捕捉器拦截这些方法的调用。
常用:
内部方法 | Handler 方法 | 何时触发 |
---|---|---|
[[Get]] | get | 读取属性 |
[[Set]] | set | 写入属性 |
[[HasProperty]] | has | in 操作符 |
[[Delete]] | deleteProperty | delete 操作符 |
[[Call]] | apply | 函数调用 |
[[Construct]] | construct | new 操作符 |
点击查看
[[GetPrototypeOf]] | getPrototypeOf | Object.getPrototypeOf (opens new window) |
---|---|---|
[[SetPrototypeOf]] | setPrototypeOf | Object.setPrototypeOf (opens new window) |
[[IsExtensible]] | isExtensible | Object.isExtensible (opens new window) |
[[PreventExtensions]] | preventExtensions | Object.preventExtensions (opens new window) |
[[DefineOwnProperty]] | defineProperty | Object.defineProperty (opens new window), Object.defineProperties (opens new window) |
[[GetOwnProperty]] | getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor (opens new window), for..in , Object.keys/values/entries |
[[OwnPropertyKeys]] | ownKeys | Object.getOwnPropertyNames (opens new window), Object.getOwnPropertySymbols (opens new window), for..in , Object.keys/values/entries s |
JavaScript 将强制执行 内部方法和捕捉器必须遵循的条件 (不变量)。例如:
[[Set]]
如果值已成功写入,则必须返回true
,否则返回false
。
[[Delete]]
如果已成功删除该值,则必须返回true
,否则返回false
。
[[GetPrototypeOf]]
,必须返回与应用于被代理对象的[[GetPrototypeOf]]
相同的值。读取代理对象的原型必须始终返回被代理对象的原型。不变量确保了语言功能的正确和一致的行为。
# 读取和写入操作
使用捕捉器拦截读取操作, handler
应该有 get(target, property, receiver)
方法,在读取属性时触发该方法。
target
:目标对象,该对象被作为第一个参数传递给new Proxy
。property
:目标属性名。receiver
:如果目标属性是一个 getter 访问器属性,则receiver
就是本次读取属性所在的this
对象。一般是proxy
对象的本身。
🌰 例子 / 使用 handler
实现对象的默认值:
let numbers = [1,2,3]
numbers = new Proxy(numbers, {
get(target, property) {
if(property in target) {
return target[prop]
} else {
return 0 // 默认值
}
}
})
console.log(numbers[0]) // 1
console.log(numbers[3]) // 0 因为没有这个数组项
2
3
4
5
6
7
8
9
10
11
12
13
14
利用 捕捉器 拦截读取操作,可以避免 要读取的目标属性中没有 目标值 时,赋予默认值可以替代
undefined
的情况。
numbers = new Proxy(numbers, ...)
完全替代了原来的目标对象。代理该目标对象后,不应该在引用目标对象。
使用捕捉器拦截写入操作, set
捕捉器被触发 set(target, property, value, receiver)
:
target
:是目标对象。(要写入的对象)property
:目标属性名称。value
:目标属性的值。(要写入的值)receiver
:与get
捕捉器类似,仅与 setter 访问器属性相关。
🌰 例子:
let numbers = []
numbers = new Proxy(numbers, {
set(target, property, value) {
if(typef value === 'number') {
target[property] = value
return true
} else {
return false
}
}
})
numbers.push(1)
numbers.push("test") // TypeError
2
3
4
5
6
7
8
9
10
11
12
13
14
15
使用
set
拦截了 写入的操作,简单实现了规定(限制)了要写入的值的类型。因此不需要重写添加元素数组方法就可以检查写入数组的值的类型。并且
set
必须要在写入成功时返回true
。如果没有返回,或者返回false
,都会报错。
# 迭代操作
Object.keys
, for..in
循环和大多数其他遍历对象属性的方法都使用内部方法 [[OwnPropertyKeys]]
(由 ownKeys
捕捉器拦截) 来获取属性列表。
可以使用 ownkeys
拦截遍历操作。
🌰 例子 / 使用 ownkeys
拦截,遍历时跳过带有 _
开头的属性:
let user = {
name: "John",
age: 30,
_password: "***"
};
user = new Proxy(user, {
ownKeys(target) {
return Object.keys(target).filter(key => !key.startWith('_'))
}
})
for(let key in user) console.log(key)
// 对下列方法同样有效
console.log(Object.keys(user))
console.log(Object.values(user))
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
但是当 返回对象中不存在的键 时, ownKeys
失效:
let user = { };
user = new Proxy(user, {
ownKeys(target) {
return ['a', 'b', 'c'];
}
});
console.log(Object.keys(user)) // <empty>
2
3
4
5
6
7
8
9
因为
Object.keys
仅返回带有enumerable
标志的属性。该方法会对每个属性调用内部方法[[GetOwnProperty]]
来获取 描述符。而 新返回的键, 没有属性、描述符为空,自然会被忽略。如果要使得 新返回的键 能带有
enumerable
描述符,可以 拦截[[GetOwnProperty]]
的调用,返回带有enumberable: true
的描述符。let user = { }; user = new Proxy(user, { // 一旦获取属性列表就会调用 ownKeys(target) { return ['a', 'b', 'c']; }, // 一旦获取属性就会调用 getOwnPropertyDescriptor(target, prop) { return { enumerable: true, configurable: true // ... }; } });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 对内部受保护属性操作
受保护的属性一般使用 开头 _
标识属性名称。但技术上,还是能够从外部获取。所以可以通过使用捕捉器,例如拦截 [[get]]
读取操作实现保护 _
开头的 受保护属性。
只需要实现:
get
读取属性时抛出错误;set
写入属性时抛出错误;deleteProperty
删除属性时抛出错误;- 遍历时(
for...in
、Object.keys
) 排除掉这些属性。
🌰 例子:
let user = {
name: "simon",
_password: "****"
}
user = new Proxy(user, {
get(target, property) {
if(property.startsWith("_")) {
throw new Error("Accress Denied")
}
},
set(target, property, value) {
if(property.startsWith("_")) {
throw new Error("Accress Denied")
} else {
target[property] = value
return true
}
},
deleteProperty(target, property) {
if(property.startsWith("_")) {
throw new Error("Accress Denied")
} else {
delete target[property]
return true
}
},
ownKeys(target) {
return Object.keys(target).filter(key => !key.startsWith("_"))
}
})
try {
console.log(user._password)
} catch (error) { console.log(error)}
try {
user._password = "test"
} catch (error) { console.log(error)}
try {
delete user._password
} catch (error) { console.log(error)}
for(key in user) {
console.log(user) // name
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
在要利用一个 访问器 访问该保护属性时,要在拦截方法 get
中检测 属性的类型:
🌰 例子:
当要调用 访问器时:
user = {
// ...
checkPassword(value) {
return value === this._password
}
}
2
3
4
5
6
检测输入的密码是否与 保护的属性 密码值相同。
get(target, property) {
let value = target[property]
return (typeof value === 'function') ? value.bind(target) : value
}
2
3
4
在拦截之后,调用该
target[property]
可能会丢失原来的上下文对象,所以要使用bind
绑定原来的target
,使得它将来的调用都会使用target
为this
。
上述的 保护内部属性 的代理方式,虽然可行,但是存在未知性的错误。如果一个方法可能会降未被代理的对象传递到其他地方,然后 此时原始对象和被代理对象都可能丢失。并且一个对象也可能会被代理多次,如果将未被包装的对象传递给方法,就会产生未知的错误。
所以不应该这样使用代理对象。在 新的 JavaScript 特性中,已经使用
#
标识私有属性。
# 使用 has
拦截 in
当使用 in
操作符来检查一个数字是否在 range
范围内。
has
捕捉器可以拦截 in
的调用, has(target, property)
:
target
:目标对象;property
:属性名称;
🌰 例子:
let range = {
start: 1,
end: 10
}
range = new Proxy(range, {
has(target, property) {
return prop >= target.start && prop <= target.end
}
})
console.log(5 in range) // true
console.log(50 in range) // false
2
3
4
5
6
7
8
9
10
11
12
13
# 包装函数
将 代理 包装在函数的周围,使用 apply(target, thisArg, args)
捕捉器代理 以函数的方式被调用:
target
:目标对象(函数);thisArg
:this
的值;args
:参数列表;
🌰 例子 / 延迟函数的调用装饰器 delay(f, ms)
:
- 在 基于函数 方式实现:
点击查看
function delay(f, ms) {
return function() {
setTimeout(() => f.apply(this, arguments), ms)
}
}
function sayHi(user) {
console.log(`Hi, ${user}`)
}
console.log(sayHi.length) // 1(参数个数)
sayHi = delay(sayHi, 3000)
console.log(sayHi.length) // 0
2
3
4
5
6
7
8
9
10
11
12
13
14
15
基于函数实现的包装函数,不会转发 属性 读取和写入操作,进行包装后就会失去对原始函数属性的访问,例如
name
、length
。
- 使用
apply
捕捉器拦截:
function delay(f, ms) {
return new Proxy(f, {
apply(target, thisArg, args) {
setTimeout(() => target.apply(thisArgs, args), ms)
}
})
}
sayHi = delay(sayHi, 3000)
console.log(sayHi.length) // 1
2
3
4
5
6
7
8
9
10
使用
Proxy
的功能要强大得多,因为它可以将所有东西转发到目标对象。这时得到一个 更加丰富 的包装器。
# Reflect
是 JavaScript 的内建对象,简化 Proxy
的创建。
在 Proxy
中不可以直接调用 [[Get]]
、 [[Set]]
等规范性内部方法,在 Reflect
对象中可以调用这些方法的最小包装。
并且, Reflect
运行将 操作符 ( new
、 delete
等)作为函数执行调用(对应方法 construct
、 deleteProperty
)。
点击查看
操作 | Reflect 调用 | 内部方法 |
---|---|---|
obj[prop] | Reflect.get(obj, prop) | [[Get]] |
obj[prop] = value | Reflect.set(obj, prop, value) | [[Set]] |
delete obj[prop] | Reflect.deleteProperty(obj, prop) | [[Delete]] |
new F(value) | Reflect.construct(F, value) | [[Construct]] |
🌰 例子:
let user = {}
Reflect.set(user, 'name', 'John')
console.log(user.name) // John
2
3
对于每个可被 Proxy
捕获的内部方法,在 Reflect
中都有一个对应的方法,其名称和参数与 Proxy
捕捉器相同。 所以,可以使用 Reflect
来将操作转发给原始对象。
🌰 例子 / 结合 Proxy
和 Reflect
的使用:
let user = {}
user = new Proxy(user, {
get(target, property, receiver) {
console.log(`GET ${property}`)
return Reflect.get(target, property, receiver)
},
set(target, property, value, receiver) {
console.log(`SET ${property} = ${value}`)
return Reflect.set(target, property, value, receiver)
}
})
2
3
4
5
6
7
8
9
10
11
12
如果一个捕捉器想要将调用转发给对象,则只需使用相同的参数调用
Reflect.<method>
就足够了。 简化了Proxy
的工作。
# 代理 getter
访问器
🌰 例子:
let user = {
_name: "Guest",
get name() {
return this._name
}
}
let userProxy = new Proxy(user, {
get(target, property, receiver) {
return target[prop]
}
})
console.log(userProperty.name)
2
3
4
5
6
7
8
9
10
11
12
13
此处,一切还正常。
创建一个以 userProxy
为原型的 admin
对象:
let admin = {
__proto__: userProxy,
_name: "Admin"
}
console.log(admin.name) // "Guest"
2
3
4
5
6
可以看出,如果直接读取
admin.name
,没有找到amdin
中的name
属性,转而向admin
的原型对象userProxy
找,此时userProxy.name
会代理 读取属性 的方法,找到的是上下文是user
的上下文,所以输出的是guest
。
要解决这种情况,需要使用捕捉器 get
的第三个参数 receiver
将正确的 this
转发给 getter
。但是按照规范 Proxy
中的 get
不能被调用,所以可以利用 Reflect.get
:
let userProxy = new Proxy(user, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver)
}
})
2
3
4
5
此时通过
receiver
保留了对正确this
的引用(即admin
)。
简化 Reflect
的使用:
let userProxy = new Proxy(user, {
get(target, prop, receiver) {
return Reflect.get(...arguments)
}
})
2
3
4
5
Reflect
调用的命名与捕捉器的命名完全相同,并且接受相同的参数。它们是以这种方式专门设计的。
因此, return Reflect...
提供了一个安全的方式,可以轻松地转发操作,并确保不会忘记与此相关的任何内容。
# Proxy
的局限
# 内建对象的内部插槽
许多内建对象,例如 Map
, Set
, Date
, Promise
等,都使用「内部插槽」。它们类似于属性,但仅限于内部使用,仅用于规范目的。
例如, Map
将项目(item)存储在 [[MapData]]
中。内建方法可以直接访问它们,而不通过 [[Get]]/[[Set]]
内部方法。
所以,当使用 Proxy
代理这些内建对象,这些内部插槽并不能被拦截。
🌰 例子:
let map = new Map()
let proxy = new Proxy(map, {})
proxy.set('test', 1) // Error
2
3
由于
this =。proxy
,在proxy
中无法找到this.[[MapData]
,所以报错。
解决方法:
let proxy = new Proxy(map, {
get(target, prop, receiver) {
let value = Reflect.get(...arguments);
return typeof value == 'function' ? value.bind(target) : value;
}
});
proxy.set('test', 1);
console.log(proxy.get('test'));
2
3
4
5
6
7
8
9
这时,
proxy.set
的内部this
不是proxy
而是原始的map
,所以找得到this.[[MapData]]
内部插槽。
# 私有字段
私有字段也是通过 内部插槽 实现的,所以直接代理并不能正常 [[GET]]/[[SET]]
。同样要使用 Reflect
带 bind
方法解决:
class User {
#name = "Guest";
getName() {
return this.#name;
}
}
let user = new User();
user = new Proxy(user, {
get(target, prop, receiver) {
let value = Reflect.get(...arguments);
return typeof value == 'function' ? value.bind(target) : value;
}
});
console.log(user.getName());
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
但是这种方法也有缺点,将原始对象暴露给该方法,可能会进一步传递破坏其他代理功能。
# 代理对象之后…
🌰 例子:
let allUsers = new Set();
class User {
constructor(name) {
this.name = name;
allUsers.add(this);
}
}
let user = new User("John");
console.log(allUsers.has(user)) // true
user = new Proxy(user, {})
console.log(allUsers.has(user)) // false
2
3
4
5
6
7
8
9
10
11
12
13
14
user
被代理后,不能再allusers
中找到。因为代理是一个不同的对象。
注意
Proxy
无法拦截严格相等性检查 ===
:一个对象只严格等于其自身,没有其他值。因此,比较对象是否相等的所有操作和内建类都会区分对象和代理。这里没有透明的替代品。
# 可撤销的代理
一个 可撤销 的代理是可以被禁用的代理。
假设有一个资源,并且想随时关闭对该资源的访问。
可以做的是将它包装成可一个撤销的代理,没有任何捕捉器。这样的代理会将操作转发给对象,并且可以随时将其禁用。
let {proxy, revoke} = Proxy.revocable(target, handler)
这个 Proxy
调用返回一个带有 proxy
和 revoke
函数的对象以将其禁用。
🌰 例子:
let object = {
data: "Valuable data"
};
let {proxy, revoke} = Proxy.revocable(object, {});
conosole.log(proxy.data)
revoke()
console.log(proxy.data) // Error
2
3
4
5
6
7
8
9
revoke()
的调用会从代理中删除对目标对象的所有内部引用,因此它们之间再无连接。
🌰 例子 / 利用 WeakMap
的不阻止「垃圾回收」 的机制绑定 proxy
和 revoke
:
let revokes = new WeakMap()
let object = {
data: "Valuable data"
};
let {proxy, revoke} = Proxy.revocable(object, {});
revokes.set(proxy, revoke);
// ... then
revoke = revokes.get(proxy)
revoke()
console.log(proxy.data) // Error
2
3
4
5
6
7
8
9
10
11
12
13
如果一个代理对象变得「不可访问」(例如,没有变量再引用它),则
WeakMap
允许将其与它的revoke
一起从内存中清除,因为我们不再需要它了。
# 总结
Proxy
是对象的包装器,将代理上的操作转发到对象,并可以选择捕获其中一些操作。它可以包装任何类型的对象,包括类和函数。