代理与反射

ECMAScript 6 新增的代理和反射为开发者提供了拦截并向基本操作嵌入额外行为的能力。具体地说,可以给目标对象定义一个关联的代理对象,而这个代理对象可以作为抽象的目标对象来使用。在对目标对象的各种操作影响目标对象之前,可以在代理对象中对这些操作加以控制

在 ES6 之前,ECMAScript 中并没有类似代理的特性。由于代理是一种新的基础性语言能力,很多转译程序都不能把代理行为转换为之前的 ECMAScript 代码,因为代理的行为实际上是无可替代的。为此,代理和反射只在百分之百支持它们的平台上有用。可以检测代理是否存在,不存在则提供后备代码。不过这会导致代码冗余,因此并不推荐

代理基础

创建空代理

代理是使用 Proxy 构造函数创建的。这个构造函数接收两个参数:目标对象和处理程序对象。缺少其中任何一个参数都会抛出 TypeError。

  1. const target = {
  2. id: 'target'
  3. };
  4. const handler = {};
  5. const proxy = new Proxy(target, handler);
  6. // id 属性会访问同一个值
  7. console.log(target.id); // target
  8. console.log(proxy.id); // target
  9. // 给目标属性赋值会反映在两个对象上
  10. // 因为两个对象访问的是同一个值
  11. target.id = 'foo';
  12. console.log(target.id); // foo
  13. console.log(proxy.id); // foo
  14. // 给代理属性赋值会反映在两个对象上
  15. // 因为这个赋值会转移到目标对象
  16. proxy.id = 'bar';
  17. console.log(target.id); // bar
  18. console.log(proxy.id); // bar
  19. // hasOwnProperty()方法在两个地方
  20. // 都会应用到目标对象
  21. console.log(target.hasOwnProperty('id')); // true
  22. console.log(proxy.hasOwnProperty('id')); // true
  23. // Proxy.prototype 是 undefined
  24. // 因此不能使用 instanceof 操作符
  25. console.log(target instanceof Proxy); // TypeError: Function has non-object prototype
  26. 'undefined' in instanceof check
  27. console.log(proxy instanceof Proxy); // TypeError: Function has non-object prototype
  28. 'undefined' in instanceof check
  29. // 严格相等可以用来区分代理和目标
  30. console.log(target === proxy); // false

定义捕获器

使用代理的主要目的是可以定义捕获器(trap)。捕获器就是在处理程序对象中定义的“基本操作的拦截器”。每个处理程序对象可以包含零个或多个捕获器,每个捕获器都对应一种基本操作,可以直接或间接在代理对象上调用。每次在代理对象上调用这些基本操作时,代理可以在这些操作传播到目标对象之前先调用捕获器函数,从而拦截并修改相应的行为,只有在代理对象上执行这些操作才会触发捕获器。在目标对象上执行这些操作仍然会产生正常的行为

  1. const target = {
  2. foo: 'bar'
  3. };
  4. const handler = {
  5. // 捕获器在处理程序对象中以方法名为键
  6. get() {
  7. return 'handler override';
  8. }
  9. };
  10. const proxy = new Proxy(target, handler);
  11. console.log(target.foo); // bar
  12. console.log(proxy.foo); // handler override
  13. console.log(target['foo']); // bar
  14. console.log(proxy['foo']); // handler override
  15. console.log(Object.create(target)['foo']); // bar
  16. console.log(Object.create(proxy)['foo']); // handler override

捕获器参数与反射 API

所有捕获器都可以访问相应的参数,基于这些参数可以重建被捕获方法的原始行为,处理程序对象中所有可以捕获的方法都有对应的反射(Reflect)API 方法。这些方法与捕获器拦截的方法具有相同的名称和函数签名,而且也具有与被拦截方法相同的行为

如果真想创建一个可以捕获所有方法,然后将每个方法转发给对应反射 API 的空代理,那么甚至不需要定义处理程序对象

  1. const target = {
  2. foo: 'bar'
  3. };
  4. const handler = {
  5. get: Reflect.get
  6. };
  7. const proxy = new Proxy(target, handler);
  8. console.log(proxy.foo); // bar
  9. console.log(target.foo); // bar
  1. const target = {
  2. foo: 'bar'
  3. };
  4. const proxy = new Proxy(target, Reflect);
  5. console.log(proxy.foo); // bar
  6. console.log(target.foo); // bar

捕获器不变式

根据 ECMAScript 规范,每个捕获的方法都知道目标对象上下文、捕获函数签名,而捕获处理程序的行为必须遵循“捕获器不变式”(trap invariant),比如,如果目标对象有一个不可配置且不可写的数据属性,那么在捕获器返回一个与该属性不同的值时,会抛出 TypeError

  1. const target = {};
  2. Object.defineProperty(target, 'foo', {
  3. configurable: false,
  4. writable: false,
  5. value: 'bar'
  6. });
  7. const handler = {
  8. get() {
  9. return 'qux';
  10. }
  11. };
  12. const proxy = new Proxy(target, handler);
  13. console.log(proxy.foo);
  14. // TypeError

可撤销代理

Proxy.revocable() 方法可以用来创建一个可撤销的代理对象

  1. const target = {
  2. foo: 'bar'
  3. };
  4. const handler = {
  5. get() {
  6. return 'intercepted';
  7. }
  8. };
  9. const { proxy, revoke } = Proxy.revocable(target, handler);
  10. console.log(proxy.foo); // intercepted
  11. console.log(target.foo); // bar
  12. revoke();
  13. console.log(proxy.foo); // TypeError

代理另一个代理

代理可以拦截反射 API 的操作,而这意味着完全可以创建一个代理,通过它去代理另一个代理。这样就可以在一个目标对象之上构建多层拦截网

  1. const target = {
  2. foo: 'bar'
  3. };
  4. const firstProxy = new Proxy(target, {
  5. get() {
  6. console.log('first proxy');
  7. return Reflect.get(...arguments);
  8. }
  9. });
  10. const secondProxy = new Proxy(firstProxy, {
  11. get() {
  12. console.log('second proxy');
  13. return Reflect.get(...arguments);
  14. }
  15. });
  16. console.log(secondProxy.foo);
  17. // second proxy
  18. // first proxy
  19. // bar

代理的问题与不足

  • 代理中的 this
  • 代理与内部槽位

代理捕获器与反射方法

代理可以捕获 13 种不同的基本操作。这些操作有各自不同的反射 API 方法、参数、关联 ECMAScript
操作和不变式

  1. get()捕获器会在获取属性值的操作中被调用。对应的反射 API 方法为 Reflect.get()
  2. set()捕获器会在设置属性值的操作中被调用。对应的反射 API 方法为 Reflect.set()
  3. has()捕获器会在 in 操作符中被调用。对应的反射 API 方法为 Reflect.has()
  4. defineProperty()捕获器会在 Object.defineProperty()中被调用。对应的反射 API 方法为Reflect.defineProperty()
  5. getOwnPropertyDescriptor()捕获器会在 Object.getOwnPropertyDescriptor()中被调用。对应的反射 API 方法为 Reflect.getOwnPropertyDescriptor()
  6. deleteProperty()捕获器会在 delete 操作符中被调用。对应的反射 API 方法为 Reflect. deleteProperty()。
  7. ownKeys()捕获器会在 Object.keys()及类似方法中被调用。对应的反射 API 方法为 Reflect. ownKeys()
  8. getPrototypeOf()捕获器会在 Object.getPrototypeOf()中被调用。对应的反射 API 方法为Reflect.getPrototypeOf()
  9. setPrototypeOf()捕获器会在 Object.setPrototypeOf()中被调用。对应的反射 API 方法为Reflect.setPrototypeOf()
  10. isExtensible()捕获器会在 Object.isExtensible()中被调用。对应的反射 API 方法为Reflect.isExtensible()
  11. preventExtensions()捕获器会在 Object.preventExtensions()中被调用。对应的反射 API方法为 Reflect.preventExtensions()。
  12. apply()捕获器会在调用函数时中被调用。对应的反射 API 方法为 Reflect.apply()。
  13. construct()捕获器会在 new 操作符中被调用。对应的反射 API 方法为 Reflect.construct()

代理模式

跟踪属性访问

通过捕获 get、set 和 has 等操作,可以知道对象属性什么时候被访问、被查询。把实现相应捕获器的某个对象代理放到应用中,可以监控这个对象何时在何处被访问过

  1. const user = {
  2. name: 'Jake'
  3. };
  4. const proxy = new Proxy(user, {
  5. get(target, property, receiver) {
  6. console.log(`Getting ${property}`);
  7. return Reflect.get(...arguments);
  8. },
  9. set(target, property, value, receiver) {
  10. console.log(`Setting ${property}=${value}`);
  11. return Reflect.set(...arguments);
  12. }
  13. });
  14. proxy.name; // Getting name
  15. proxy.age = 27; // Setting age=27

隐藏属性

代理的内部实现对外部代码是不可见的,因此要隐藏目标对象上的属性也轻而易举。比如

  1. const hiddenProperties = ['foo', 'bar'];
  2. const targetObject = {
  3. foo: 1,
  4. bar: 2,
  5. baz: 3
  6. };
  7. const proxy = new Proxy(targetObject, {
  8. get(target, property) {
  9. if (hiddenProperties.includes(property)) {
  10. return undefined;
  11. } else {
  12. return Reflect.get(...arguments);
  13. }
  14. },
  15. has(target, property) {
  16. if (hiddenProperties.includes(property)) {
  17. return false;
  18. } else {
  19. return Reflect.has(...arguments);
  20. }
  21. }
  22. });
  23. // get()
  24. console.log(proxy.foo); // undefined
  25. console.log(proxy.bar); // undefined
  26. console.log(proxy.baz); // 3
  27. // has()
  28. console.log('foo' in proxy); // false
  29. console.log('bar' in proxy); // false
  30. console.log('baz' in proxy); // true

属性验证

因为所有赋值操作都会触发 set()捕获器,所以可以根据所赋的值决定是允许还是拒绝赋值

  1. const target = {
  2. onlyNumbersGoHere: 0
  3. };
  4. const proxy = new Proxy(target, {
  5. set(target, property, value) {
  6. if (typeof value !== 'number') {
  7. return false;
  8. } else {
  9. return Reflect.set(...arguments);
  10. }
  11. }
  12. });
  13. proxy.onlyNumbersGoHere = 1;
  14. console.log(proxy.onlyNumbersGoHere); // 1
  15. proxy.onlyNumbersGoHere = '2';
  16. console.log(proxy.onlyNumbersGoHere); // 1

函数与构造函数参数验证

跟保护和验证对象属性类似,也可对函数和构造函数参数进行审查。比如,可以让函数只接收某种类型的值

  1. function median(...nums) {
  2. return nums.sort()[Math.floor(nums.length / 2)];
  3. }
  4. const proxy = new Proxy(median, {
  5. apply(target, thisArg, argumentsList) {
  6. for (const arg of argumentsList) {
  7. if (typeof arg !== 'number') {
  8. throw 'Non-number argument provided';
  9. }
  10. }
  11. return Reflect.apply(...arguments);
  12. }
  13. });
  14. console.log(proxy(4, 7, 1)); // 4
  15. console.log(proxy(4, '7', 1));
  16. // Error: Non-number argument provided

数据绑定与可观察对象

通过代理可以把运行时中原本不相关的部分联系到一起。这样就可以实现各种模式,从而让不同的代码互操作

小结

代理是 ECMAScript 6 新增的令人兴奋和动态十足的新特性。尽管不支持向后兼容,但它开辟出了一片前所未有的 JavaScript 元编程及抽象的新天地。

从宏观上看,代理是真实 JavaScript 对象的透明抽象层。代理可以定义包含捕获器的处理程序对象,而这些捕获器可以拦截绝大部分 JavaScript 的基本操作和方法。在这个捕获器处理程序中,可以修改任何基本操作的行为,当然前提是遵从捕获器不变式。

与代理如影随形的反射 API,则封装了一整套与捕获器拦截的操作相对应的方法。可以把反射 API看作一套基本操作,这些操作是绝大部分 JavaScript 对象 API 的基础。

代理的应用场景是不可限量的。开发者使用它可以创建出各种编码模式,比如(但远远不限于)跟踪属性访问、隐藏属性、阻止修改或删除属性、函数参数验证、构造函数参数验证、数据绑定,以及可观察对象