2022 年 6 月 26 日

代理和反射

一个 Proxy 对象包装另一个对象并拦截操作,例如读取/写入属性和其他操作,可以选择自行处理它们,或透明地允许该对象处理它们。

代理在许多库和一些浏览器框架中使用。我们将在本文中看到许多实际应用。

代理

语法

let proxy = new Proxy(target, handler)
  • target – 是要包装的对象,可以是任何东西,包括函数。
  • handler – 代理配置:一个包含“陷阱”的对象,这些方法会拦截操作。– 例如,get 陷阱用于读取 target 的属性,set 陷阱用于将属性写入 target,等等。

对于 proxy 上的操作,如果 handler 中存在相应的陷阱,则它会运行,并且代理有机会处理它,否则操作将在 target 上执行。

作为入门示例,让我们创建一个没有任何陷阱的代理

let target = {};
let proxy = new Proxy(target, {}); // empty handler

proxy.test = 5; // writing to proxy (1)
alert(target.test); // 5, the property appeared in target!

alert(proxy.test); // 5, we can read it from proxy too (2)

for(let key in proxy) alert(key); // test, iteration works (3)

由于没有陷阱,proxy 上的所有操作都将转发到 target

  1. 写入操作 proxy.test= 将值设置在 target 上。
  2. 读取操作 proxy.test 返回 target 中的值。
  3. 遍历 proxy 返回 target 中的值。

正如我们所见,在没有陷阱的情况下,proxytarget 的透明包装器。

Proxy 是一种特殊的“奇异对象”。它没有自己的属性。使用空的 handler,它会透明地将操作转发到 target

为了激活更多功能,让我们添加陷阱。

我们可以用它们拦截什么?

对于对象上的大多数操作,在 JavaScript 规范中都有一个所谓的“内部方法”,它描述了在最低级别是如何工作的。例如 [[Get]],读取属性的内部方法,[[Set]],写入属性的内部方法,等等。这些方法只在规范中使用,我们不能直接按名称调用它们。

Proxy 陷阱拦截了对这些方法的调用。它们列在 Proxy 规范 和下面的表格中。

对于每个内部方法,表格中都有一个陷阱:我们可以添加到 new Proxyhandler 参数中的方法名称,用于拦截操作。

内部方法 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.definePropertyObject.defineProperties
[[GetOwnProperty]] getOwnPropertyDescriptor Object.getOwnPropertyDescriptorfor..inObject.keys/values/entries
[[OwnPropertyKeys]] ownKeys Object.getOwnPropertyNamesObject.getOwnPropertySymbolsfor..inObject.keys/values/entries
不变式

JavaScript 强制执行一些不变式 - 内部方法和陷阱必须满足的条件。

大多数是针对返回值的

  • [[Set]] 如果值写入成功,则必须返回 true,否则返回 false
  • [[Delete]] 如果值成功删除,则必须返回 true,否则返回 false
  • …等等,我们将在下面的示例中看到更多。

还有一些其他的不变式,比如

  • [[GetPrototypeOf]],应用于代理对象必须返回与应用于代理对象目标对象的 [[GetPrototypeOf]] 相同的值。换句话说,读取代理的原型必须始终返回目标对象的原型。

陷阱可以拦截这些操作,但它们必须遵循这些规则。

不变式确保语言特性的正确和一致的行为。完整的不变式列表在 规范 中。如果你没有做奇怪的事情,你可能不会违反它们。

让我们看看它在实际示例中的工作原理。

使用“get”陷阱的默认值

最常见的陷阱是用于读取/写入属性。

要拦截读取,handler 应该有一个方法 get(target, property, receiver)

它在读取属性时触发,带有以下参数

  • target - 是目标对象,作为第一个参数传递给 new Proxy
  • property - 属性名称,
  • receiver - 如果目标属性是 getter,则 receiver 是将在其调用中用作 this 的对象。通常是 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; // default value
    }
  }
});

alert( numbers[1] ); // 1
alert( numbers[123] ); // 0 (no such item)

正如我们所看到的,使用 get 陷阱很容易做到。

我们可以使用 Proxy 为“默认”值实现任何逻辑。

假设我们有一个字典,包含短语及其翻译

let dictionary = {
  'Hello': 'Hola',
  'Bye': 'Adiós'
};

alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome'] ); // undefined

现在,如果没有短语,从 dictionary 读取将返回 undefined。但在实践中,将短语保留为未翻译通常比 undefined 更好。因此,让我们在那种情况下让它返回一个未翻译的短语,而不是 undefined

为了实现这一点,我们将 dictionary 包装在一个代理中,该代理会拦截读取操作

let dictionary = {
  'Hello': 'Hola',
  'Bye': 'Adiós'
};

dictionary = new Proxy(dictionary, {
  get(target, phrase) { // intercept reading a property from dictionary
    if (phrase in target) { // if we have it in the dictionary
      return target[phrase]; // return the translation
    } else {
      // otherwise, return the non-translated phrase
      return phrase;
    }
  }
});

// Look up arbitrary phrases in the dictionary!
// At worst, they're not translated.
alert( dictionary['Hello'] ); // Hola
alert( dictionary['Welcome to Proxy']); // Welcome to Proxy (no translation)
请注意

请注意代理如何覆盖变量

dictionary = new Proxy(dictionary, ...);

代理应该完全替换目标对象。在代理目标对象后,任何人都不能再引用目标对象。否则很容易弄乱。

使用“set”陷阱进行验证

假设我们想要一个专门用于数字的数组。如果添加了其他类型的值,应该出现错误。

当写入属性时,set 陷阱会触发。

set(target, property, value, receiver):

  • target - 是目标对象,作为第一个参数传递给 new Proxy
  • property - 属性名称,
  • value – 属性值,
  • receiver – 与 get 陷阱类似,仅对 setter 属性有效。

如果设置成功,set 陷阱应该返回 true,否则返回 false(触发 TypeError)。

让我们用它来验证新值

let numbers = [];

numbers = new Proxy(numbers, { // (*)
  set(target, prop, val) { // to intercept property writing
    if (typeof val == 'number') {
      target[prop] = val;
      return true;
    } else {
      return false;
    }
  }
});

numbers.push(1); // added successfully
numbers.push(2); // added successfully
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 属性会自动增加。我们的代理不会破坏任何东西。

我们不必重写像 pushunshift 这样的值添加数组方法,等等,在其中添加检查,因为在内部它们使用 [[Set]] 操作,该操作被代理拦截。

所以代码干净简洁。

不要忘记返回 true

如上所述,有一些不变式需要保持。

对于 set,它必须为成功写入返回 true

如果我们忘记这样做或返回任何假值,操作将触发 TypeError

使用“ownKeys”和“getOwnPropertyDescriptor”进行迭代

Object.keysfor..in 循环和大多数其他遍历对象属性的方法使用 [[OwnPropertyKeys]] 内部方法(被 ownKeys 陷阱拦截)来获取属性列表。

这些方法在细节上有所不同

  • Object.getOwnPropertyNames(obj) 返回非符号键。
  • Object.getOwnPropertySymbols(obj) 返回符号键。
  • Object.keys/values() 返回具有 enumerable 标志的非符号键/值(属性标志在文章 属性标志和描述符 中解释)。
  • for..in 循环遍历具有 enumerable 标志的非符号键,以及原型键。

…但它们都从该列表开始。

在下面的示例中,我们使用 ownKeys 陷阱来使 for..in 循环遍历 user,以及 Object.keysObject.values,以跳过以下划线 _ 开头的属性

let user = {
  name: "John",
  age: 30,
  _password: "***"
};

user = new Proxy(user, {
  ownKeys(target) {
    return Object.keys(target).filter(key => !key.startsWith('_'));
  }
});

// "ownKeys" filters out _password
for(let key in user) alert(key); // name, then: age

// same effect on these methods:
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) ); // <empty>

为什么?原因很简单:Object.keys 只返回具有 enumerable 标志的属性。为了检查它,它会为每个属性调用内部方法 [[GetOwnProperty]] 来获取 它的描述符。在这里,由于没有属性,它的描述符为空,没有 enumerable 标志,因此它被跳过。

为了让Object.keys返回一个属性,我们需要它要么存在于对象中,并且具有enumerable标志,要么我们可以拦截对[[GetOwnProperty]]的调用(getOwnPropertyDescriptor陷阱会这样做),并返回一个具有enumerable: true的描述符。

以下是一个示例

let user = { };

user = new Proxy(user, {
  ownKeys(target) { // called once to get a list of properties
    return ['a', 'b', 'c'];
  },

  getOwnPropertyDescriptor(target, prop) { // called for every property
    return {
      enumerable: true,
      configurable: true
      /* ...other flags, probable "value:..." */
    };
  }

});

alert( Object.keys(user) ); // a, b, c

再次注意:只有当属性不存在于对象中时,我们才需要拦截[[GetOwnProperty]]

使用“deleteProperty”和其他陷阱保护属性

有一个普遍的约定,即以下划线_为前缀的属性和方法是内部的。不应该从对象外部访问它们。

虽然从技术上讲这是可能的

let user = {
  name: "John",
  _password: "secret"
};

alert(user._password); // secret

让我们使用代理来阻止对以_开头的属性的任何访问。

我们需要以下陷阱

  • get在读取此类属性时抛出错误,
  • set在写入时抛出错误,
  • deleteProperty在删除时抛出错误,
  • ownKeysfor..inObject.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) { // to intercept property writing
    if (prop.startsWith('_')) {
      throw new Error("Access denied");
    } else {
      target[prop] = val;
      return true;
    }
  },
  deleteProperty(target, prop) { // to intercept property deletion
    if (prop.startsWith('_')) {
      throw new Error("Access denied");
    } else {
      delete target[prop];
      return true;
    }
  },
  ownKeys(target) { // to intercept property list
    return Object.keys(target).filter(key => !key.startsWith('_'));
  }
});

// "get" doesn't allow to read _password
try {
  alert(user._password); // Error: Access denied
} catch(e) { alert(e.message); }

// "set" doesn't allow to write _password
try {
  user._password = "test"; // Error: Access denied
} catch(e) { alert(e.message); }

// "deleteProperty" doesn't allow to delete _password
try {
  delete user._password; // Error: Access denied
} catch(e) { alert(e.message); }

// "ownKeys" filters out _password
for(let key in user) alert(key); // name

请注意get陷阱中(*)行中的重要细节

get(target, prop) {
  // ...
  let value = target[prop];
  return (typeof value === 'function') ? value.bind(target) : value; // (*)
}

为什么我们需要一个函数来调用value.bind(target)

原因是对象方法,例如user.checkPassword(),必须能够访问_password

user = {
  // ...
  checkPassword(value) {
    // object method must be able to read _password
    return value === this._password;
  }
}

user.checkPassword()的调用将代理user作为this(点之前的对象变为this),因此当它尝试访问this._password时,get陷阱会激活(它会在任何属性读取时触发)并抛出错误。

因此,我们在(*)行中将对象方法的上下文绑定到原始对象target。然后它们将来的调用将使用target作为this,而不会有任何陷阱。

这种解决方案通常有效,但并不理想,因为方法可能会将未代理的对象传递到其他地方,然后我们会变得混乱:原始对象在哪里,代理对象在哪里?

此外,一个对象可能会被多次代理(多个代理可能会对对象添加不同的“调整”),如果我们将未包装的对象传递给方法,可能会出现意外后果。

因此,这样的代理不应该在任何地方使用。

类的私有属性

现代 JavaScript 引擎原生支持类中的私有属性,以 # 为前缀。它们在文章 私有和受保护的属性和方法 中有描述。不需要代理。

但是,此类属性也存在一些问题。特别是,它们不会被继承。

“在范围内”与“has”陷阱

让我们看更多例子。

我们有一个范围对象

let range = {
  start: 1,
  end: 10
};

我们想使用 in 运算符来检查一个数字是否在 range 中。

has 陷阱拦截 in 调用。

has(target, property)

  • target – 是目标对象,作为第一个参数传递给 new Proxy
  • property – 属性名称

这是演示

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 中是一个对象),
  • thisArgthis 的值。
  • args 是一个参数列表。

例如,让我们回顾一下我们在文章 装饰器和转发,call/apply 中做的 delay(f, ms) 装饰器。

在那篇文章中,我们没有使用代理来实现它。对 delay(f, ms) 的调用返回一个函数,该函数在 ms 毫秒后将所有调用转发到 f

这是之前的基于函数的实现

function delay(f, ms) {
  // return a wrapper that passes the call to f after the timeout
  return function() { // (*)
    setTimeout(() => f.apply(this, arguments), ms);
  };
}

function sayHi(user) {
  alert(`Hello, ${user}!`);
}

// after this wrapping, calls to sayHi will be delayed for 3 seconds
sayHi = delay(sayHi, 3000);

sayHi("John"); // Hello, John! (after 3 seconds)

正如我们已经看到的那样,这在大多数情况下都能正常工作。包装函数 (*) 在超时后执行调用。

但是包装函数不会转发属性读写操作或其他任何操作。包装后,对原始函数属性的访问将丢失,例如 namelength 等。

function delay(f, ms) {
  return function() {
    setTimeout(() => f.apply(this, arguments), ms);
  };
}

function sayHi(user) {
  alert(`Hello, ${user}!`);
}

alert(sayHi.length); // 1 (function length is the arguments count in its declaration)

sayHi = delay(sayHi, 3000);

alert(sayHi.length); // 0 (in the wrapper declaration, there are zero arguments)

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 (*) proxy forwards "get length" operation to the target

sayHi("John"); // Hello, John! (after 3 seconds)

结果是一样的,但现在不仅是调用,代理上的所有操作都将转发到原始函数。因此,在 (*) 行中包装后,sayHi.length 会正确返回。

我们得到了一个“更丰富”的包装器。

还存在其他陷阱:完整的列表在本文开头。它们的用法模式与上面类似。

Reflect

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); // John

特别是,Reflect 允许我们将运算符(newdelete 等)作为函数调用(Reflect.constructReflect.deleteProperty 等)。这是一个有趣的功能,但还有一点很重要。

对于每个可以被 Proxy 拦截的内部方法,Reflect 中都有一个对应的方法,其名称和参数与 Proxy 拦截器相同。

因此,我们可以使用 Reflect 将操作转发到原始对象。

在这个例子中,getset 拦截器都透明地(就像它们不存在一样)将读写操作转发到对象,并显示一条消息

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; // shows "GET name"
user.name = "Pete"; // shows "SET name=Pete"

这里

  • Reflect.get 读取对象属性。
  • Reflect.set 写入对象属性,如果成功则返回 true,否则返回 false

也就是说,一切都非常简单:如果拦截器想要将调用转发到对象,只需使用相同的参数调用 Reflect.<method> 即可。

在大多数情况下,我们可以在没有 Reflect 的情况下执行相同的操作,例如,读取属性 Reflect.get(target, prop, receiver) 可以用 target[prop] 替换。但是,有一些重要的细微差别。

代理 getter

让我们看一个例子,它演示了为什么 Reflect.get 更好。我们还将看到为什么 get/set 有第三个参数 receiver,我们之前没有使用它。

我们有一个对象 user,它具有 _name 属性和一个 getter。

这是一个围绕它的代理

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 拦截器在这里是“透明的”,它返回原始属性,不做任何其他操作。这对于我们的例子来说已经足够了。

一切似乎都很好。但是,让我们使示例稍微复杂一些。

user 继承另一个对象 admin 后,我们可以观察到不正确的行为

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"
};

// Expected: Admin
alert(admin.name); // outputs: Guest (?!?)

读取 admin.name 应该返回 "Admin",而不是 "Guest"

出了什么问题?也许我们在继承方面做错了什么?

但是,如果我们删除代理,那么一切将按预期工作。

问题实际上出在代理中,在代码行(*)处。

  1. 当我们读取admin.name时,由于admin对象没有该属性,所以会去它的原型链上查找。

  2. 原型是userProxy

  3. 当从代理中读取name属性时,它的get陷阱会被触发,并从原始对象中返回target[prop],如代码行(*)所示。

    prop是一个 getter 时,调用target[prop]会以this=target的上下文执行它的代码。因此,结果是来自原始对象targetthis._name,也就是来自user

为了解决这种情况,我们需要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...提供了一种安全且无需考虑的解决方案,可以转发操作并确保我们不会遗漏任何相关内容。

代理的局限性

代理提供了一种独特的方式,可以在最低级别更改或调整现有对象的行为。然而,它并不完美,也有一些局限性。

内置对象:内部槽

许多内置对象,例如MapSetDatePromise等,都使用所谓的“内部槽”。

它们类似于属性,但保留用于内部、规范特定的目的。例如,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 (works!)

现在它可以正常工作了,因为 get 陷阱将函数属性(例如 map.set)绑定到目标对象 (map) 本身。

与前面的示例不同,proxy.set(...) 内部 this 的值将不是 proxy,而是原始的 map。因此,当 set 的内部实现尝试访问 this.[[MapData]] 内部槽时,它会成功。

Array 没有内部槽

一个值得注意的例外:内置 Array 不使用内部槽。这是出于历史原因,因为它出现的时间太久了。

因此,在代理数组时不存在此类问题。

私有字段

私有类字段也会发生类似的事情。

例如,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

也就是说,该解决方案有缺点,如前所述:它将原始对象暴露给方法,可能允许它被进一步传递并破坏其他代理功能。

代理 != 目标

代理和原始对象是不同的对象。这是自然的,对吧?

因此,如果我们使用原始对象作为键,然后代理它,那么代理将找不到

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)等等。

但是,无法拦截对象的严格相等测试。一个对象仅与其自身严格相等,而与其他任何值都不相等。

因此,所有比较对象是否相等的运算符和内置类都将区分对象和代理。这里没有透明的替换。

可撤销代理

可撤销代理是可以禁用的代理。

假设我们有一个资源,并且希望随时关闭对它的访问。

我们可以做的是将其包装在一个可撤销的代理中,没有任何陷阱。这样的代理将把操作转发到对象,并且我们可以随时禁用它。

语法是

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

该调用返回一个包含 proxyrevoke 函数的对象,用于禁用它。

以下是一个示例

let object = {
  data: "Valuable data"
};

let {proxy, revoke} = Proxy.revocable(object, {});

// pass the proxy somewhere instead of object...
alert(proxy.data); // Valuable data

// later in our code
revoke();

// the proxy isn't working any more (revoked)
alert(proxy.data); // Error

调用revoke()会从代理中移除对目标对象的所有内部引用,因此它们不再连接。

最初,revokeproxy是分开的,这样我们就可以在当前作用域中保留revoke的同时传递proxy

我们也可以通过设置proxy.revoke = revokerevoke方法绑定到代理。

另一种选择是创建一个WeakMap,它以proxy作为键,相应的revoke作为值,这样可以方便地找到代理的revoke

let revokes = new WeakMap();

let object = {
  data: "Valuable data"
};

let {proxy, revoke} = Proxy.revocable(object, {});

revokes.set(proxy, revoke);

// ..somewhere else in our code..
revoke = revokes.get(proxy);
revoke();

alert(proxy.data); // Error (revoked)

我们在这里使用WeakMap而不是Map,因为它不会阻止垃圾回收。如果代理对象变得“不可达”(例如,不再有变量引用它),WeakMap允许它与我们不再需要的revoke一起从内存中清除。

参考资料

总结

Proxy 是一个对象包装器,它将对它的操作转发到对象,并可以选择性地拦截其中一些操作。

它可以包装任何类型的对象,包括类和函数。

语法是

let proxy = new Proxy(target, {
  /* traps */
});

…然后我们应该在任何地方使用proxy而不是target。代理没有自己的属性或方法。如果提供了陷阱,它会拦截操作,否则会将其转发到target对象。

我们可以拦截

  • 读取(get)、写入(set)、删除(deleteProperty)属性(即使是非存在的属性)。
  • 调用函数(apply 陷阱)。
  • new 运算符(construct 陷阱)。
  • 许多其他操作(完整列表在文章开头和文档中)。

这使我们能够创建“虚拟”属性和方法,实现默认值、可观察对象、函数装饰器等等。

我们还可以用不同的代理多次包装一个对象,用各种功能方面装饰它。

Reflect API 旨在补充Proxy。对于任何Proxy 陷阱,都有一个具有相同参数的Reflect 调用。我们应该使用它们将调用转发到目标对象。

代理有一些限制

  • 内置对象具有“内部槽”,对它们的访问无法被代理。请参阅上面的解决方法。
  • 私有类字段也是如此,因为它们在内部使用槽实现。因此,代理方法调用必须将目标对象作为this来访问它们。
  • 对象相等性测试 === 无法被拦截。
  • 性能:基准测试取决于引擎,但通常使用最简单的代理访问属性要慢几倍。在实践中,这只会影响一些“瓶颈”对象。

任务

通常,尝试读取不存在的属性会返回 undefined

创建一个代理,在尝试读取不存在的属性时抛出错误。

这有助于尽早发现编程错误。

编写一个函数 wrap(target),它接受一个对象 target 并返回一个代理,该代理添加了此功能方面。

这就是它的工作原理

let user = {
  name: "John"
};

function wrap(target) {
  return new Proxy(target, {
      /* your code */
  });
}

user = wrap(user);

alert(user.name); // John
alert(user.age); // ReferenceError: Property doesn't exist: "age"
let user = {
  name: "John"
};

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}"`)
      }
    }
  });
}

user = wrap(user);

alert(user.name); // John
alert(user.age); // ReferenceError: Property doesn't exist: "age"

在一些编程语言中,我们可以使用负索引访问数组元素,从末尾开始计数。

像这样

let array = [1, 2, 3];

array[-1]; // 3, the last element
array[-2]; // 2, one step from the end
array[-3]; // 1, two steps from the end

换句话说,array[-N] 等同于 array[array.length - N]

创建一个代理来实现这种行为。

这就是它的工作原理

let array = [1, 2, 3];

array = new Proxy(array, {
  /* your code */
});

alert( array[-1] ); // 3
alert( array[-2] ); // 2

// Other array functionality should be kept "as is"
let array = [1, 2, 3];

array = new Proxy(array, {
  get(target, prop, receiver) {
    if (prop < 0) {
      // even if we access it like arr[1]
      // prop is a string, so need to convert it to number
      prop = +prop + target.length;
    }
    return Reflect.get(target, prop, receiver);
  }
});


alert(array[-1]); // 3
alert(array[-2]); // 2

创建一个函数 makeObservable(target),通过返回一个代理来“使对象可观察”。

以下是它的工作原理

function makeObservable(target) {
  /* your code */
}

let user = {};
user = makeObservable(user);

user.observe((key, value) => {
  alert(`SET ${key}=${value}`);
});

user.name = "John"; // alerts: SET name=John

换句话说,由 makeObservable 返回的对象就像原始对象一样,但还具有 observe(handler) 方法,该方法将 handler 函数设置为在任何属性更改时调用。

每当属性更改时,都会调用 handler(key, value),其中包含属性的名称和值。

附注:在这个任务中,请只关注写入属性。其他操作可以以类似的方式实现。

解决方案由两部分组成

  1. 每当调用 .observe(handler) 时,我们需要在某个地方记住处理程序,以便以后能够调用它。我们可以使用我们的符号作为属性键,将处理程序存储在对象中。
  2. 我们需要一个带有 set 陷阱的代理,以便在发生任何更改时调用处理程序。
let handlers = Symbol('handlers');

function makeObservable(target) {
  // 1. Initialize handlers store
  target[handlers] = [];

  // Store the handler function in array for future calls
  target.observe = function(handler) {
    this[handlers].push(handler);
  };

  // 2. Create a proxy to handle changes
  return new Proxy(target, {
    set(target, property, value, receiver) {
      let success = Reflect.set(...arguments); // forward the operation to object
      if (success) { // if there were no error while setting the property
        // call all handlers
        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";
教程地图

评论

在评论之前请阅读…
  • 如果您有改进建议,请提交 GitHub 问题或拉取请求,而不是评论。
  • 如果您不理解文章中的某些内容,请详细说明。
  • 要插入几行代码,请使用<code>标签,对于多行代码,请将其包装在<pre>标签中,对于超过 10 行的代码,请使用沙箱(plnkrjsbincodepen…)