- 文档/教程
- 前端
【免费下载链接】en.javascript.info
Modern JavaScript Tutorial
导读:本文以 Modern JavaScript Tutorial 中《Proxy and Reflect》一章(1-js/99-js-misc/01-proxy/article.md)为主体,系统讲解 JavaScript
Proxy代理对象的全部拦截器(trap)、Reflect反射 API 的配套用法,以及代理内置对象、私有字段时的底层限制与规避方案。读完本文,你将掌握如何用Proxy实现默认值、输入校验、属性保护、可观察对象、函数装饰器等实战能力,并理解"内部槽""receiver 绑定"等容易踩坑的底层原理。
Proxy对象包裹另一个对象并拦截其上的操作——例如读写属性等,可以选择自己处理这些操作,也可以透明地把操作转交给被包裹的对象。许多库和浏览器框架内部都大量使用代理,本文将通过一系列可运行的例子展示它的实用场景。
Proxy 基础:语法与透明转发
Proxy的创建语法非常简单:
let proxy = new Proxy(target, handler)target—— 要被包裹的对象,可以是任何东西,包括函数;handler—— 代理配置:一个包含"拦截器(traps)"的对象,其中的方法负责拦截相应操作,例如读取target属性的get拦截器、写入target属性的set拦截器,等等。
对proxy执行操作时,如果handler中存在对应的拦截器,就会运行它,代理有机会自行处理;否则操作会直接落到target上。
先看一个没有任何拦截器的代理:
let target = {}; let proxy = new Proxy(target, {}); // 空 handler proxy.test = 5; // 写入 proxy(1) alert(target.test); // 5,属性出现在 target 上! alert(proxy.test); // 5,也能从 proxy 读出(2) for(let key in proxy) alert(key); // test,迭代也正常工作(3)由于没有任何拦截器,proxy上的所有操作都会被转发到target:
- 写入操作
proxy.test=会把值设置到target上; - 读取操作
proxy.test会从target返回值; - 对
proxy的迭代返回target的值。
可见,没有拦截器时,proxy就是target的透明包装:
Proxy是一种特殊的"异质对象(exotic object)",它没有自己的属性。使用空handler时,它把操作透明地转发给target。要给代理赋予更多能力,就需要添加拦截器。
拦截器总览:内部方法(Internal Method)与 Trap 对照表
对象的绝大多数操作,在 JavaScript 规范中都有对应的"内部方法(internal method)"来定义其最底层的实现。例如读取属性的[[Get]]、写入属性的[[Set]]等。这些方法只存在于规范中,不能直接用名字调用。
Proxy 的拦截器截获的正是这些内部方法的调用。下表列出每个内部方法对应的拦截器(即可以添加到new Proxy的handler参数中、用于拦截该操作的方法名):
| 内部方法 | 拦截器方法 | 触发时机 |
|---|---|---|
[[Get]] | get | 读取属性 |
[[Set]] | set | 写入属性 |
[[HasProperty]] | has | in运算符 |
[[Delete]] | deleteProperty | delete运算符 |
[[Call]] | apply | 函数调用 |
[[Construct]] | construct | new运算符 |
[[GetPrototypeOf]] | getPrototypeOf | Object.getPrototypeOf |
[[SetPrototypeOf]] | setPrototypeOf | Object.setPrototypeOf |
[[IsExtensible]] | isExtensible | Object.isExtensible |
[[PreventExtensions]] | preventExtensions | Object.preventExtensions |
[[DefineOwnProperty]] | defineProperty | Object.defineProperty、Object.defineProperties |
[[GetOwnProperty]] | getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor、for..in、Object.keys/values/entries |
[[OwnPropertyKeys]] | ownKeys | Object.getOwnPropertyNames、Object.getOwnPropertySymbols、for..in、Object.keys/values/entries |
不变式(Invariants)
JavaScript 强制一些"不变式"——即内部方法和拦截器必须满足的条件。大多数与返回值有关:
[[Set]]写入成功必须返回true,否则返回false;[[Delete]]删除成功必须返回true,否则返回false;- ……后续例子中还会看到更多。
还有一些其他不变式,例如:
- 对代理对象应用
[[GetPrototypeOf]],必须返回与对代理的target应用[[GetPrototypeOf]]相同的值。换句话说,读取代理的原型必须始终返回 target 对象的原型。
拦截器可以拦截这些操作,但必须遵守这些规则。不变式保证了语言特性的正确和一致行为。只要不做奇怪的事,通常不会违反它们。
用get拦截器实现默认值
读写属性是最常用的拦截场景。拦截读取需要 handler 提供get(target, property, receiver)方法,触发时接收以下参数:
target—— 目标对象,即传给new Proxy的第一个参数;property—— 属性名;receiver—— 如果目标属性是 getter,receiver就是调用时用作this的对象,通常就是proxy本身(如果从 proxy 继承,则是继承它的对象)。目前用不到这个参数,后面会详细说明。
用get为对象实现默认值:做一个数值数组,访问不存在的元素时返回0。通常读取不存在的数组项得到undefined,这里把普通数组包进代理,拦截读取,没有该属性就返回0:
let numbers = [0, 1, 2]; numbers = new Proxy(numbers, { get(target, prop) { if (prop in target) { return target[prop]; } else { return 0; // 默认值 } } }); alert( numbers[1] ); // 1 alert( numbers[123] ); // 0(没有这个元素)可以看到,用get拦截器实现"默认值"逻辑非常简单。Proxy可以为任何对象实现任意"默认值"逻辑。
再比如一个短语字典:未翻译的短语返回undefined体验不佳,不如直接返回原文。包裹dictionary拦截读取操作:
let dictionary = { 'Hello': 'Hola', 'Bye': 'Adiós' }; dictionary = new Proxy(dictionary, { get(target, phrase) { // 拦截从 dictionary 读取属性 if (phrase in target) { // 字典里有这个短语 return target[phrase]; // 返回翻译 } else { // 否则返回未翻译的短语 return phrase; } } }); // 随意查询任意短语!最坏情况就是没有翻译 alert( dictionary['Hello'] ); // Hola alert( dictionary['Welcome to Proxy']); // Welcome to Proxy(未翻译)请注意这里用代理覆盖变量的写法:
dictionary = new Proxy(dictionary, ...);代理应该完全取代目标对象出现在所有地方。目标对象一旦被代理后,任何人都不应再引用它,否则很容易出问题。
配套练习:读取不存在属性时抛错
仓库在本章目录下提供了配套习题 01-error-nonexisting/task.md:通常读取不存在的属性返回undefined,请写一个wrap(target)让读取不存在属性时抛出错误,帮助提前发现编程失误。参考解法见 01-error-nonexisting/solution.md,其核心就是在get拦截器中用prop in target判断存在性,不存在时抛出ReferenceError:
function wrap(target) { return new Proxy(target, { get(target, prop, receiver) { if (prop in target) { return Reflect.get(target, prop, receiver); } else { throw new ReferenceError(`Property doesn't exist: "${prop}"`) } } }); }用set拦截器做输入校验
假设我们想要一个只允许存放数字的数组,添加其他类型的值应该报错。set拦截器在属性被写入时触发。
set(target, property, value, receiver):
target—— 目标对象;property—— 属性名;value—— 属性值;receiver—— 与get拦截器类似,仅对 setter 属性有意义。
set拦截器写入成功必须返回true,否则返回false(会触发TypeError)。用它校验新值:
let numbers = []; numbers = new Proxy(numbers, { // (*) set(target, prop, val) { // 拦截属性写入 if (typeof val == 'number') { target[prop] = val; return true; } else { return false; } } }); numbers.push(1); // 成功添加 numbers.push(2); // 成功添加 alert("Length is: " + numbers.length); // 2 numbers.push("test"); // TypeError('set' on proxy returned false) alert("This line is never reached (error in the line above)");注意:数组的内置功能仍然完好!值由push添加,length属性随添加自动增长,代理没有破坏任何东西。我们不需要重写push、unshift等添加值的方法来加入检查,因为它们内部使用的正是[[Set]]操作,会被代理拦截。代码因此干净而简洁。
别忘了返回true:如前述,set写入成功必须返回true。如果忘记返回或返回假值,操作会触发TypeError。
配套练习:可观察对象(Observable)
03-observable/task.md 要求实现makeObservable(target),让对象拥有observe(handler)方法,属性每次变化时调用handler(key, value)。官方参考解法 03-observable/solution.md 展示了set拦截器的经典组合用法——先用Reflect.set转发写入,成功后再调用全部处理器:
let handlers = Symbol('handlers'); function makeObservable(target) { // 1. 初始化处理器存储 target[handlers] = []; // 把 handler 存进数组供后续调用 target.observe = function(handler) { this[handlers].push(handler); }; // 2. 创建代理处理变更 return new Proxy(target, { set(target, property, value, receiver) { let success = Reflect.set(...arguments); // 把操作转发给对象 if (success) { // 写入无错误 // 调用所有处理器 target[handlers].forEach(handler => handler(property, value)); } return success; } }); } let user = {}; user = makeObservable(user); user.observe((key, value) => { alert(`SET ${key}=${value}`); }); user.name = "John"; // 触发 handler这本质上就是"观察者模式"的最小实现:set拦截器负责在属性变化时通知订阅者。
用ownKeys与getOwnPropertyDescriptor控制迭代
Object.keys、for..in循环以及大多数迭代对象属性的方法,都通过[[OwnPropertyKeys]]内部方法(由ownKeys拦截器截获)获取属性列表。这些方法在细节上有差异:
Object.getOwnPropertyNames(obj)返回非 Symbol 键;Object.getOwnPropertySymbols(obj)返回 Symbol 键;Object.keys/values()返回带enumerable标记的非 Symbol 键/值(属性标记在属性描述符一文中有详细讲解);for..in遍历带enumerable标记的非 Symbol 键,还会包含原型链上的键。
……但它们都以那份列表为起点。下面的例子用ownKeys拦截器让for..in、Object.keys、Object.values跳过以下划线_开头的属性:
let user = { name: "John", age: 30, _password: "***" }; user = new Proxy(user, { ownKeys(target) { return Object.keys(target).filter(key => !key.startsWith('_')); } }); // "ownKeys" 过滤掉了 _password for(let key in user) alert(key); // name, 然后是: age // 对这些方法效果相同: alert( Object.keys(user) ); // name,age alert( Object.values(user) ); // John,30但注意:如果返回对象中不存在的键,Object.keys不会列出它:
let user = { }; user = new Proxy(user, { ownKeys(target) { return ['a', 'b', 'c']; } }); alert( Object.keys(user) ); // <空>原因很简单:Object.keys只返回带enumerable标记的属性。要检查这个标记,它会对每个属性调用内部方法[[GetOwnProperty]]获取属性描述符。这里没有对应属性,描述符为空,没有enumerable标记,于是被跳过。
要让Object.keys返回某属性,需要它要么真实存在于对象中且带enumerable标记,要么拦截[[GetOwnProperty]]调用(getOwnPropertyDescriptor拦截器),返回带enumerable: true的描述符:
let user = { }; user = new Proxy(user, { ownKeys(target) { // 调用一次获取属性列表 return ['a', 'b', 'c']; }, getOwnPropertyDescriptor(target, prop) { // 对每个属性调用 return { enumerable: true, configurable: true /* ...其他标记,可能的 "value:..." */ }; } }); alert( Object.keys(user) ); // a, b, c再次强调:只有属性在对象中不存在时,才需要拦截[[GetOwnProperty]]。
用deleteProperty等拦截器保护"私有"属性
业界有个广泛约定:以下划线_前缀开头的属性和方法视为内部属性,不应从对象外部访问。技术上这完全可以做到:
let user = { name: "John", _password: "secret" }; alert(user._password); // secret用代理阻止任何对_前缀属性的访问,需要这些拦截器:
get—— 读取这类属性时抛错;set—— 写入时抛错;deleteProperty—— 删除时抛错;ownKeys—— 让for..in和Object.keys等方法排除_前缀属性。
完整代码:
let user = { name: "John", _password: "***" }; user = new Proxy(user, { get(target, prop) { if (prop.startsWith('_')) { throw new Error("Access denied"); } let value = target[prop]; return (typeof value === 'function') ? value.bind(target) : value; // (*) }, set(target, prop, val) { // 拦截属性写入 if (prop.startsWith('_')) { throw new Error("Access denied"); } else { target[prop] = val; return true; } }, deleteProperty(target, prop) { // 拦截属性删除 if (prop.startsWith('_')) { throw new Error("Access denied"); } else { delete target[prop]; return true; } }, ownKeys(target) { // 拦截属性列表 return Object.keys(target).filter(key => !key.startsWith('_')); } }); // "get" 不允许读取 _password try { alert(user._password); // Error: Access denied } catch(e) { alert(e.message); } // "set" 不允许写入 _password try { user._password = "test"; // Error: Access denied } catch(e) { alert(e.message); } // "deleteProperty" 不允许删除 _password try { delete user._password; // Error: Access denied } catch(e) { alert(e.message); } // "ownKeys" 过滤掉 _password for(let key in user) alert(key); // name为什么get中要给函数bind(target)?
注意get拦截器中标(*)的重要细节:
get(target, prop) { // ... let value = target[prop]; return (typeof value === 'function') ? value.bind(target) : value; // (*) }原因是对象方法(如user.checkPassword())必须能访问_password:
user = { // ... checkPassword(value) { // 对象方法必须能读取 _password return value === this._password; } }调用user.checkPassword()时,被代理的user会成为this(点号前的对象就是this),因此方法内访问this._password时get拦截器会触发(它拦截一切属性读取)并抛出错误。所以在(*)行把对象方法的上下文绑定到原始对象target,之后它们的调用将以target作为this,完全不经过拦截器。
这个方案通常可行,但并不理想:方法可能把未代理的对象传给别处,导致"哪个是原始对象、哪个是代理"的混乱;而且一个对象可能被多次代理(多个代理给对象叠加不同"特性"),把未包装对象传给方法可能产生意外后果。因此这类代理不应随处滥用。
补充说明:现代 JavaScript 引擎在类中原生支持以#前缀的私有属性,详见私有与受保护属性与方法,无需代理。不过这类属性也有自身的问题,尤其是不支持继承。
用has拦截器实现"范围检查"
has拦截器截获in调用。has(target, property):
target—— 传给new Proxy的目标对象;property—— 属性名。
假设有一个范围对象,希望用in运算符检查数字是否在范围内:
let range = { start: 1, end: 10 }; range = new Proxy(range, { has(target, prop) { return prop >= target.start && prop <= target.end; } }); alert(5 in range); // true alert(50 in range); // false优雅的语法糖,而且实现极简。
用apply拦截器包装函数
代理也可以包裹函数。apply(target, thisArg, args)拦截器处理"把代理当作函数调用"的情况:
target是目标对象(在 JavaScript 中函数也是对象);thisArg是this的值;args是参数列表。
先回顾装饰器(call/apply)一章中的delay(f, ms)装饰器。当时我们没用代理:调用delay(f, ms)返回一个函数,在ms毫秒后把所有调用转发给f。基于函数的旧实现:
function delay(f, ms) { // 返回一个在超时后把调用传给 f 的包装函数 return function() { // (*) setTimeout(() => f.apply(this, arguments), ms); }; } function sayHi(user) { alert(`Hello, ${user}!`); } // 包装后,对 sayHi 的调用将延迟 3 秒 sayHi = delay(sayHi, 3000); sayHi("John"); // Hello, John!(3 秒后)这种实现大体可用:包装函数(*)在超时后执行调用。但包装函数不会转发属性读写等其他操作。包装之后,原始函数的属性(如name、length)访问就丢了:
function delay(f, ms) { return function() { setTimeout(() => f.apply(this, arguments), ms); }; } function sayHi(user) { alert(`Hello, ${user}!`); } alert(sayHi.length); // 1(函数 length 是声明中参数个数) sayHi = delay(sayHi, 3000); alert(sayHi.length); // 0(包装函数声明中没有参数)Proxy强大得多,因为它把所有操作都转发给目标对象。改用Proxy实现:
function delay(f, ms) { return new Proxy(f, { apply(target, thisArg, args) { setTimeout(() => target.apply(thisArg, args), ms); } }); } function sayHi(user) { alert(`Hello, ${user}!`); } sayHi = delay(sayHi, 3000); alert(sayHi.length); // 1(*)代理把 "get length" 操作转发给目标 sayHi("John"); // Hello, John!(3 秒后)结果相同,但这次不仅调用,代理上的所有操作都会被转发给原函数。所以sayHi.length在包装后仍能正确返回(见(*)行)。我们得到了一个"更丰富"的包装器。
其余拦截器(construct等)的用法模式与此类似,完整列表见本文开头的对照表。
配套练习:负索引数组
02-array-negative/task.md 要求实现array[-1]这样的负索引访问。参考解法 02-array-negative/solution.md 是get拦截器配合Reflect.get转发的一行式技巧:
let array = [1, 2, 3]; array = new Proxy(array, { get(target, prop, receiver) { if (prop < 0) { // 即使像 arr[1] 这样访问,prop 也是字符串,需要转成数字 prop = +prop + target.length; } return Reflect.get(target, prop, receiver); } }); alert(array[-1]); // 3 alert(array[-2]); // 2Reflect:与拦截器一一对应的反射 API
Reflect是一个内置对象,用于简化Proxy的创建。前面说过[[Get]]、[[Set]]等内部方法仅存在于规范中,不能直接调用。Reflect让这成为可能——它的方法就是内部方法的最小包装。下面列出等价的操作与Reflect调用:
| 操作 | 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'); alert(user.name); // JohnReflect还允许把运算符(new、delete……)当作函数调用(Reflect.construct、Reflect.deleteProperty……),这本身很有用,但更重要的是下面这一点:
凡是能被Proxy拦截的每个内部方法,Reflect都有同名同参的对应方法。
因此可以用Reflect把操作转发回原始对象。下面例子中get、set两个拦截器透明地把读写转发给对象(仿佛不存在),同时打印消息:
let user = { name: "John", }; user = new Proxy(user, { get(target, prop, receiver) { alert(`GET ${prop}`); return Reflect.get(target, prop, receiver); // (1) }, set(target, prop, val, receiver) { alert(`SET ${prop}=${val}`); return Reflect.set(target, prop, val, receiver); // (2) } }); let name = user.name; // 显示 "GET name" user.name = "Pete"; // 显示 "SET name=Pete"Reflect.get读取对象属性;Reflect.set写入对象属性,成功返回true,否则返回false。
规则很简单:拦截器想转发调用给对象,只需用相同参数调用Reflect.<方法>。多数情况下不用Reflect也能做到(比如Reflect.get(target, prop, receiver)可替换为target[prop]),但有重要细微差别。
代理 getter 时的this问题:receiver的作用
下面这个例子说明为什么Reflect.get更好,也解释get/set第三个参数receiver的意义(此前一直没用)。
有一个带_name属性和 getter 的对象user:
let user = { _name: "Guest", get name() { return this._name; } }; let userProxy = new Proxy(user, { get(target, prop, receiver) { return target[prop]; } }); alert(userProxy.name); // Guest这个get拦截器是"透明"的:直接返回原属性,不做其他事。一切看起来正常。但让例子复杂一点——让另一个对象admin继承自user,错误行为就暴露了:
let user = { _name: "Guest", get name() { return this._name; } }; let userProxy = new Proxy(user, { get(target, prop, receiver) { return target[prop]; // (*) target = user } }); let admin = { __proto__: userProxy, _name: "Admin" }; // 期望: Admin alert(admin.name); // 输出: Guest(?!?)读取admin.name理应返回"Admin",而不是"Guest"!问题出在代理的(*)行:
- 读取
admin.name时,admin自身没有该属性,查找沿原型链进行; - 原型是
userProxy; - 从代理读取
name属性时get拦截器触发,在(*)行通过target[prop]从原始对象返回。当prop是 getter 时,target[prop]会在this=target的上下文执行其代码,结果取的是原始对象target(即user)的this._name。
修复这类问题需要receiver——get拦截器的第三个参数。它保存了应传给 getter 的正确this,本例中是admin。如何给 getter 传上下文?普通函数可以用call/apply,但 getter 不是"被调用"的,只是被读取。Reflect.get能做到。使用它一切就正确了:
let user = { _name: "Guest", get name() { return this._name; } }; let userProxy = new Proxy(user, { get(target, prop, receiver) { // receiver = admin return Reflect.get(target, prop, receiver); // (*) } }); let admin = { __proto__: userProxy, _name: "Admin" }; alert(admin.name); // Admin现在receiver(保存正确this,即admin)通过Reflect.get在(*)行传给 getter。拦截器还能写得更短:
get(target, prop, receiver) { return Reflect.get(...arguments); }Reflect的调用与拦截器同名同参,是刻意这样设计的。所以return Reflect...提供了一种安全无脑的转发方式,保证不遗漏任何细节。
Proxy 的限制
代理提供了在最底层改变既有对象行为的独特手段,但并非完美,存在一些限制。
内置对象:内部槽(Internal Slots)
许多内置对象(如Map、Set、Date、Promise等)使用所谓的"内部槽(internal slots)"。它们像属性,但只用于规范内部。例如Map把条目存在内部槽[[MapData]]中,内置方法直接访问它,而不是通过[[Get]]/[[Set]]内部方法,所以Proxy无法拦截。
问题在于:内置对象被代理后,代理没有这些内部槽,内置方法会失败:
let map = new Map(); let proxy = new Proxy(map, {}); proxy.set('test', 1); // Error内部地,Map把全部数据存在[[MapData]]内部槽中,代理没有这个槽。Map.prototype.set方法尝试访问this.[[MapData]],但this=proxy,在proxy上找不到,直接失败。
幸运的是有修复办法:
let map = new Map(); 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); alert(proxy.get('test')); // 1(正常工作了!)现在一切正常,因为get拦截器把函数属性(如map.set)绑定到目标对象map本身。与之前的例子不同,proxy.set(...)内部的this不再是proxy,而是原始map。于是set的内部实现访问this.[[MapData]]内部槽时就能成功。
一个显著例外:内置的Array不使用内部槽。这是历史原因——数组出现得太早。因此代理数组不存在这类问题。
私有字段(Private Fields)
私有类字段有类似问题。例如getName()方法访问私有属性#name,代理后就会出错:
class User { #name = "Guest"; getName() { return this.#name; } } let user = new User(); user = new Proxy(user, {}); alert(user.getName()); // Error原因:私有字段也是用内部槽实现的,JavaScript 访问它们时不经过[[Get]]/[[Set]]。调用getName()时this是被代理的user,它没有保存私有字段的槽。
同样,绑定方法可以解决:
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; } }); alert(user.getName()); // Guest不过如前所述,这个方案有缺点:把原始对象暴露给方法,可能被继续传出去,破坏其他代理功能。
Proxy 不等于 target
代理和原始对象是两个不同的对象。如果先用原始对象作键,再把它代理,代理就找不到了:
let allUsers = new Set(); class User { constructor(name) { this.name = name; allUsers.add(this); } } let user = new User("John"); alert(allUsers.has(user)); // true user = new Proxy(user, {}); alert(allUsers.has(user)); // false代理后无法在allUsers集合中找到user,因为代理是另一个对象。
代理无法拦截严格相等测试===:代理可以拦截许多运算符(new对应construct、in对应has、delete对应deleteProperty等),但没有任何办法拦截对象间的严格相等测试——一个对象只与自身严格相等,与任何其他值都不相等。因此所有比较对象是否相等的操作和内置类都会区分对象与代理,这里不存在透明替换。
可撤销代理(Revocable Proxies)
可撤销代理是能被禁用的代理。假设有一个资源,希望在任意时刻关闭对它的访问。做法是把它包进一个没有拦截器的可撤销代理:平时代理把操作转发给对象,需要时随时禁用。
语法:
let {proxy, revoke} = Proxy.revocable(target, handler)调用返回一个对象,包含proxy和用于禁用它的revoke函数:
let object = { data: "Valuable data" }; let {proxy, revoke} = Proxy.revocable(object, {}); // 把 proxy 传给别处而不是 object... alert(proxy.data); // Valuable data // 稍后在我们的代码中 revoke(); // 代理不再工作了(已撤销) alert(proxy.data); // Error调用revoke()会移除代理对目标对象的所有内部引用,二者不再连接。最初revoke与proxy分离,便于把proxy传出去而把revoke留在当前作用域。也可以设置proxy.revoke = revoke把revoke绑定到代理上。
另一种做法是创建以proxy为键、对应revoke为值的WeakMap,方便为某个代理快速找到revoke:
let revokes = new WeakMap(); let object = { data: "Valuable data" }; let {proxy, revoke} = Proxy.revocable(object, {}); revokes.set(proxy, revoke); // ..代码的别处.. revoke = revokes.get(proxy); revoke(); alert(proxy.data); // Error(已撤销)这里用WeakMap而非Map,因为它不会阻止垃圾回收:如果代理对象变得"不可达"(例如没有变量再引用它),WeakMap允许它连同不再需要的revoke一起从内存中清除。
总结
Proxy是对象的包装器,把对它的操作转发给对象,并可选地拦截其中一部分。它可以包装任何对象,包括类和函数。
语法:
let proxy = new Proxy(target, { /* traps */ });之后应当在所有地方使用proxy而不再使用target。代理没有自己的属性或方法;提供了拦截器就拦截该操作,否则转发给target。
可以拦截的操作:
- 读取(
get)、写入(set)、删除(deleteProperty)属性(即使是尚不存在的属性); - 函数调用(
apply拦截器); new运算符(construct拦截器);- 以及其他许多操作(完整列表见本文开头的对照表)。
这让我们能够创建"虚拟"属性和方法、实现默认值、可观察对象、函数装饰器等等。还可以用不同的代理多次包裹同一对象,叠加各种功能面。
ReflectAPI 专为配合Proxy设计:每个Proxy拦截器都有同参的Reflect调用,转发调用时应优先使用它们。代理也有一些限制:
- 内置对象有"内部槽",其访问无法被代理拦截,需使用上文介绍的绑定方法绕开;
- 私有类字段同样基于内部槽实现,被代理的方法调用必须让
this指向目标对象才能访问; - 对象相等测试
===无法被拦截; - 性能方面:基准测试因引擎而异,但通常最简代理访问属性也要慢数倍,实践中只对少数"瓶颈"对象有实际影响。
延伸阅读
- 属性描述符与
enumerable等标记:属性描述符; - 类中
#私有属性与受保护属性:私有与受保护属性与方法; - 基于函数的装饰器实现:call/apply 与装饰器;
- 配套练习:01-error-nonexisting、02-array-negative、03-observable。
- 文档/教程
- 前端
【免费下载链接】en.javascript.info
Modern JavaScript Tutorial
相关推荐
如何用Magika的AI智能识别技术解决文件类型检测难题
如何用Magika的AI智能识别技术解决文件类型检测难题 在日常开发和安全工作中,我们经常遇到一个看似简单却充满挑战的问题:如何快速准确地识别文件的真实类型?传
人工智能机器学习深度学习CLI如何用AI多智能体技术打造你的私人投资顾问:TradingAgents-CN完整指南
如何用AI多智能体技术打造你的私人投资顾问:TradingAgents CN完整指南 还在为复杂的股票分析工具而烦恼吗?想拥有专业的AI投资分析能力却不知从何入
人工智能大模型AI Agent多智能体金融科技后端前端JavaScript 教程:深入理解 Proxy 和 Reflect
JavaScript 教程:深入理解 Proxy 和 Reflect 在现代 JavaScript 开发中,元编程(Metaprogramming)能力变得越来
文档/教程前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考