ES6之Proxy代理
什么是Proxy代理
ES6 让开发者能进一步接近 JS 引擎的能力,这些能力原先只存在于内置对象上。语言通过代
理( proxy )暴露了在对象上的内部工作,代理是一种封装,能够拦截并改变 JS 引擎的底
层操作。
人话是:把代理看做是设计模式代理模式中的一种,有一个代理对象来代理本体,而ES6的Proxy牛逼的一点是可以把本体没法改变的内部属性改了
代理与反射是什么?
通过调用 new Proxy() ,你可以创建一个代理用来替代另一个对象(被称为目标),这个代理对目标对象进行了虚拟,因此该代理与该目标对象表面上可以被当作同一个对象来对待。
代理允许你拦截在目标对象上的底层操作,而这原本是JS引擎的内部能力。拦截行为使用了一个能够响应特定操作的函数(被称为陷阱)。
被 Reflect 对象所代表的反射接口,是给底层操作提供默认行为的方法的集合,这些操作是能够被代理重写的。每个代理陷阱都有一个对应的反射方法,每个方法都与对应的陷阱函数同名,并且接收的参数也与之一致。下表总结了这些行为:
代理陷阱 | 被重写的行为 | 默认行为 |
---|---|---|
get | 读取一个属性的值 | Reflect.get() |
set | 写入一个属性 | Reflect.set() |
has | in 运算符 | Reflect.has() |
deleteProperty | delete 运算符 | Reflect.deleteProperty() |
getPrototypeOf | Object.getPrototypeOf() | Reflect.getPrototypeOf() |
setPrototypeOf | Object.setPrototypeOf() | Reflect.setPrototypeOf() |
isExtensible | Object.isExtensiable() | Reflect.setPrototypeOf() |
preventExtensions | Object.preventExtensions() | Reflect.preventExtensions() |
getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor() | Reflect.getOwnPropertyDescriptor() |
defineProperty | Object.defineProperty() | Reflect.defineProperty() |
ownKeys | Object.keys 、Object.getOwnPropertyNames() 与Object.getOwnPropertySymbols() | Reflect.ownKeys() |
apply | 调用一个函数 | Reflect.apply() |
construct | 使用new调用一个函数 | Reflect.construct() |
每个陷阱函数都可以重写 JS 对象的一个特定内置行为,允许你拦截并修改它。如果你仍然需
要使用原先的内置行为,则可使用对应的反射接口方法。一旦创建了代理,你就能清晰了解
代理与反射接口之间的关系,因此我们最好通过一些例子来进行深入研究。
创建一个简单的代理
当你使用 Proxy 构造器来创建一个代理时,需要传递两个参数:目标对象以及一个处理器(handler),后者是定义了一个或多个陷阱函数的对象。如果未提供陷阱函数,代理会对所有操作采取默认行为。为了创建一个仅进行传递的代理,你需要使用不包含任何陷阱函数的处理器:
let target={}
let proxy=new Proxy(target,{})
proxy.name = "proxy";
console.log(proxy.name); // "proxy"
console.log(target.name); // "proxy"
target.name = "target";
console.log(proxy.name); // "target"
console.log(target.name); // "target"
使用 set 陷阱函数验证属性值
假设你想要创建一个对象,并要求其属性值只能是数值,这就意味着该对象的每个新增属性都要被验证,并且在属性值不为数值类型时应当抛出错误。为此你需要定义 set 陷阱函数来重写设置属性值时的默认行为,该陷阱函数能接受四个参数:
- trapTarget :将接收属性的对象(即代理的目标对象);
- key :需要写入的属性的键(字符串类型或符号类型);
- value :将被写入属性的值;
- receiver :操作发生的对象(通常是代理对象)。
Reflect.set() 是 set 陷阱函数对应的反射方法,同时也是set操作的默认行为。Reflect.set()方法与set陷阱函数一样,能接受这四个参数,让该方法能在陷阱函数内部被方便使用。该陷阱函数需要在属性被设置完成的情况下返回 true ,否则就要返回 false,而 Reflect.set() 也会基于操作是否成功而返回相应的结果。
你需要使用 set 陷阱函数来拦截传入的 value 值,以便对属性值进行验证。这里有个例子:
let target = {
name: "target"
};
let proxy = new Proxy(target, {
set(trapTarget, key, value, receiver) {
// 忽略已有属性,避免影响它们
if (!trapTarget.hasOwnProperty(key)) {
if (isNaN(value)) {
throw new TypeError("Property must be a number.");
}
}
// 添加属性
return Reflect.set(trapTarget, key, value, receiver);
}
});
// 添加一个新属性
proxy.count = 1;
console.log(proxy.count); // 1
console.log(target.count); // 1
// 你可以为 name 赋一个非数值类型的值,因为该属性已经存在
proxy.name = "proxy";
console.log(proxy.name); // "proxy"
console.log(target.name); // "proxy"
// 抛出错误
proxy.anotherName = "proxy";
使用 get 陷阱函数进行对象外形验证
该陷阱函数会在读取属性时被调用,即使该属性在对象中并不存在,它能接受三个参数:
- trapTarget :将会被读取属性的对象(即代理的目标对象);
- key :需要读取的属性的键(字符串类型或符号类型);
- receiver :操作发生的对象(通常是代理对象)。
你可以使用 get 陷阱函数与 Reflect.get() 方法在目标属性不存在时抛出错误,就像这样:
let proxy = new Proxy({}, {
get(trapTarget, key, receiver) {
if (!(key in receiver)) {
throw new TypeError("Property " + key + " doesn't exist.");
}
return Reflect.get(trapTarget, key, receiver);
}
});
// 添加属性的功能正常
proxy.name = "proxy";
console.log(proxy.name); // "proxy"
// 读取不存在属性会抛出错误
console.log(proxy.nme); // 抛出错误
使用 has 陷阱函数隐藏属性
has 陷阱函数会在使用 in 运算符的情况下被调用,并且会被传入两个参数:
- trapTarget :需要读取属性的对象(即代理的目标对象);
- key :需要检查的属性的键(字符串类型或符号类型)。
Reflect.has() 方法接受与之相同的参数,并向 in 运算符返回默认响应结果。使用 has陷阱函数以及 Reflect.has() 方法,允许你修改部分属性在接受 in 检测时的行为,但保留其他属性的默认行为。例如,假设你只想要隐藏 value 属性,你可以这么做:
let target = {
name: "target",
value: 42
};
let proxy = new Proxy(target, {
has(trapTarget, key) {
if (key === "value") {
return false;
} else {
return Reflect.has(trapTarget, key);
}
}
});
console.log("value" in proxy); // false
console.log("name" in proxy); // true
console.log("toString" in proxy); // true
这里的 proxy 对象使用了 has 陷阱函数,用于检查 key 值是否为 "value" 。如果是,则返回 false ;否则通过调用 Reflect.has() 方法来返回默认的结果。这样,虽然 value 属性确实存在于目标对象中,但 in 运算符却会对该属性返回 false ;而其他的属性(name 与 toString )则会正确地返回 true 。
使用 deleteProperty 陷阱函数避免属性被删除
deleteProperty 陷阱函数会在使用 delete 运算符去删除对象属性时下被调用,并且会被传入两个参数:
- trapTarget :需要删除属性的对象(即代理的目标对象);
- key :需要删除的属性的键(字符串类型或符号类型)。
Reflect.deleteProperty() 方法也接受这两个参数,并提供了 deleteProperty 陷阱函数的默认实现。你可以结合 Reflect.deleteProperty() 方法以及 deleteProperty 陷阱函数,来修改 delete 运算符的行为。例如,能确保 value 属性不被删除:
let target = {
name: "target",
value: 42
};
let proxy = new Proxy(target, {
deleteProperty(trapTarget, key) {
if (key === "value") {
return false;
} else {
return Reflect.deleteProperty(trapTarget, key);
}
}
});
// 尝试删除 proxy.value
console.log("value" in proxy); // true
let result1 = delete proxy.value;
console.log(result1); // false
console.log("value" in proxy); // true
// 尝试删除 proxy.name
console.log("name" in proxy); // true
let result2 = delete proxy.name;
console.log(result2); // true
console.log("name" in proxy); // false
原型代理的陷阱函数
ES6 引入该方法用于对 ES5 的Object.getPrototypeOf() 方法进行补充。代理允许你通过 setPrototypeOf 与
getPrototypeOf 陷阱函数来对这两个方法的操作进行拦截。Object对象上的这两个方法都会调用代理中对应名称的陷阱函数,从而允许你改变这两个方法的行为。
setPrototypeOf 陷阱函数接受三个参数:
- trapTarget :需要设置原型的对象(即代理的目标对象);
- proto :需用被用作原型的对象。
getPrototypeOf 陷阱函数的返回值必须是一个对象或者null,其他任何类型的返回值都会引发“运行时”错误。对于返回值的检测确保了Object.getPrototypeOf() 会返回预期的结果。类似的, setPrototypeOf 必须在操作没有成功的情况下返回 false ,这样会让 Object.setPrototypeOf()抛出错误;而若setPrototypeOf的返回值不是false,则Object.setPrototypeOf() 就会认为操作已成功。
let target = {};
let proxy = new Proxy(target, {
getPrototypeOf(trapTarget) {
return null;
},
setPrototypeOf(trapTarget, proto) {
return false;
}
});
let targetProto = Object.getPrototypeOf(target);
let proxyProto = Object.getPrototypeOf(proxy);
console.log(targetProto === Object.prototype); // true
console.log(proxyProto === Object.prototype); // false
console.log(proxyProto); // null
// 成功
Object.setPrototypeOf(target, {});
// 抛出错误
Object.setPrototypeOf(proxy, {});
如果你想在这两个陷阱函数中使用默认的行为,那么只需调用Reflect对象上的相应方法。例如,下面的代码为getPrototypeOf 方法与 setPrototypeOf 方法实现了默认的行为:
let target = {};
let proxy = new Proxy(target, {
getPrototypeOf(trapTarget) {
return Reflect.getPrototypeOf(trapTarget);
},
setPrototypeOf(trapTarget, proto) {
return Reflect.setPrototypeOf(trapTarget, proto);
}
});
let targetProto = Object.getPrototypeOf(target);
let proxyProto = Object.getPrototypeOf(proxy);
console.log(targetProto === Object.prototype); // true
console.log(proxyProto === Object.prototype); // true
// 成功
Object.setPrototypeOf(target, {});
// 同样成功
Object.setPrototypeOf(proxy, {});
对象可扩展性的陷阱函数
ES5 通过 Object.preventExtensions() 与 Object.isExtensible() 方法给对象增加了可扩展性。而 ES6 则通过 preventExtensions 与 isExtensible 陷阱函数允许代理拦截对于底层对象的方法调用。
isExtensible 陷阱函数必须返回一个布尔值用于表明目标对象是否可被扩展,而preventExtensions陷阱函数也需要返回一个布尔值,用于表明操作是否已成功。同时也存在 Reflect.preventExtensions() 与 Reflect.isExtensible() 方法,用于实现默认的行为。这两个方法都返回布尔值,因此它们可以在对应的陷阱函数内直接使用。
为了弄懂对象可扩展性的陷阱函数如何运作,可研究如下代码,该代码实现了 isExtensible与 preventExtensions 陷阱函数的默认行为。
let target = {};
let proxy = new Proxy(target, {
isExtensible(trapTarget) {
return Reflect.isExtensible(trapTarget);
},
preventExtensions(trapTarget) {
return Reflect.preventExtensions(trapTarget);
}
});
console.log(Object.isExtensible(target)); // true
console.log(Object.isExtensible(proxy)); // true
Object.preventExtensions(proxy);
console.log(Object.isExtensible(target)); // false
console.log(Object.isExtensible(proxy)); // false
属性描述符的陷阱函数
ES5 最重要的特征之一就是引入了Object.defineProperty()方法用于定义属性的特性。在JS之前的版本中,没有方法可以定义一个访问器属性,也不能让属性变成只读或是不可枚举。而这些特性都能够利用Object.defineProperty()方法来实现,并且你还可以利用Object.getOwnPropertyDescriptor() 方法来检索这些特性。
代理允许你使用 defineProperty 与 getOwnPropertyDescriptor 陷阱函数,来分别拦截对Object.defineProperty() 与 Object.getOwnPropertyDescriptor() 的调用。 defineProperty陷阱函数接受下列三个参数:
- trapTarget :需要被定义属性的对象(即代理的目标对象);
- key :属性的键(字符串类型或符号类型);
- descriptor :为该属性准备的描述符对象。
默认的行为:
let proxy = new Proxy({}, {
defineProperty(trapTarget, key, descriptor) {
return Reflect.defineProperty(trapTarget, key, descriptor);
},
getOwnPropertyDescriptor(trapTarget, key) {
return Reflect.getOwnPropertyDescriptor(trapTarget, key);
}
});
Object.defineProperty(proxy, "name", {
value: "proxy"
});
console.log(proxy.name); // "proxy"
let descriptor = Object.getOwnPropertyDescriptor(proxy, "name");
console.log(descriptor.value); // "proxy"
阻止 Object.defineProperty(
defineProperty 陷阱函数要求你返回一个布尔值用于表示操作是否已成功。当它返回 true时, Object.defineProperty() 会正常执行;而如果它返回了 false ,则Object.defineProperty() 会抛出错误。
let proxy = new Proxy({}, {
defineProperty(trapTarget, key, descriptor) {
if (typeof key === "symbol") {
return false;
}
return Reflect.defineProperty(trapTarget, key, descriptor);
}
});
Object.defineProperty(proxy, "name", {
value: "proxy"
});
console.log(proxy.name); // "proxy"
let nameSymbol = Symbol("name");
// 抛出错误
Object.defineProperty(proxy, nameSymbol, {
value: "proxy"
});
描述符对象的限制
为了确保 Object.defineProperty() 与 Object.getOwnPropertyDescriptor() 方法的行为一致,传递给 defineProperty 陷阱函数的描述符对象必须是正规的。出于同一原因,getOwnPropertyDescriptor陷阱函数返回的对象也始终需要被验证。
任意对象都能作为 Object.defineProperty() 方法的第三个参数;然而传递给defineProperty 陷阱函数的描述符对象参数,则只有 enumerable 、 configurable 、value 、 writable 、 get 与 set 这些属性是被许可的。
ownKeys 陷阱函数
ownKeys 代理陷阱拦截了内部方法[[OwnPropertyKeys]],并允许你返回一个数组用于重写该行为。返回的这个数组会被用于四个方法: Object.keys() 方法、Object.getOwnPropertyNames() 方法、 Object.getOwnPropertySymbols() 方法与
Object.assign() 方法,其中 Object.assign() 方法会使用该数组来决定哪些属性会被复制。
ownKeys 陷阱函数的默认行为由Reflect.ownKeys()方法实现,会返回一个由全部自有属性的键构成的数组,无论键的类型是字符串还是符号。 Object.getOwnProperyNames() 方法与Object.keys() 方法会将符号值从该数组中过滤出去;相反,
Object.getOwnPropertySymbols() 会将字符串值过滤掉;而Object.assign()方法会使用数组中所有的字符串值与符号值。
ownKeys 陷阱函数接受单个参数,即目标对象,同时必须返回一个数组或者一个类数组对象,不合要求的返回值会导致错误。你可以使用 ownKeys 陷阱函数去过滤特定的属性,以避免这些属性被Object.keys()方法、Object.getOwnPropertyNames() 方法、Object.getOwnPropertySymbols()方法或Object.assign()方法使用。假设你不想在结果中包含任何以下划线打头的属性(在 JS 的编码惯例中,这代表该字段是私有的),那么可以使用ownKeys陷阱函数来将它们过滤掉,就像下面这样:
let proxy = new Proxy({}, {
ownKeys(trapTarget) {
return Reflect.ownKeys(trapTarget).filter(key => {
return typeof key !== "string" || key[0] !== "_";
});
}
});
let nameSymbol = Symbol("name");
proxy.name = "proxy";
proxy._name = "private";
proxy[nameSymbol] = "symbol";
let names = Object.getOwnPropertyNames(proxy),
keys = Object.keys(proxy);
symbols = Object.getOwnPropertySymbols(proxy);
console.log(names.length); // 1
console.log(names[0]); // "name"
console.log(keys.length); // 1
console.log(keys[0]); // "name"
console.log(symbols.length); // 1
console.log(symbols[0]); // "Symbol(name)"
ownKeys 陷阱函数也能影响 for-in 循环,因为这种循环调用了陷阱函数来决定哪些值能够被用在循环内。
使用 apply 与 construct 陷阱函数的函数代理
在所有的代理陷阱中,只有 apply 与 construct 要求代理目标对象必须是一个函数。函数拥有两个内部方法: [[Call]] 与 [[Construct]] ,前者会在函数被直接调用时执行,而后者会在函数被使用 new 运算符调用时执行。 apply 与 construct陷阱函数对应着这两个内部方法,并允许你对其进行重写。当不使用 new 去调用一个函数时, apply 陷阱函数会接收到下列三个参数( Reflect.apply() 也会接收这些参数):
- trapTarget :被执行的函数(即代理的目标对象);
- thisArg :调用过程中函数内部的 this 值;
- argumentsList :被传递给函数的参数数组。
当使用 new 去执行函数时, construct 陷阱函数会被调用并接收到下列两个参数:
- trapTarget :被执行的函数(即代理的目标对象);
- argumentsList :被传递给函数的参数数组。
因此,可以用来做很多骚操作,比如
调用构造器而无须使用 new
function Numbers(...values) {
if (typeof new.target === "undefined") {
throw new TypeError("This function must be called with new.");
}
this.values = values;
}
let NumbersProxy = new Proxy(Numbers, {
apply: function(trapTarget, thisArg, argumentsList) {
return Reflect.construct(trapTarget, argumentsList);
}
});
let instance = NumbersProxy(1, 2, 3, 4);
console.log(instance.values); // [1,2,3,4]
可被撤销的代理
在被创建之后,代理通常就不能再从目标对象上被解绑。本章之前的例子都使用了不可被撤销的代理,但有的情况下你可能想撤销一个代理以便让它不能再被使用。当你想通过公共接口向外提供一个安全的对象,并且要求要随时都能切断对某些功能的访问,这种情况下可被撤销的代理就会非常有用。
你可以使用 Proxy.revocable()方法来创建一个可被撤销的代理,该方法接受的参数与Proxy构造器的相同:一个目标对象- 、一个代理处理器,而返回值是包含下列属性的一个对象:
- proxy :可被撤销的代理对象;
- revoke :用于撤销代理的函数。
当 revoke() 函数被调用后,就不能再对该proxy对象进行更多操作,任何与该代理对象交互的意图都会触发代理的陷阱函数,从而抛出一个错误。
总结
在 ES6 之前,特定对象(例如数组)会显示出一些非常规的、无法被开发者复制的行为,而代理的出现改变了这种情况。代理允许你为一些 JS 底层操作自行定义非常规行为,因此你就可以通过代理陷阱来复制JS内置对象的所有行为。在各种不同操作发生时(例如对于 in运算符的使用),这些代理陷阱会在后台被调用。
反射接口也是在 ES6 中引入的,允许开发者为每个代理陷阱实现默认的行为。每个代理陷阱在 Reflect 对象( ES6 的另一个新特性)上都有一个同名的对应方法。将代理陷阱与反射接口方法结合使用,就可以在特定条件下让一些操作有不同的表现,有别于默认的内置行为。
可被撤销的代理是一种特殊的代理,可以使用revoke()函数去有效禁用。revoke()函数终结了代理的所有功能,因此在它被调用之后,所有与代理属性交互的意图都会导致抛出错误。第三方开发者可能需要在一定时间内获取特定对象的使用权,在这种场合,可被撤销的代理对应用的安全性来说就非常重要。
尽管直接使用代理是最有力的使用方式,但你也可以把代理用作另一个对象的原型。但只有很少的代理陷阱能在作为原型的代理上被有效使用,包括 get 、 set 与 has 这几个,这让这方面的用例变得十分有限。
本作品采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。