深入理解 JavaScript
请支持本书:购买捐赠
(广告,请勿屏蔽。)

18 使用代理进行元编程



18.1 概述

代理使我们能够拦截和自定义对对象执行的操作(例如获取属性)。它们是一种*元编程*特性。

在以下示例中

我们只拦截一个操作 - `get`(获取属性)

const logged = [];

const target = {size: 0};
const handler = {
  get(target, propKey, receiver) {
    logged.push('GET ' + propKey);
    return 123;
  }
};
const proxy = new Proxy(target, handler);

当我们获取属性 `proxy.size` 时,处理器会拦截该操作

assert.equal(
  proxy.size, 123);

assert.deepEqual(
  logged, ['GET size']);

有关可以拦截的操作列表,请参阅完整 API 参考

18.2 编程与元编程

在我们深入了解代理是什么以及它们为什么有用之前,我们首先需要了解什么是*元编程*。

在编程中,存在多个级别

基础级别和元级别可以使用不同的语言。在以下元程序中,元编程语言是 JavaScript,基础编程语言是 Java。

const str = 'Hello' + '!'.repeat(3);
console.log('System.out.println("'+str+'")');

元编程可以采取不同的形式。在上一个示例中,我们将 Java 代码打印到控制台。让我们同时使用 JavaScript 作为元编程语言和基础编程语言。这方面的经典示例是 `eval()` 函数,它允许我们动态地评估/编译 JavaScript 代码。在下面的交互中,我们使用它来评估表达式 `5 + 2`。

> eval('5 + 2')
7

其他 JavaScript 操作可能看起来不像元编程,但如果我们仔细观察,它们实际上是

// Base level
const obj = {
  hello() {
    console.log('Hello!');
  },
};

// Meta level
for (const key of Object.keys(obj)) {
  console.log(key);
}

程序在运行时检查自身的结构。这看起来不像元编程,因为 JavaScript 中编程结构和数据结构之间的界限很模糊。所有 `Object.*` 方法都可以被视为元编程功能。

18.2.1 元编程的种类

反射式元编程意味着程序处理自身。 Kiczales 等人 [2] 区分了三种反射式元编程

让我们看一些例子。

**示例:内省。** `Object.keys()` 执行内省(参见前面的示例)。

**示例:自我修改。** 以下函数 `moveProperty` 将属性从源移动到目标。它通过用于属性访问的方括号运算符、赋值运算符和 `delete` 运算符执行自我修改。(在生产代码中,我们可能会为此任务使用 属性描述符。)

function moveProperty(source, propertyName, target) {
  target[propertyName] = source[propertyName];
  delete source[propertyName];
}

以下是 `moveProperty()` 的使用方法

const obj1 = { color: 'blue' };
const obj2 = {};

moveProperty(obj1, 'color', obj2);

assert.deepEqual(
  obj1, {});

assert.deepEqual(
  obj2, { color: 'blue' });

ECMAScript 5 不支持拦截;创建代理是为了填补这一空白。

18.3 代理详解

代理为 JavaScript 带来了拦截。它们的工作原理如下。我们可以对对象 `obj` 执行许多操作 - 例如

代理是允许我们自定义其中一些操作的特殊对象。代理使用两个参数创建

注意:“拦截”的动词形式是“进行拦截”。拦截本质上是双向的。拦截本质上是单向的。

18.3.1 示例

在以下示例中,处理器拦截 `get` 和 `has` 操作。

const logged = [];

const target = {};
const handler = {
  /** Intercepts: getting properties */
  get(target, propKey, receiver) {
    logged.push(`GET ${propKey}`);
    return 123;
  },

  /** Intercepts: checking whether properties exist */
  has(target, propKey) {
    logged.push(`HAS ${propKey}`);
    return true;
  }
};
const proxy = new Proxy(target, handler);

如果我们获取属性(A 行)或使用 `in` 运算符(B 行),则处理器会拦截这些操作

assert.equal(proxy.age, 123); // (A)
assert.equal('hello' in proxy, true); // (B)

assert.deepEqual(
  logged, [
    'GET age',
    'HAS hello',
  ]);

处理器没有实现陷阱 `set`(设置属性)。因此,设置 `proxy.age` 会转发到 `target`,并导致设置 `target.age`

proxy.age = 99;
assert.equal(target.age, 99);

18.3.2 函数特定的陷阱

如果目标是函数,则可以拦截另外两个操作

仅对函数目标启用这些陷阱的原因很简单:否则,我们将无法转发 `apply` 和 `construct` 操作。

18.3.3 拦截方法调用

如果我们想通过代理拦截方法调用,我们将面临一个挑战:没有针对方法调用的陷阱。相反,方法调用被视为两个操作的序列

因此,如果我们想拦截方法调用,我们需要拦截两个操作

以下代码演示了如何做到这一点。

const traced = [];

function traceMethodCalls(obj) {
  const handler = {
    get(target, propKey, receiver) {
      const origMethod = target[propKey];
      return function (...args) { // implicit parameter `this`!
        const result = origMethod.apply(this, args);
        traced.push(propKey + JSON.stringify(args)
          + ' -> ' + JSON.stringify(result));
        return result;
      };
    }
  };
  return new Proxy(obj, handler);
}

我们没有使用代理进行第二次拦截;我们只是将原始方法包装在一个函数中。

让我们使用以下对象来试用 `traceMethodCalls()`

const obj = {
  multiply(x, y) {
    return x * y;
  },
  squared(x) {
    return this.multiply(x, x);
  },
};

const tracedObj = traceMethodCalls(obj);
assert.equal(
  tracedObj.squared(9), 81);

assert.deepEqual(
  traced, [
    'multiply[9,9] -> 81',
    'squared[9] -> 81',
  ]);

甚至 `obj.squared()` 中的调用 `this.multiply()` 也被跟踪了!这是因为 `this` 一直引用代理。

这不是最有效的解决方案。例如,可以缓存方法。此外,代理本身也会影响性能。

18.3.4 可撤销代理

代理可以被*撤销*(关闭)

const {proxy, revoke} = Proxy.revocable(target, handler);

在我们第一次调用函数 `revoke` 之后,我们对 `proxy` 应用的任何操作都会导致 `TypeError`。后续调用 `revoke` 不会产生任何影响。

const target = {}; // Start with an empty object
const handler = {}; // Don’t intercept anything
const {proxy, revoke} = Proxy.revocable(target, handler);

// `proxy` works as if it were the object `target`:
proxy.city = 'Paris';
assert.equal(proxy.city, 'Paris');

revoke();

assert.throws(
  () => proxy.prop,
  /^TypeError: Cannot perform 'get' on a proxy that has been revoked$/
);

18.3.5 作为原型的代理

代理 `proto` 可以成为对象 `obj` 的原型。一些在 `obj` 中开始的操作可能会在 `proto` 中继续。`get` 就是这样一种操作。

const proto = new Proxy({}, {
  get(target, propertyKey, receiver) {
    console.log('GET '+propertyKey);
    return target[propertyKey];
  }
});

const obj = Object.create(proto);
obj.weight;

// Output:
// 'GET weight'

在 `obj` 中找不到属性 `weight`,这就是为什么搜索在 `proto` 中继续,并在那里触发陷阱 `get` 的原因。还有更多影响原型的操作;它们列在本节的末尾。

18.3.6 转发被拦截的操作

处理器没有实现其陷阱的操作会自动转发到目标。有时,除了转发操作之外,我们还想执行一些任务。例如,拦截和记录所有操作,而不会阻止它们到达目标

const handler = {
  deleteProperty(target, propKey) {
    console.log('DELETE ' + propKey);
    return delete target[propKey];
  },
  has(target, propKey) {
    console.log('HAS ' + propKey);
    return propKey in target;
  },
  // Other traps: similar
}
18.3.6.1 改进:使用 `Reflect.*`

对于每个陷阱,我们首先记录操作的名称,然后通过手动执行操作来转发它。JavaScript 具有类似模块的对象 `Reflect`,可以帮助转发。

对于每个陷阱

handler.trap(target, arg_1, ···, arg_n)

`Reflect` 都有一个方法

Reflect.trap(target, arg_1, ···, arg_n)

如果我们使用 `Reflect`,则前面的示例如下所示。

const handler = {
  deleteProperty(target, propKey) {
    console.log('DELETE ' + propKey);
    return Reflect.deleteProperty(target, propKey);
  },
  has(target, propKey) {
    console.log('HAS ' + propKey);
    return Reflect.has(target, propKey);
  },
  // Other traps: similar
}
18.3.6.2 改进:使用代理实现处理器

现在,每个陷阱的功能都非常相似,我们可以通过代理实现处理器

const handler = new Proxy({}, {
  get(target, trapName, receiver) {
    // Return the handler method named trapName
    return (...args) => {
      console.log(trapName.toUpperCase() + ' ' + args[1]);
      // Forward the operation
      return Reflect[trapName](...args);
    };
  },
});

对于每个陷阱,代理都通过 `get` 操作请求一个处理器方法,我们给它一个。也就是说,所有处理器方法都可以通过单个元方法 `get` 来实现。使这种虚拟化变得简单是代理 API 的目标之一。

让我们使用这个基于代理的处理器

const target = {};
const proxy = new Proxy(target, handler);

proxy.distance = 450; // set
assert.equal(proxy.distance, 450); // get

// Was `set` operation correctly forwarded to `target`?
assert.equal(
  target.distance, 450);

// Output:
// 'SET distance'
// 'GETOWNPROPERTYDESCRIPTOR distance'
// 'DEFINEPROPERTY distance'
// 'GET distance'

18.3.7 陷阱:并非所有对象都可以被代理透明地包装

代理对象可以看作是拦截对其目标对象执行的操作 - 代理包装目标。代理的处理器对象就像代理的观察者或侦听器。它通过实现相应的方法(`get` 用于读取属性等)来指定应拦截哪些操作。如果缺少某个操作的处理器方法,则不会拦截该操作。它只是被转发到目标。

因此,如果处理器是空对象,则代理应该透明地包装目标。唉,这并不总是有效。

18.3.7.1 包装对象会影响 `this`

在我们深入探讨之前,让我们快速回顾一下包装目标如何影响 `this`

const target = {
  myMethod() {
    return {
      thisIsTarget: this === target,
      thisIsProxy: this === proxy,
    };
  }
};
const handler = {};
const proxy = new Proxy(target, handler);

如果我们直接调用 `target.myMethod()`,`this` 指向 `target`

assert.deepEqual(
  target.myMethod(), {
    thisIsTarget: true,
    thisIsProxy: false,
  });

如果我们通过代理调用该方法,`this` 指向 `proxy`

assert.deepEqual(
  proxy.myMethod(), {
    thisIsTarget: false,
    thisIsProxy: true,
  });

也就是说,如果代理将方法调用转发到目标,则 `this` 不会更改。因此,如果目标使用 `this`(例如,进行方法调用),则代理将继续处于循环中。

18.3.7.2 无法透明包装的对象

通常,具有空处理器的代理会透明地包装目标:我们不会注意到它们的存在,并且它们不会更改目标的行为。

但是,如果目标通过代理无法控制的机制将信息与 `this` 相关联,则会出现问题:事情会失败,因为关联的信息会因目标是否被包装而异。

例如,下面的类 Person 将私有信息存储在 WeakMap _name 中(关于此技术的更多信息,请参阅JavaScript for impatient programmers)。

const _name = new WeakMap();
class Person {
  constructor(name) {
    _name.set(this, name);
  }
  get name() {
    return _name.get(this);
  }
}

Person 的实例不能被透明地包装。

const jane = new Person('Jane');
assert.equal(jane.name, 'Jane');

const proxy = new Proxy(jane, {});
assert.equal(proxy.name, undefined);

jane.name 与包装后的 proxy.name 不同。以下实现没有这个问题。

class Person2 {
  constructor(name) {
    this._name = name;
  }
  get name() {
    return this._name;
  }
}

const jane = new Person2('Jane');
assert.equal(jane.name, 'Jane');

const proxy = new Proxy(jane, {});
assert.equal(proxy.name, 'Jane');
18.3.7.3 包装内置构造函数的实例

大多数内置构造函数的实例也使用一种不被代理拦截的机制。因此,它们也不能被透明地包装。如果我们使用 Date 的实例,我们可以看到这一点。

const target = new Date();
const handler = {};
const proxy = new Proxy(target, handler);

assert.throws(
  () => proxy.getFullYear(),
  /^TypeError: this is not a Date object\.$/
);

不受代理影响的机制称为*内部插槽*。这些插槽是与实例关联的类似属性的存储。规范将这些插槽视为具有方括号名称的属性。例如,以下方法是内部的,可以在所有对象 O 上调用。

O.[[GetPrototypeOf]]()

与属性不同,访问内部插槽不是通过普通的“获取”和“设置”操作完成的。如果通过代理调用 .getFullYear(),它将无法在 this 上找到所需的内部插槽,并通过 TypeError 报错。

对于 Date 方法,语言规范指出

除非另有明确定义,否则下面定义的 Date 原型对象的方法不是泛型的,并且传递给它们的 this 值必须是具有已初始化为时间值的 [[DateValue]] 内部插槽的对象。

18.3.7.4 解决方法

作为一种解决方法,我们可以更改处理程序转发方法调用的方式,并有选择地将 this 设置为目标而不是代理。

const handler = {
  get(target, propKey, receiver) {
    if (propKey === 'getFullYear') {
      return target.getFullYear.bind(target);
    }
    return Reflect.get(target, propKey, receiver);
  },
};
const proxy = new Proxy(new Date('2030-12-24'), handler);
assert.equal(proxy.getFullYear(), 2030);

这种方法的缺点是,方法在 this 上执行的任何操作都不会通过代理进行。

18.3.7.5 数组可以被透明地包装

与其他内置类型不同,数组可以被透明地包装。

const p = new Proxy(new Array(), {});

p.push('a');
assert.equal(p.length, 1);

p.length = 0;
assert.equal(p.length, 0);

数组可以包装的原因是,即使属性访问被自定义以使 .length 工作,数组方法也不依赖于内部插槽——它们是泛型的。

18.4 代理的用例

本节演示代理的用途。这将使我们有机会看到 API 的实际应用。

18.4.1 跟踪属性访问(getset

假设我们有一个函数 tracePropertyAccesses(obj, propKeys),每当设置或获取 obj 的属性(其键在数组 propKeys 中)时,它都会记录日志。在下面的代码中,我们将该函数应用于类 Point 的实例。

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  toString() {
    return `Point(${this.x}, ${this.y})`;
  }
}

// Trace accesses to properties `x` and `y`
const point = new Point(5, 7);
const tracedPoint = tracePropertyAccesses(point, ['x', 'y']);

获取和设置被跟踪对象 p 的属性具有以下效果。

assert.equal(tracedPoint.x, 5);
tracedPoint.x = 21;

// Output:
// 'GET x'
// 'SET x=21'

有趣的是,每当 Point 访问属性时,跟踪也会起作用,因为 this 现在指的是被跟踪的对象,而不是 Point 的实例。

assert.equal(
  tracedPoint.toString(),
  'Point(21, 7)');

// Output:
// 'GET x'
// 'GET y'
18.4.1.1 在没有代理的情况下实现 tracePropertyAccesses()

如果没有代理,我们将按如下方式实现 tracePropertyAccesses()。我们将每个属性替换为一个跟踪访问的 getter 和 setter。setter 和 getter 使用一个额外的对象 propData 来存储属性的数据。请注意,我们正在破坏性地更改原始实现,这意味着我们正在进行元编程。

function tracePropertyAccesses(obj, propKeys, log=console.log) {
  // Store the property data here
  const propData = Object.create(null);

  // Replace each property with a getter and a setter
  propKeys.forEach(function (propKey) {
    propData[propKey] = obj[propKey];
    Object.defineProperty(obj, propKey, {
      get: function () {
        log('GET '+propKey);
        return propData[propKey];
      },
      set: function (value) {
        log('SET '+propKey+'='+value);
        propData[propKey] = value;
      },
    });
  });
  return obj;
}

参数 log 使得对该函数进行单元测试更容易。

const obj = {};
const logged = [];
tracePropertyAccesses(obj, ['a', 'b'], x => logged.push(x));

obj.a = 1;
assert.equal(obj.a, 1);

obj.c = 3;
assert.equal(obj.c, 3);

assert.deepEqual(
  logged, [
    'SET a=1',
    'GET a',
  ]);
18.4.1.2 使用代理实现 tracePropertyAccesses()

代理为我们提供了一种更简单的解决方案。我们拦截属性的获取和设置,而不必更改实现。

function tracePropertyAccesses(obj, propKeys, log=console.log) {
  const propKeySet = new Set(propKeys);
  return new Proxy(obj, {
    get(target, propKey, receiver) {
      if (propKeySet.has(propKey)) {
        log('GET '+propKey);
      }
      return Reflect.get(target, propKey, receiver);
    },
    set(target, propKey, value, receiver) {
      if (propKeySet.has(propKey)) {
        log('SET '+propKey+'='+value);
      }
      return Reflect.set(target, propKey, value, receiver);
    },
  });
}

18.4.2 警告未知属性(getset

在访问属性方面,JavaScript 非常宽容。例如,如果我们尝试读取一个属性并拼写错误,我们不会得到异常——我们得到结果 undefined

在这种情况下,我们可以使用代理来获取异常。其工作原理如下。我们将代理设为对象的原型。如果在对象中找不到属性,则会触发代理的 get 陷阱。

这是这种方法的实现。

const propertyCheckerHandler = {
  get(target, propKey, receiver) {
    // Only check string property keys
    if (typeof propKey === 'string' && !(propKey in target)) {
      throw new ReferenceError('Unknown property: ' + propKey);
    }
    return Reflect.get(target, propKey, receiver);
  }
};
const PropertyChecker = new Proxy({}, propertyCheckerHandler);

让我们将 PropertyChecker 用于一个对象。

const jane = {
  __proto__: PropertyChecker,
  name: 'Jane',
};

// Own property:
assert.equal(
  jane.name,
  'Jane');

// Typo:
assert.throws(
  () => jane.nmae,
  /^ReferenceError: Unknown property: nmae$/);

// Inherited property:
assert.equal(
  jane.toString(),
  '[object Object]');
18.4.2.1 PropertyChecker 作为类

如果我们将 PropertyChecker 转换为构造函数,我们可以通过 extends 将其用于类。

// We can’t change .prototype of classes, so we are using a function
function PropertyChecker2() {}
PropertyChecker2.prototype = new Proxy({}, propertyCheckerHandler);

class Point extends PropertyChecker2 {
  constructor(x, y) {
    super();
    this.x = x;
    this.y = y;
  }
}

const point = new Point(5, 7);
assert.equal(point.x, 5);
assert.throws(
  () => point.z,
  /^ReferenceError: Unknown property: z/);

这是 point 的原型链。

const p = Object.getPrototypeOf.bind(Object);
assert.equal(p(point), Point.prototype);
assert.equal(p(p(point)), PropertyChecker2.prototype);
assert.equal(p(p(p(point))), Object.prototype);
18.4.2.2 防止意外创建属性

如果我们担心意外*创建*属性,我们有两个选择。

18.4.3 负数组索引(get

一些数组方法允许我们通过 -1 引用最后一个元素,通过 -2 引用倒数第二个元素,等等。例如:

> ['a', 'b', 'c'].slice(-1)
[ 'c' ]

唉,这在通过方括号运算符 ([]) 访问元素时不起作用。但是,我们可以使用代理来添加此功能。以下函数 createArray() 创建支持负索引的数组。它通过围绕数组实例包装代理来做到这一点。代理拦截由方括号运算符触发的 get 操作。

function createArray(...elements) {
  const handler = {
    get(target, propKey, receiver) {
      if (typeof propKey === 'string') {
        const index = Number(propKey);
        if (index < 0) {
          propKey = String(target.length + index);
        }
      }
      return Reflect.get(target, propKey, receiver);
    }
  };
  // Wrap a proxy around the Array
  return new Proxy(elements, handler);
}
const arr = createArray('a', 'b', 'c');
assert.equal(
  arr[-1], 'c');
assert.equal(
  arr[0], 'a');
assert.equal(
  arr.length, 3);

18.4.4 数据绑定(set

数据绑定是在对象之间同步数据。一个流行的用例是基于 MVC(模型-视图-控制器)模式的小部件:使用数据绑定,如果我们更改*模型*(由小部件可视化的数据),则*视图*(小部件)将保持最新。

为了实现数据绑定,我们必须观察对对象的更改并做出反应。以下代码片段是观察数组更改如何工作的草图。

function createObservedArray(callback) {
  const array = [];
  return new Proxy(array, {
    set(target, propertyKey, value, receiver) {
      callback(propertyKey, value);
      return Reflect.set(target, propertyKey, value, receiver);
    }
  });
}
const observedArray = createObservedArray(
  (key, value) => console.log(
    `${JSON.stringify(key)} = ${JSON.stringify(value)}`));
observedArray.push('a');

// Output:
// '"0" = "a"'
// '"length" = 1'

18.4.5 访问 RESTful Web 服务(方法调用)

代理可用于创建一个可以调用任意方法的对象。在以下示例中,函数 createWebService() 创建了一个这样的对象 service。在 service 上调用方法将检索具有相同名称的 Web 服务资源的内容。检索是通过 Promise 处理的。

const service = createWebService('http://example.com/data');
// Read JSON data in http://example.com/data/employees
service.employees().then((jsonStr) => {
  const employees = JSON.parse(jsonStr);
  // ···
});

以下代码是在没有代理的情况下快速而粗略地实现 createWebService 的方法。我们需要事先知道将在 service 上调用哪些方法。参数 propKeys 为我们提供了这些信息;它包含一个包含方法名称的数组。

function createWebService(baseUrl, propKeys) {
  const service = {};
  for (const propKey of propKeys) {
    service[propKey] = () => {
      return httpGet(baseUrl + '/' + propKey);
    };
  }
  return service;
}

使用代理,createWebService() 更简单。

function createWebService(baseUrl) {
  return new Proxy({}, {
    get(target, propKey, receiver) {
      // Return the method to be called
      return () => httpGet(baseUrl + '/' + propKey);
    }
  });
}

这两种实现都使用以下函数来发出 HTTP GET 请求(其工作原理在 JavaScript for impatient programmers 中有解释)。

function httpGet(url) {
  return new Promise(
    (resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.onload = () => {
        if (xhr.status === 200) {
          resolve(xhr.responseText); // (A)
        } else {
          // Something went wrong (404, etc.)
          reject(new Error(xhr.statusText)); // (B)
        }
      }
      xhr.onerror = () => {
        reject(new Error('Network error')); // (C)
      };
      xhr.open('GET', url);
      xhr.send();
    });
}

18.4.6 可撤销引用

*可撤销引用*的工作原理如下:不允许客户端直接访问重要资源(对象),只能通过引用(中间对象,资源的包装器)访问。通常,应用于引用的每个操作都会转发到资源。客户端完成后,通过*撤销*引用(将其关闭)来保护资源。此后,对引用应用操作会引发异常,并且不再转发任何内容。

在以下示例中,我们为资源创建了一个可撤销引用。然后,我们通过引用读取资源的属性之一。这是可行的,因为引用授予了我们访问权限。接下来,我们撤销引用。现在,引用不再允许我们读取该属性。

const resource = { x: 11, y: 8 };
const {reference, revoke} = createRevocableReference(resource);

// Access granted
assert.equal(reference.x, 11);

revoke();

// Access denied
assert.throws(
  () => reference.x,
  /^TypeError: Cannot perform 'get' on a proxy that has been revoked/
);

代理非常适合实现可撤销引用,因为它们可以拦截和转发操作。这是一个简单的基于代理的 createRevocableReference 实现。

function createRevocableReference(target) {
  let enabled = true;
  return {
    reference: new Proxy(target, {
      get(target, propKey, receiver) {
        if (!enabled) {
          throw new TypeError(
            `Cannot perform 'get' on a proxy that has been revoked`);
        }
        return Reflect.get(target, propKey, receiver);
      },
      has(target, propKey) {
        if (!enabled) {
          throw new TypeError(
            `Cannot perform 'has' on a proxy that has been revoked`);
        }
        return Reflect.has(target, propKey);
      },
      // (Remaining methods omitted)
    }),
    revoke: () => {
      enabled = false;
    },
  };
}

可以使用上一节中的代理作为处理程序技术来简化代码。这一次,处理程序基本上是 Reflect 对象。因此,get 陷阱通常返回适当的 Reflect 方法。如果引用已被撤销,则会抛出 TypeError

function createRevocableReference(target) {
  let enabled = true;
  const handler = new Proxy({}, {
    get(_handlerTarget, trapName, receiver) {
      if (!enabled) {
        throw new TypeError(
          `Cannot perform '${trapName}' on a proxy`
          + ` that has been revoked`);
      }
      return Reflect[trapName];
    }
  });
  return {
    reference: new Proxy(target, handler),
    revoke: () => {
      enabled = false;
    },
  };
}

但是,我们不必自己实现可撤销引用,因为代理可以被撤销。这一次,撤销发生在代理中,而不是在处理程序中。处理程序要做的就是将每个操作转发给目标。正如我们所见,如果处理程序没有实现任何陷阱,这会自动发生。

function createRevocableReference(target) {
  const handler = {}; // forward everything
  const { proxy, revoke } = Proxy.revocable(target, handler);
  return { reference: proxy, revoke };
}
18.4.6.1 膜

*膜*建立在可撤销引用的基础上:用于安全运行不受信任代码的库在该代码周围包裹了一层膜,以隔离它并确保系统其余部分的安全。对象在两个方向上传递膜。

在这两种情况下,可撤销引用都包装在对象周围。包装函数或方法返回的对象也被包装。此外,如果将包装的湿对象传递回膜中,则会将其解包。

一旦不受信任的代码完成,所有可撤销引用都将被撤销。结果,它在外部的任何代码都无法再执行,并且它引用的外部对象也将停止工作。Caja 编译器 是“一种使第三方 HTML、CSS 和 JavaScript 安全嵌入到您的网站中的工具”。它使用膜来实现这一目标。

18.4.7 在 JavaScript 中实现 DOM

浏览器的文档对象模型 (DOM) 通常以 JavaScript 和 C++ 的混合方式实现。在纯 JavaScript 中实现它对于以下方面很有用:

唉,标准 DOM 可以做一些在 JavaScript 中不容易复制的事情。例如,大多数 DOM 集合都是 DOM 当前状态的实时视图,每当 DOM 发生更改时,这些视图都会动态更改。结果,纯 JavaScript 实现的 DOM 效率不高。将代理添加到 JavaScript 的原因之一是启用更高效的 DOM 实现。

18.4.8 更多用例

代理还有更多用例。例如:

18.4.9 正在使用代理的库

18.5 代理 API 的设计

在本节中,我们将更深入地探讨代理的工作原理以及它们为何以这种方式工作。

18.5.1 分层:保持基础层和元层分离

Firefox 曾经支持一种有限形式的介入式元编程:如果一个对象 O 有一个名为 __noSuchMethod__ 的方法,那么每当在 O 上调用一个不存在的方法时,它都会收到通知。以下代码演示了它是如何工作的

const calc = {
  __noSuchMethod__: function (methodName, args) {
    switch (methodName) {
      case 'plus':
        return args.reduce((a, b) => a + b);
      case 'times':
        return args.reduce((a, b) => a * b);
      default:
        throw new TypeError('Unsupported: ' + methodName);
    }
  }
};

// All of the following method calls are implemented via
// .__noSuchMethod__().
assert.equal(
  calc.plus(3, 5, 2), 10);
assert.equal(
  calc.times(2, 3, 4), 24);

assert.equal(
  calc.plus('Parts', ' of ', 'a', ' string'),
  'Parts of a string');

因此,__noSuchMethod__ 的工作方式类似于 Proxy 陷阱。与 Proxies 不同的是,陷阱是我们想要拦截其操作的对象的自有方法或继承方法。这种方法的问题在于基础级别(普通方法)和元级别(__noSuchMethod__)是混合的。基础级别代码可能会意外调用或看到元级别方法,并且有可能意外定义元级别方法。

即使在标准 ECMAScript 中,基础级别和元级别有时也会混合在一起。例如,以下元编程机制可能会失败,因为它们存在于基础级别

到目前为止,应该很明显,使(基础级别)属性键特殊化是有问题的。因此,Proxies 是分层的:基础级别(Proxy 对象)和元级别(处理程序对象)是分开的。

18.5.2 虚拟对象与包装器

Proxies 用于两种角色

Proxy API 的早期设计将 Proxies 视为纯粹的虚拟对象。然而,事实证明,即使在该角色中,目标也是有用的,可以强制执行不变量(稍后解释)并作为处理程序未实现的陷阱的回退。

18.5.3 透明虚拟化和处理程序封装

Proxies 以两种方式屏蔽

这两个原则赋予 Proxies 相当大的能力来模拟其他对象。强制执行不变量(稍后解释)的原因之一是控制这种能力。

如果我们确实需要一种方法来区分 Proxies 和非 Proxies,我们必须自己实现它。以下代码是一个模块 lib.mjs,它导出两个函数:一个函数创建 Proxies,另一个函数确定对象是否是这些 Proxies 之一。

// lib.mjs
const proxies = new WeakSet();

export function createProxy(obj) {
  const handler = {};
  const proxy = new Proxy(obj, handler);
  proxies.add(proxy);
  return proxy;
}

export function isProxy(obj) {
  return proxies.has(obj);
}

此模块使用数据结构 WeakSet 来跟踪 Proxies。WeakSet 非常适合此目的,因为它不会阻止其元素被垃圾回收。

下一个示例显示了如何使用 lib.mjs

// main.mjs
import { createProxy, isProxy } from './lib.mjs';

const proxy = createProxy({});
assert.equal(isProxy(proxy), true);
assert.equal(isProxy({}), false);

18.5.4 元对象协议和 Proxy 陷阱

在本节中,我们将研究 JavaScript 的内部结构以及如何选择 Proxy 陷阱集。

在编程语言和 API 设计的上下文中,协议是一组接口以及使用它们的规则。ECMAScript 规范描述了如何执行 JavaScript 代码。它包括一个处理对象的协议。此协议在元级别运行,有时称为元对象协议 (MOP)。JavaScript MOP 由所有对象都具有的自身内部方法组成。“内部”意味着它们仅存在于规范中(JavaScript 引擎可能具有也可能不具有它们)并且无法从 JavaScript 访问。内部方法的名称用双括号括起来。

用于获取属性的内部方法称为.[[Get]]()。如果我们使用双下划线而不是双括号,则此方法在 JavaScript 中将大致实现如下。

// Method definition
__Get__(propKey, receiver) {
  const desc = this.__GetOwnProperty__(propKey);
  if (desc === undefined) {
    const parent = this.__GetPrototypeOf__();
    if (parent === null) return undefined;
    return parent.__Get__(propKey, receiver); // (A)
  }
  if ('value' in desc) {
    return desc.value;
  }
  const getter = desc.get;
  if (getter === undefined) return undefined;
  return getter.__Call__(receiver, []);
}

此代码中调用的 MOP 方法是

在 A 行,我们可以看到为什么原型链中的 Proxies 在“早期”对象中找不到属性时会找出 get:如果没有自己的键为 propKey 的属性,则搜索将继续在 this 的原型 parent 中进行。

基本操作与派生操作。我们可以看到 .[[Get]]() 调用了其他 MOP 操作。执行此操作的操作称为派生操作。不依赖于其他操作的操作称为基本操作。

18.5.4.1 Proxies 的元对象协议

Proxies 的元对象协议与普通对象的元对象协议不同。对于普通对象,派生操作会调用其他操作。对于 Proxies,每个操作(无论它是基本操作还是派生操作)要么被处理程序方法拦截,要么转发到目标。

哪些操作应该可以通过 Proxies 进行拦截?

后者的优点是它提高了性能并且更方便。例如,如果没有 get 的陷阱,我们必须通过 getOwnPropertyDescriptor 实现其功能。

包含派生陷阱的一个缺点是,这会导致 Proxies 的行为不一致。例如,get 返回的值可能与 getOwnPropertyDescriptor 返回的描述符中的值不同。

18.5.4.2 选择性拦截:哪些操作应该可以拦截?

Proxies 的拦截是选择性的:我们不能拦截所有语言操作。为什么排除了一些操作?让我们看看两个原因。

首先,稳定操作不适合拦截。如果一个操作对于相同的参数总是产生相同的结果,那么它就是稳定的。如果 Proxy 可以捕获稳定操作,则它可能会变得不稳定,从而变得不可靠。严格相等 (===) 就是这样一种稳定操作。它不能被捕获,并且它的结果是通过将 Proxy 本身视为另一个对象来计算的。保持稳定性的另一种方法是对目标而不是 Proxy 应用操作。如后文所述,当我们查看如何对 Proxies 强制执行不变量时,当 Object.getPrototypeOf() 应用于目标不可扩展的 Proxy 时,就会发生这种情况。

不使更多操作可拦截的第二个原因是,拦截意味着在通常不可能的情况下执行自定义代码。这种代码交织发生的越多,程序就越难理解和调试。它也会对性能产生负面影响。

18.5.4.3 陷阱:getinvoke

如果我们想通过 Proxies 创建虚拟方法,我们必须从 get 陷阱返回函数。这就提出了一个问题:为什么不为方法调用引入一个额外的陷阱(例如 invoke)?这将使我们能够区分

不这样做有两个原因。

首先,并非所有实现都区分 getinvoke。例如,Apple 的 JavaScriptCore 就没有

其次,提取一个方法并稍后通过 .call().apply() 调用它应该与通过调度调用该方法具有相同的效果。换句话说,以下两种变体应该等效地工作。如果有一个额外的陷阱 invoke,那么这种等价性将更难维护。

// Variant 1: call via dynamic dispatch
const result1 = obj.m();

// Variant 2: extract and call directly
const m = obj.m;
const result2 = m.call(obj);
18.5.4.3.1 invoke 的用例

有些事情只有在我们能够区分 getinvoke 时才能完成。因此,使用当前的 Proxy API 无法做到这些事情。两个例子是:自动绑定和拦截丢失的方法。让我们看看如果 Proxies 支持 invoke,人们将如何实现它们。

自动绑定。通过使 Proxy 成为对象 obj 的原型,我们可以自动绑定方法

自动绑定有助于将方法用作回调。例如,上一个示例中的变体 2 变得更简单

const boundMethod = obj.m;
const result = boundMethod();

拦截丢失的方法。 invoke 允许 Proxy 模拟前面提到的 __noSuchMethod__ 机制。Proxy 将再次成为对象 obj 的原型。它会根据访问未知属性 prop 的方式做出不同的反应

18.5.5 对 Proxies 强制执行不变量

在我们查看不变量是什么以及如何对 Proxies 强制执行它们之前,让我们回顾一下如何通过不可扩展性和不可配置性来保护对象。

18.5.5.1 保护对象

有两种保护对象的方法

有关此主题的更多信息,请参阅§10“保护对象不被更改”

18.5.5.2 强制执行不变量

传统上,不可扩展性和不可配置性是

这些和其他在面对语言操作时保持不变的特性称为不变量。通过 Proxies 很容易违反不变量,因为它们本质上不受不可扩展性等的约束。Proxy API 通过检查目标对象和处理程序方法的结果来防止这种情况发生。

接下来的两个小节描述了四个不变量。本章末尾给出了不变量的完整列表。

18.5.5.3 通过目标对象强制执行的两个不变量

以下两个不变量涉及不可扩展性和不可配置性。这些是通过使用目标对象进行簿记来强制执行的:处理程序方法返回的结果必须与目标对象基本同步。

18.5.5.4 通过检查返回值强制执行的两个不变量

以下两个不变量是通过检查返回值来强制执行的

18.5.5.5 不变量的好处

强制执行不变量具有以下好处

接下来的两节将给出强制执行不变量的示例。

18.5.5.6 示例:不可扩展目标的原型必须如实表示

响应 getPrototypeOf 陷阱时,如果目标不可扩展,则代理必须返回目标的原型。

为了演示此不变量,让我们创建一个处理程序,该处理程序返回与目标原型不同的原型

const fakeProto = {};
const handler = {
  getPrototypeOf(t) {
    return fakeProto;
  }
};

如果目标是可扩展的,则伪造原型有效

const extensibleTarget = {};
const extProxy = new Proxy(extensibleTarget, handler);

assert.equal(
  Object.getPrototypeOf(extProxy), fakeProto);

但是,如果我们为不可扩展的对象伪造原型,则会收到错误消息。

const nonExtensibleTarget = {};
Object.preventExtensions(nonExtensibleTarget);
const nonExtProxy = new Proxy(nonExtensibleTarget, handler);

assert.throws(
  () => Object.getPrototypeOf(nonExtProxy),
  {
    name: 'TypeError',
    message: "'getPrototypeOf' on proxy: proxy target is"
      + " non-extensible but the trap did not return its"
      + " actual prototype",
  });
18.5.5.7 示例:不可写、不可配置的目标属性必须如实表示

如果目标具有不可写、不可配置的属性,则处理程序必须响应 get 陷阱返回该属性的值。为了演示此不变量,让我们创建一个处理程序,该处理程序始终为属性返回相同的值。

const handler = {
  get(target, propKey) {
    return 'abc';
  }
};
const target = Object.defineProperties(
  {}, {
    manufacturer: {
      value: 'Iso Autoveicoli',
      writable: true,
      configurable: true
    },
    model: {
      value: 'Isetta',
      writable: false,
      configurable: false
    },
  });
const proxy = new Proxy(target, handler);

属性 target.manufacturer 不是不可写且不可配置的,这意味着允许处理程序假装它具有不同的值

assert.equal(
  proxy.manufacturer, 'abc');

但是,属性 target.model 是不可写且不可配置的。因此,我们无法伪造它的值

assert.throws(
  () => proxy.model,
  {
    name: 'TypeError',
    message: "'get' on proxy: property 'model' is a read-only and"
      + " non-configurable data property on the proxy target but"
      + " the proxy did not return its actual value (expected"
      + " 'Isetta' but got 'abc')",
  });

18.6 常见问题解答:代理

18.6.1 enumerate 陷阱在哪里?

ECMAScript 6 最初有一个由 for-in 循环触发的陷阱 enumerate。但最近为了简化代理而将其删除。Reflect.enumerate() 也被删除了。(来源:TC39 笔记

18.7 参考:代理 API

本节是代理 API 的快速参考

该参考使用以下自定义类型

type PropertyKey = string | symbol;

18.7.1 创建代理

创建代理有两种方法

18.7.2 处理程序方法

本小节解释了处理程序可以实现哪些陷阱以及哪些操作会触发它们。一些陷阱会返回布尔值。对于陷阱 hasisExtensible,布尔值是操作的结果。对于所有其他陷阱,布尔值指示操作是否成功。

所有对象的陷阱

函数的陷阱(如果目标是函数,则可用)

18.7.2.1 基本操作与派生操作

以下操作是*基本的*,它们不使用其他操作来完成其工作:applydefinePropertydeletePropertygetOwnPropertyDescriptorgetPrototypeOfisExtensibleownKeyspreventExtensionssetPrototypeOf

所有其他操作都是*派生的*,它们可以通过基本操作来实现。例如,get 可以通过 getPrototypeOf 迭代原型链并为每个链成员调用 getOwnPropertyDescriptor 来实现,直到找到自己的属性或链结束。

18.7.3 处理程序方法的不变量

不变量是处理程序的安全约束。本小节记录了代理 API 强制执行的不变量以及如何执行。每当我们在下面读到“处理程序必须执行 X”时,这意味着如果不这样做,就会抛出 TypeError。一些不变量限制返回值,而另一些不变量限制参数。陷阱返回值的正确性通过两种方式确保

这是强制执行的不变量的完整列表

  ECMAScript 规范中的不变量

在规范中,不变量列在“代理对象内部方法和内部插槽”一节中。

18.7.4 影响原型链的操作

普通对象的以下操作对原型链中的对象执行操作。因此,如果该链中的某个对象是代理,则会触发其陷阱。规范将操作实现为内部自有方法(JavaScript 代码不可见)。但在本节中,我们假设它们是与陷阱同名的普通方法。参数 target 成为方法调用的接收者。

所有其他操作仅影响自有属性,对原型链没有影响。

  ECMAScript 规范中的内部操作

在规范中,这些操作(和其他操作)在“普通对象内部方法和内部插槽”一节中进行了描述。

18.7.5 Reflect

全局对象 Reflect 将 JavaScript 元对象协议的所有可拦截操作实现为方法。这些方法的名称与处理程序方法的名称相同,正如我们所见,这有助于将操作从处理程序转发到目标。

一些方法返回布尔值。对于 .has().isExtensible(),它们是操作的结果。对于其余方法,它们指示操作是否成功。

18.7.5.1 除了转发之外的 Reflect 用例

除了转发操作之外,为什么 Reflect 很有用 [4]

18.7.5.2 Object.*Reflect.*

展望未来,Object 将托管对普通应用程序感兴趣的操作,而 Reflect 将托管更底层的操作。

18.8 总结

至此,我们对 Proxy API 的深入研究就结束了。需要注意的一件事是,代理会降低代码的速度。如果性能至关重要,这可能很重要。

另一方面,性能通常并不重要,并且拥有代理提供的元编程能力是很好的。


致谢

18.9 延伸阅读