深入 JavaScript:理论和技术(下)

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 深入 JavaScript:理论和技术(下)

十一、属性:赋值 vs. 定义



  • 11.1 赋值 vs. 定义
  • 11.1.1 赋值
  • 11.1.2 定义
  • 11.2 在理论中的赋值和定义(可选)
  • 11.2.1 分配给属性
  • 11.2.2 定义属性
  • 11.3 实践中的定义和赋值
  • 11.3.1 只有定义允许我们创建具有任意属性的属性
  • 11.3.2 赋值操作符不会更改原型中的属性
  • 11.3.3 赋值调用 setter,定义不会
  • 11.3.4 继承的只读属性阻止通过赋值创建自有属性
  • 11.4 哪些语言结构使用定义,哪些使用赋值?
  • 11.4.1 对象文字的属性是通过定义添加的
  • 11.4.2 赋值操作符=总是使用赋值
  • 11.4.3 公共类字段是通过定义添加的
  • 11.5 进一步阅读和本章的来源

有两种方法可以创建或更改对象obj的属性prop

  • 赋值obj.prop = true
  • 定义Object.defineProperty(obj, '', {value: true})

本章解释了它们的工作原理。

必需知识:属性属性和属性描述符

对于本章,您应该熟悉属性属性和属性描述符。如果您不熟悉,请查看§9“属性属性:介绍”。

11.1 赋值 vs. 定义

11.1.1 赋值

我们使用赋值操作符=将值value分配给对象obj的属性.prop

obj.prop = value

这个操作符的工作方式取决于.prop的外观:

  • 更改属性:如果存在自有数据属性.prop,赋值会将其值更改为value
  • 调用 setter:如果存在自有或继承的 setter.prop,赋值会调用该 setter。
  • 创建属性:如果没有自有数据属性.prop,也没有自有或继承的 setter,赋值会创建一个新的自有数据属性。

也就是说,赋值的主要目的是进行更改。这就是为什么它支持 setter。

11.1.2 定义

要定义对象obj的键propKey的属性,我们使用以下方法的操作:

Object.defineProperty(obj, propKey, propDesc)

这种方法的工作方式取决于属性的外观:

  • 更改属性:如果存在具有键propKey的自有属性,则定义将根据属性描述符propDesc(如果可能)更改其属性。
  • 创建属性:否则,定义将创建一个具有propDesc指定属性的自有属性(如果可能)。

也就是说,定义的主要目的是创建一个自有属性(即使存在继承的 setter,它也会忽略)并改变属性的属性。

11.2 理论上的赋值和定义(可选)

ECMAScript 规范中的属性描述符

在规范操作中,属性描述符不是 JavaScript 对象,而是Records,这是一个规范内部的数据结构,具有fields。字段的键用双括号括起来。例如,Desc.[[Configurable]]访问Desc.[[Configurable]]字段。这些记录在与外部世界交互时会被转换为 JavaScript 对象。

11.2.1 分配属性

通过ECMAScript 规范中的以下操作来处理属性的赋值工作:

OrdinarySetWithOwnDescriptor(O, P, V, Receiver, ownDesc)

这些是参数:

  • O是当前正在访问的对象。
  • P是我们正在赋值的属性的键。
  • V是我们正在赋值的值。
  • Receiver是赋值开始的对象。
  • ownDescO[P]的描述符,如果该属性不存在则为null

返回值是一个布尔值,指示操作是否成功。如本章后面所述,严格模式赋值如果OrdinarySetWithOwnDescriptor()失败会抛出TypeError

这是算法的高级摘要:

  • 它遍历Receiver的原型链,直到找到键为P的属性。遍历是通过递归调用OrdinarySetWithOwnDescriptor()来完成的。在递归过程中,O会改变并指向当前正在访问的对象,但Receiver保持不变。
  • 根据遍历的结果,在Receiver(递归开始的地方)中创建一个自有属性,或者发生其他事情。

更详细地说,这个算法的工作方式如下:

  • 如果ownDescundefined,那么我们还没有找到一个带有键P的属性:
  • 如果O有一个原型parent,那么我们返回parent.[[Set]](P, V, Receiver)。这将继续我们的搜索。该方法调用通常最终会递归调用OrdinarySetWithOwnDescriptor()
  • 否则,我们对P的搜索失败了,并将ownDesc设置如下:
{
  [[Value]]: undefined, [[Writable]]: true,
  [[Enumerable]]: true, [[Configurable]]: true
}
  • 有了这个ownDesc,下一个if语句将在Receiver中创建一个自有属性。
  • 如果ownDesc指定了一个数据属性,那么我们已经找到了一个属性:
  • 如果ownDesc.[[Writable]]false,则返回false。这意味着任何不可写的属性P(自有的或继承的)都会阻止赋值。
  • existingDescriptorReceiver.[[GetOwnProperty]](P)。也就是说,检索赋值开始的属性的描述符。现在我们有:
  • 当前对象O和当前属性描述符ownDesc
  • 原始对象Receiver和另一方面的原始属性描述符existingDescriptor
  • 如果existingDescriptor不是undefined
  • (如果我们到了这里,那么我们仍然在原型链的开始处 - 只有在Receiver没有属性P时才会递归。)
  • 以下两个if条件永远不应该为true,因为ownDescexistingDesc应该相等:
  • 如果existingDescriptor指定了一个访问器,则返回false
  • 如果existingDescriptor.[[Writable]]false,则返回false
  • 返回Receiver.[[DefineOwnProperty]](P, { [[Value]]: V })。这个内部方法执行定义,我们用它来改变属性Receiver[P]的值。定义算法在下一小节中描述。
  • 否则:
  • (如果我们到了这里,那么Receiver没有一个带有键P的自有属性。)
  • 返回 CreateDataProperty(Receiver, P, V)。(此操作 在其第一个参数中创建自有数据属性。)
  • (如果我们到达这里,那么 ownDesc 描述的是自有或继承的访问器属性。)
  • setterownDesc.[[Set]]
  • 如果 setterundefined,返回 false
  • 执行 Call(setter, Receiver, «V»)Call() 调用函数对象 setter,并将 this 设置为 Receiver,单个参数 V(规范中使用法文引号 «» 表示列表)。
  • 返回 true
11.2.1.1 从赋值到 OrdinarySetWithOwnDescriptor() 的过程是如何进行的?

在不涉及解构的赋值中,涉及以下步骤:

  • 在规范中,评估从赋值表达式的运行时语义部分开始。该部分处理为匿名函数提供名称、解构等。
  • 如果没有解构模式,则使用 PutValue() 进行赋值。
  • 对于属性赋值,PutValue() 调用内部方法 .[[Set]]()
  • 对于普通对象,.[[Set]]() 调用 OrdinarySet()(调用 OrdinarySetWithOwnDescriptor())并返回结果。

值得注意的是,在严格模式下,如果 .[[Set]]() 的结果为 falsePutValue() 会抛出 TypeError

11.2.2 定义属性

定义属性的实际工作是通过 ECMAScript 规范中的以下操作处理的:

ValidateAndApplyPropertyDescriptor(O, P, extensible, Desc, current)

参数是:

  • 我们要定义属性的对象 O。这里有一个特殊的仅验证模式,其中 Oundefined。我们在这里忽略了这种模式。
  • 我们要定义的属性的属性键 P
  • extensible 表示 O 是否可扩展。
  • Desc 是指定属性所需的属性描述符。
  • 如果存在,则 current 包含自有属性 O[P] 的属性描述符。否则,currentundefined

操作的结果是一个布尔值,指示操作是否成功。失败可能会产生不同的后果。有些调用者会忽略结果。其他调用者,如 Object.defineProperty(),如果结果为 false,则会抛出异常。

这是算法的摘要:

  • 如果currentundefined,则属性P目前不存在,必须创建。
  • 如果 extensiblefalse,返回 false 表示无法添加属性。
  • 否则,检查 Desc 并创建数据属性或访问器属性。
  • 返回 true
  • 如果 Desc 没有任何字段,则返回 true,表示操作成功(因为不需要进行任何更改)。
  • 如果current.[[Configurable]]false
  • Desc 不允许改变除 value 之外的属性。)
  • 如果 Desc.[[Configurable]] 存在,则它必须与 current.[[Configurable]] 具有相同的值。如果不是,则返回 false
  • 相同的检查:Desc.[[Enumerable]]
  • 接下来,我们验证属性描述符Desccurrent描述的属性是否可以更改为Desc指定的值?如果不能,返回false。如果可以,继续。
  • 如果描述符是通用的(没有特定于数据属性或访问器属性的属性),则验证成功,我们可以继续。
  • 否则,如果一个描述符指定了数据属性,另一个指定了访问器属性:
  • 当前属性必须是可配置的(否则其属性无法按需更改)。如果不是,返回 false
  • 将当前属性从数据属性更改为访问器属性,反之亦然。在这样做时,.[[Configurable]].[[Enumerable]]的值被保留,所有其他属性获得默认值(对象值属性为undefined,布尔值属性为false)。
  • 否则,如果两个描述符都指定数据属性:
  • 如果current.[[Configurable]]current.[[Writable]]都为false,则不允许进行任何更改,Desccurrent必须指定相同的属性:
  • (由于current.[[Configurable]]falseDesc.[[Configurable]]Desc.[[Enumerable]]已经在先前检查过,并且具有正确的值。)
  • 如果Desc.[[Writable]]存在且为true,则返回false
  • 如果Desc.[[Value]]存在且与current.[[Value]]的值不同,则返回false
  • 没有其他事情可做。返回true表示算法成功。
  • (请注意,通常情况下,我们不能更改非可配置属性的任何属性,除了它的值。这个规则的一个例外是,我们总是可以从可写变为不可写。该算法正确处理了这个例外。)
  • 否则(两个描述符都指定访问器属性):
  • 如果current.[[Configurable]]false,则不允许进行任何更改,Desccurrent必须指定相同的属性:
  • (由于current.[[Configurable]]falseDesc.[[Configurable]]Desc.[[Enumerable]]已经在先前检查过,并且具有正确的值。)
  • 如果Desc.[[Set]]存在,则它必须与current.[[Set]]具有相同的值。如果不是,则返回false
  • 相同的检查:Desc.[[Get]]
  • 没有其他事情可做。返回true表示算法成功。
  • 将属性P的属性设置为Desc指定的值。由于验证,我们可以确保所有更改都是允许的。
  • 返回true

11.3 定义和实践中的赋值

本节描述了属性定义和赋值的一些后果。

11.3.1 只有定义允许我们创建具有任意属性的属性

如果我们通过赋值创建自有属性,它总是创建属性,其属性writableenumerableconfigurable都为true

const obj = {};
obj.dataProp = 'abc';
assert.deepEqual(
 Object.getOwnPropertyDescriptor(obj, 'dataProp'),
 {
 value: 'abc',
 writable: true,
 enumerable: true,
 configurable: true,
 });

因此,如果我们想指定任意属性,我们必须使用定义。

虽然我们可以在对象文字中创建 getter 和 setter,但我们不能通过赋值后来添加它们。在这里,我们也需要定义。

11.3.2 赋值运算符不会更改原型中的属性

让我们考虑以下设置,其中objproto继承了属性prop

const proto = { prop: 'a' };
const obj = Object.create(proto);

我们不能通过给obj.prop赋值来(破坏性地)更改proto.prop。这样做会创建一个新的自有属性:

assert.deepEqual(
 Object.keys(obj), []);
obj.prop = 'b';
// The assignment worked:
assert.equal(obj.prop, 'b');
// But we created an own property and overrode proto.prop,
// we did not change it:
assert.deepEqual(
 Object.keys(obj), ['prop']);
assert.equal(proto.prop, 'a');

这种行为的原因如下:原型可以具有其所有后代共享的属性值。如果我们只想在一个后代中更改这样的属性,我们必须通过覆盖来进行非破坏性地更改。然后,更改不会影响其他后代。

11.3.3 赋值调用 setter,定义不会

定义obj.prop属性与对其进行赋值有什么区别?

如果我们定义,那么我们的意图是要创建或更改obj的自有(非继承的)属性。因此,在以下示例中,定义忽略了.prop的继承的 setter:

let setterWasCalled = false;
const proto = {
 get prop() {
 return 'protoGetter';
 },
 set prop(x) {
 setterWasCalled = true;
 },
};
const obj = Object.create(proto);
assert.equal(obj.prop, 'protoGetter');
// Defining obj.prop:
Object.defineProperty(
 obj, 'prop', { value: 'objData' });
assert.equal(setterWasCalled, false);
// We have overridden the getter:
assert.equal(obj.prop, 'objData');

相反,如果我们对.prop进行赋值,那么我们的意图通常是要更改已经存在的东西,并且这种更改应该由 setter 处理:

let setterWasCalled = false;
const proto = {
 get prop() {
 return 'protoGetter';
 },
 set prop(x) {
 setterWasCalled = true;
 },
};
const obj = Object.create(proto);
assert.equal(obj.prop, 'protoGetter');
// Assigning to obj.prop:
obj.prop = 'objData';
assert.equal(setterWasCalled, true);
// The getter still active:
assert.equal(obj.prop, 'protoGetter');
11.3.4 继承的只读属性阻止通过赋值创建自己的属性

如果原型中的.prop是只读的会发生什么?

const proto = Object.defineProperty(
 {}, 'prop', {
 value: 'protoValue',
 writable: false,
 });

在从proto继承只读.prop的任何对象中,我们不能使用赋值来创建具有相同键的自有属性,例如:

const obj = Object.create(proto);
assert.throws(
 () => obj.prop = 'objValue',
 /^TypeError: Cannot assign to read only property 'prop'/);

为什么我们不能赋值?理由是通过创建自己的属性来覆盖继承的属性可以被视为非破坏性地更改继承的属性。可以说,如果属性是不可写的,我们就不应该能够这样做。

然而,定义.prop仍然有效,并允许我们覆盖:

Object.defineProperty(
 obj, 'prop', { value: 'objValue' });
assert.equal(obj.prop, 'objValue');

没有 setter 的访问器属性也被认为是只读的:

const proto = {
 get prop() {
 return 'protoValue';
 }
};
const obj = Object.create(proto);
assert.throws(
 () => obj.prop = 'objValue',
 /^TypeError: Cannot set property prop of #<Object> which has only a getter$/);

“覆盖错误”:优缺点

只读属性在原型链中较早阻止赋值的事实被称为覆盖错误

  • 它是在 ECMAScript 5.1 中引入的。
  • 一方面,这种行为与原型继承和 setter 的工作方式一致。(因此,可以说这是一个错误。)
  • 另一方面,使用这种行为,深冻结全局对象会导致不希望的副作用。
  • 曾经有尝试改变这种行为,但那破坏了 Lodash 库并被放弃了(GitHub 上的拉取请求)。
  • 背景知识:

11.4 语言构造使用定义,哪些使用赋值?

在本节中,我们检查语言何时使用定义以及何时使用赋值。我们通过跟踪是否调用继承的 setter 来检测使用的操作。有关更多信息,请参见§11.3.3 “赋值调用 setter,定义不调用”。

11.4.1 对象文字的属性是通过定义添加的

当我们通过对象文字创建属性时,JavaScript 总是使用定义(因此从不调用继承的 setter):

let lastSetterArgument;
const proto = {
 set prop(x) {
 lastSetterArgument = x;
 },
};
const obj = {
 __proto__: proto,
 prop: 'abc',
};
assert.equal(lastSetterArgument, undefined);
11.4.2 赋值运算符=总是使用赋值

赋值运算符=总是使用赋值来创建或更改属性。

let lastSetterArgument;
const proto = {
 set prop(x) {
 lastSetterArgument = x;
 },
};
const obj = Object.create(proto);
// Normal assignment:
obj.prop = 'abc';
assert.equal(lastSetterArgument, 'abc');
// Assigning via destructuring:
[obj.prop] = ['def'];
assert.equal(lastSetterArgument, 'def');
11.4.3 公共类字段是通过定义添加的

遗憾的是,即使公共类字段具有与赋值相同的语法,它们使用赋值来创建属性,它们使用定义(就像对象文字中的属性一样):

let lastSetterArgument1;
let lastSetterArgument2;
class A {
 set prop1(x) {
 lastSetterArgument1 = x;
 }
 set prop2(x) {
 lastSetterArgument2 = x;
 }
}
class B extends A {
 prop1 = 'one';
 constructor() {
 super();
 this.prop2 = 'two';
 }
}
new B();
// The public class field uses definition:
assert.equal(lastSetterArgument1, undefined);
// Inside the constructor, we trigger assignment:
assert.equal(lastSetterArgument2, 'two');

11.5 本章的进一步阅读和来源

评论

十二、属性的可枚举性

原文:exploringjs.com/deep-js/ch_enumerability.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 12.1 可枚举性如何影响属性迭代构造
  • 12.1.1 只考虑可枚举属性的操作
  • 12.1.2 同时考虑可枚举和不可枚举属性的操作
  • 12.1.3 内省操作的命名规则
  • 12.2 预定义和创建属性的可枚举性
  • 12.3 可枚举性的用例
  • 12.3.1 用例:隐藏属性不被for-in循环处理
  • 12.3.2 用例:标记不需要被复制的属性
  • 12.3.3 将属性标记为私有
  • 12.3.4 隐藏自有属性不被JSON.stringify()处理
  • 12.4 结论

可枚举性是对象属性的属性。在本章中,我们将更仔细地看看它是如何使用的,以及它如何影响Object.keys()Object.assign()等操作。

必需知识:属性特性

在本章中,您应该熟悉属性特性。如果不熟悉,请查看§9“属性特性:介绍”。

12.1 可枚举性如何影响属性迭代构造

为了演示各种操作受可枚举性的影响,我们使用以下对象obj,其原型是proto

const protoEnumSymbolKey = Symbol('protoEnumSymbolKey');
const protoNonEnumSymbolKey = Symbol('protoNonEnumSymbolKey');
const proto = Object.defineProperties({}, {
 protoEnumStringKey: {
 value: 'protoEnumStringKeyValue',
 enumerable: true,
 },
 [protoEnumSymbolKey]: {
 value: 'protoEnumSymbolKeyValue',
 enumerable: true,
 },
 protoNonEnumStringKey: {
 value: 'protoNonEnumStringKeyValue',
 enumerable: false,
 },
 [protoNonEnumSymbolKey]: {
 value: 'protoNonEnumSymbolKeyValue',
 enumerable: false,
 },
});
const objEnumSymbolKey = Symbol('objEnumSymbolKey');
const objNonEnumSymbolKey = Symbol('objNonEnumSymbolKey');
const obj = Object.create(proto, {
 objEnumStringKey: {
 value: 'objEnumStringKeyValue',
 enumerable: true,
 },
 [objEnumSymbolKey]: {
 value: 'objEnumSymbolKeyValue',
 enumerable: true,
 },
 objNonEnumStringKey: {
 value: 'objNonEnumStringKeyValue',
 enumerable: false,
 },
 [objNonEnumSymbolKey]: {
 value: 'objNonEnumSymbolKeyValue',
 enumerable: false,
 },
});
12.1.1 只考虑可枚举属性的操作

表 2:忽略不可枚举属性的操作。

操作 字符串键 符号键 继承
Object.keys() ES5
Object.values() ES2017
Object.entries() ES2017
扩展{...x} ^([ES2018])
Object.assign() ES6
JSON.stringify() ES5
for-in ES1

以下操作(在 tbl. 2 中总结)只考虑可枚举属性:

  • Object.keys() ^([ES5]) 返回可枚举自有字符串键属性的键。
> Object.keys(obj)
[ 'objEnumStringKey' ]
  • Object.values() ^([ES2017]) 返回可枚举自有字符串键属性的值。
> Object.values(obj)
[ 'objEnumStringKeyValue' ]
  • Object.entries() ^([ES2017]) 返回可枚举自有字符串键属性的键值对。(请注意,Object.fromEntries()接受符号作为键,但只创建可枚举属性。)
> Object.entries(obj)
[ [ 'objEnumStringKey', 'objEnumStringKeyValue' ] ]
  • 扩展到对象字面量 ^([ES2018]) 只考虑自有可枚举属性(带有字符串键或符号键)。
> const copy = {...obj};
> Reflect.ownKeys(copy)
[ 'objEnumStringKey', objEnumSymbolKey ]
  • Object.assign() ^([ES6]) 只会复制可枚举的自有属性(带有字符串键或符号键)。
> const copy = Object.assign({}, obj);
> Reflect.ownKeys(copy)
[ 'objEnumStringKey', objEnumSymbolKey ]
  • JSON.stringify() ^([ES5]) 只会将可枚举的自有属性与字符串键字符串化。
> JSON.stringify(obj)
'{"objEnumStringKey":"objEnumStringKeyValue"}'
  • for-in 循环 ^([ES1]) 遍历自有和继承的可枚举字符串键属性。
const propKeys = [];
for (const propKey in obj) {
 propKeys.push(propKey);
}
assert.deepEqual(
 propKeys, ['objEnumStringKey', 'protoEnumStringKey']);

for-in 是唯一一个内置操作,其中可枚举性对继承属性很重要。所有其他操作只适用于自有属性。

12.1.2 同时考虑可枚举和不可枚举属性的操作

表 3:同时考虑可枚举和不可枚举属性的操作。

操作 字符串键 符号键 继承
Object.getOwnPropertyNames() ES5
Object.getOwnPropertySymbols() ES6
Reflect.ownKeys() ES6
Object.getOwnPropertyDescriptors() ES2017

以下操作(在 tbl. 3 中总结)考虑了可枚举和不可枚举的属性:

  • Object.getOwnPropertyNames() ^([ES5]) 列出所有自有字符串键属性的键。
> Object.getOwnPropertyNames(obj)
[ 'objEnumStringKey', 'objNonEnumStringKey' ]
  • Object.getOwnPropertySymbols() ^([ES6]) 列出所有自有符号键属性的键。
> Object.getOwnPropertySymbols(obj)
[ objEnumSymbolKey, objNonEnumSymbolKey ]
  • Reflect.ownKeys() ^([ES6]) 列出所有自有属性的键。
> Reflect.ownKeys(obj)
[
 'objEnumStringKey',
 'objNonEnumStringKey',
 objEnumSymbolKey,
 objNonEnumSymbolKey
]
  • Object.getOwnPropertyDescriptors() ^([ES2017]) 列出所有自有属性的属性描述符。
> Object.getOwnPropertyDescriptors(obj)
{
 objEnumStringKey: {
 value: 'objEnumStringKeyValue',
 writable: false,
 enumerable: true,
 configurable: false
 },
 objNonEnumStringKey: {
 value: 'objNonEnumStringKeyValue',
 writable: false,
 enumerable: false,
 configurable: false
 },
 [objEnumSymbolKey]: {
 value: 'objEnumSymbolKeyValue',
 writable: false,
 enumerable: true,
 configurable: false
 },
 [objNonEnumSymbolKey]: {
 value: 'objNonEnumSymbolKeyValue',
 writable: false,
 enumerable: false,
 configurable: false
 }
}
12.1.3 内省操作的命名规则

内省使程序能够在运行时检查值的结构。这是元编程:普通编程是关于编写程序;元编程是关于检查和/或更改程序。

在 JavaScript 中,常见的内省操作具有简短的名称,而很少使用的操作具有较长的名称。忽略不可枚举的属性是规范,这就是为什么执行这种操作的名称很短,而不执行这种操作的名称很长的原因:

  • Object.keys() 忽略不可枚举的属性。
  • Object.getOwnPropertyNames() 列出所有自有属性的字符串键。

然而,Reflect 方法(如 Reflect.ownKeys())违反了这一规则,因为 Reflect 提供了更多与代理相关的“元”操作。

此外,自 ES6 以来,还有以下区别:

  • 属性键要么是字符串,要么是符号。
  • 属性名称是字符串键的属性键。
  • 属性符号是符号键的属性键。

因此,Object.keys() 的更好名称现在将是 Object.names()

12.2 预定义和创建属性的可枚举性

在本节中,我们将像这样缩写 Object.getOwnPropertyDescriptor()

const desc = Object.getOwnPropertyDescriptor.bind(Object);

大多数数据属性都是使用以下属性创建的:

{
 writable: true,
 enumerable: false,
 configurable: true,
}

这包括:

  • 赋值
  • 对象字面量
  • 公共类字段
  • Object.fromEntries()

最重要的不可枚举属性是:

  • 内置类的原型属性
> desc(Object.prototype, 'toString').enumerable
false
  • 通过用户定义的类创建的原型属性
> desc(class {foo() {}}.prototype, 'foo').enumerable
false
  • 数组的属性.length
> Object.getOwnPropertyDescriptor([], 'length')
{ value: 0, writable: true, enumerable: false, configurable: false }
  • 字符串的属性.length(注意原始值的所有属性都是只读的):
> Object.getOwnPropertyDescriptor('', 'length')
{ value: 0, writable: false, enumerable: false, configurable: false }

接下来我们将看一下可枚举性的使用案例,这将告诉我们为什么有些属性是可枚举的,而其他的不是。

12.3 可枚举性的使用案例

可枚举性是一个不一致的特性。它确实有用例,但总是有某种注意事项。在本节中,我们将看看使用案例和注意事项。

12.3.1 使用案例:隐藏for-in循环中的属性

for-in循环遍历对象的所有可枚举的字符串键属性,包括自有的和继承的。因此,属性enumerable用于隐藏不应遍历的属性。这是在 ECMAScript 1 中引入可枚举性的原因。

一般来说,最好避免使用for-in。接下来的两个小节将解释为什么。以下函数将帮助我们演示for-in的工作原理。

function listPropertiesViaForIn(obj) {
 const result = [];
 for (const key in obj) {
 result.push(key);
 }
 return result;
}
12.3.1.1 使用for-in遍历对象的注意事项

for-in遍历所有属性,包括继承的属性:

const proto = {enumerableProtoProp: 1};
const obj = {
 __proto__: proto,
 enumerableObjProp: 2,
};
assert.deepEqual(
 listPropertiesViaForIn(obj),
 ['enumerableObjProp', 'enumerableProtoProp']);

对于普通的普通对象,for-in不会看到继承的方法,比如Object.prototype.toString(),因为它们都是不可枚举的:

const obj = {};
assert.deepEqual(
 listPropertiesViaForIn(obj),
 []);

在用户定义的类中,所有继承的属性也是不可枚举的,因此被忽略:

class Person {
 constructor(first, last) {
 this.first = first;
 this.last = last;
 }
 getName() {
 return this.first + ' ' + this.last;
 }
}
const jane = new Person('Jane', 'Doe');
assert.deepEqual(
 listPropertiesViaForIn(jane),
 ['first', 'last']);

结论: 在对象中,for-in考虑了继承属性,我们通常希望忽略这些属性。因此最好将for-of循环与Object.keys()Object.entries()等结合使用。

12.3.1.2 使用for-in遍历数组的注意事项

数组和字符串中的自有属性.length是不可枚举的,因此被for-in忽略:

> listPropertiesViaForIn(['a', 'b'])
[ '0', '1' ]
> listPropertiesViaForIn('ab')
[ '0', '1' ]

然而,通常不安全使用for-in来遍历数组的索引,因为它考虑了既继承的又是非索引的自有属性。以下示例演示了如果数组具有自有的非索引属性会发生什么:

const arr1 = ['a', 'b'];
assert.deepEqual(
 listPropertiesViaForIn(arr1),
 ['0', '1']);
const arr2 = ['a', 'b'];
arr2.nonIndexProp = 'yes';
assert.deepEqual(
 listPropertiesViaForIn(arr2),
 ['0', '1', 'nonIndexProp']);

**结论:**不应该使用for-in来遍历数组的索引,因为它考虑索引属性和非索引属性:

  • 如果您对数组的键感兴趣,请使用数组方法.keys()
> [...['a', 'b', 'c'].keys()]
[ 0, 1, 2 ]
  • 如果要遍历数组的元素,请使用for-of循环,这样还可以与其他可迭代的数据结构一起使用。
12.3.2 用例:标记不要复制的属性

通过使属性不可枚举,我们可以将它们从某些复制操作中隐藏起来。让我们首先检查两个历史复制操作,然后再转向更现代的复制操作。

12.3.2.1 历史复制操作:Prototype 的Object.extend()

Prototype是一个 JavaScript 框架,由 Sam Stephenson 于 2005 年 2 月创建,作为 Ruby on Rails 中 Ajax 支持的基础的一部分。

Prototype 的Object.extend(destination, source)source的所有可枚举的自有和继承属性复制到destination的自有属性中。它的实现方式如下:

function extend(destination, source) {
 for (var property in source)
 destination[property] = source[property];
 return destination;
}

如果我们使用Object.extend()与一个对象,我们可以看到它将继承属性复制到自有属性中,并忽略不可枚举的属性(它还忽略符号键属性)。所有这些都是由for-in的工作方式决定的。

const proto = Object.defineProperties({}, {
 enumProtoProp: {
 value: 1,
 enumerable: true,
 },
 nonEnumProtoProp: {
 value: 2,
 enumerable: false,
 },
});
const obj = Object.create(proto, {
 enumObjProp: {
 value: 3,
 enumerable: true,
 },
 nonEnumObjProp: {
 value: 4,
 enumerable: false,
 },
});
assert.deepEqual(
 extend({}, obj),
 {enumObjProp: 3, enumProtoProp: 1});
12.3.2.2 历史复制操作:jQuery 的$.extend()

jQuery 的$.extend(target, source1, source2, ···)类似于Object.extend()

  • 它将source1的所有可枚举的自有和继承属性复制到target的自有属性中。
  • 然后它对source2执行相同的操作。
  • 等等。
12.3.2.3 可枚举性驱动复制的缺点

基于可枚举性进行复制有几个缺点:

  • 虽然可枚举性对于隐藏继承属性很有用,但主要是以这种方式使用,因为我们通常只想将自有属性复制到自有属性中。忽略继承属性可以更好地实现相同的效果。
  • 要复制哪些属性通常取决于手头的任务;为所有用例提供单个标志很少有意义。更好的选择是提供一个带有谓词(返回布尔值的回调)的复制操作,告诉它何时忽略属性。
  • 可枚举性在复制时方便地隐藏了数组的自有属性.length。但这是一个极为罕见的特例:一个既影响兄弟属性又受其影响的魔术属性。如果我们自己实现这种魔术,我们将使用(继承的)getter 和/或 setter,而不是(自有的)数据属性。
12.3.2.4 Object.assign() ^([ES5])

在 ES6 中,Object.assign(target, source_1, source_2, ···)可以用于将源合并到目标中。源的所有自有可枚举属性都会被考虑(无论是字符串键还是符号键)。Object.assign()使用“get”操作从源中读取值,并使用“set”操作将值写入目标。

关于可枚举性,Object.assign()延续了Object.extend()$.extend()的传统。引用 Yehuda Katz

Object.assign将为所有已经在流通中的extend()API 铺平道路。我们认为在这些情况下不复制可枚举方法的先例足以证明Object.assign具有这种行为的足够理由。

换句话说:Object.assign()是为了从$.extend()(以及类似的方法)升级而创建的。它的方法比$.extend更清晰,因为它忽略了继承的属性。

12.3.2.5 非可枚举性在复制时有用的罕见例子

非可枚举性有助的情况很少。一个罕见的例子是fs-extra最近遇到的问题

  • 内置的 Node.js 模块fs有一个包含基于 Promise 的fs API 版本的对象的属性.promises。在问题出现时,读取.promise会导致以下警告被记录到控制台:
ExperimentalWarning: The fs.promises API is experimental
  • 除了提供自己的功能外,fs-extra还将fs中的所有内容重新导出。对于 CommonJS 模块,这意味着将fs的所有属性复制到fs-extramodule.exports中(通过Object.assign())。当fs-extra这样做时,就会触发警告。这很令人困惑,因为每次加载fs-extra时都会发生这种情况。
  • 一个快速的修复是将属性fs.promises设为不可枚举。之后,fs-extra就忽略了它。
12.3.3 将属性标记为私有

如果我们使一个属性不可枚举,它就不能被Object.keys()for-in循环等看到了。在这些机制方面,该属性是私有的。

然而,这种方法存在几个问题:

  • 在复制对象时,我们通常希望复制私有属性。这与使不应该被复制的属性不可枚举相冲突(见上一节)。
  • 属性并不是真正私有的。获取、设置和其他几种机制对可枚举和不可枚举的属性没有区别。
  • 在处理代码时,无论是作为源代码还是交互式地,我们无法立即看到属性是否可枚举。命名约定(例如给属性名加上下划线前缀)更容易发现。
  • 我们不能使用可枚举性来区分公共和私有方法,因为原型中的方法默认是不可枚举的。
12.3.4 隐藏自有属性不被JSON.stringify()处理

JSON.stringify()在输出中不包括非可枚举的属性。因此,我们可以使用可枚举性来确定应该导出到 JSON 的自有属性。这个用例类似于前面的一个,将属性标记为私有。但也不同,因为这更多关于导出,并且应用了略微不同的考虑。例如:一个对象能否完全从 JSON 中重建?

作为可枚举性的替代方案,对象可以实现方法.toJSON(),并且JSON.stringify()会将该方法返回的内容字符串化,而不是对象本身。下一个例子演示了这是如何工作的。

class Point {
 static fromJSON(json) {
 return new Point(json[0], json[1]);
 }
 constructor(x, y) {
 this.x = x;
 this.y = y;
 }
 toJSON() {
 return [this.x, this.y];
 }
}
assert.equal(
 JSON.stringify(new Point(8, -3)),
 '[8,-3]'
);

我觉得toJSON()比可枚举性更清晰。它还给了我们更多关于存储格式应该是什么样的自由。

12.4 结论

我们已经看到几乎所有非可枚举性的应用都是现在有其他更好的解决方案的变通方法。

对于我们自己的代码,我们通常可以假装可枚举性不存在:

  • 通过对象字面量和赋值创建属性总是创建可枚举的属性。
  • 通过类创建的原型属性总是不可枚举的。

也就是说,我们自动遵循最佳实践。

评论

第四部分:OOP:技术

原文:exploringjs.com/deep-js/pt_oop-techniques.html

译者:飞龙

协议:CC BY-NC-SA 4.0

接下来:13 实例化类的技术

十三、实例化类的技术

原文:exploringjs.com/deep-js/ch_creating-class-instances.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 13.1 问题:异步初始化属性
  • 13.2 解决方案:基于 Promise 的构造函数
  • 13.2.1 使用立即调用的异步箭头函数
  • 13.3 解决方案:静态工厂方法
  • 13.3.1 改进:通过秘密令牌私有构造函数
  • 13.3.2 改进:构造函数抛出,工厂方法借用类原型
  • 13.3.3 改进:实例默认处于非活动状态,由工厂方法激活
  • 13.3.4 变体:单独的工厂函数
  • 13.4 基于 Promise 的构造函数的子类化(可选)
  • 13.5 结论
  • 13.6 进一步阅读

在本章中,我们将研究创建类实例的几种方法:构造函数,工厂函数等。我们通过多次解决一个具体的问题来做到这一点。本章的重点是类,因此忽略了类的替代方案。

13.1 问题:异步初始化属性

以下容器类应该异步接收其属性.data的内容。这是我们的第一次尝试:

class DataContainer {
 #data; // (A)
 constructor() {
 Promise.resolve('downloaded')
 .then(data => this.#data = data); // (B)
 }
 getData() {
 return 'DATA: '+this.#data; // (C)
 }
}

此代码的关键问题:属性.data最初为undefined

const dc = new DataContainer();
assert.equal(dc.getData(), 'DATA: undefined');
setTimeout(() => assert.equal(
 dc.getData(), 'DATA: downloaded'), 0);

在 A 行,我们声明了私有字段.#data,我们在 B 行和 C 行中使用。

DataContainer构造函数内部的 Promise 是异步解决的,这就是为什么我们只有在完成当前任务并启动新任务(通过setTimeout())后才能看到.data的最终值。换句话说,当我们第一次看到它时,DataContainer实例尚未完全初始化。

13.2 解决方案:基于 Promise 的构造函数

如果我们延迟访问DataContainer实例直到完全初始化,会怎么样?我们可以通过从构造函数返回一个 Promise 来实现这一点。默认情况下,构造函数返回其所属类的新实例。如果我们明确返回一个对象,我们可以覆盖它:

class DataContainer {
 #data;
 constructor() {
 return Promise.resolve('downloaded')
 .then(data => {
 this.#data = data;
 return this; // (A)
 });
 }
 getData() {
 return 'DATA: '+this.#data;
 }
}
new DataContainer()
 .then(dc => assert.equal( // (B)
 dc.getData(), 'DATA: downloaded'));

现在我们必须等到可以访问我们的实例(B 行)。在数据“下载”后(A 行)将其传递给我们。此代码中可能出现两种错误:

  • 下载可能失败并产生拒绝。
  • 在第一个.then()回调的主体中可能会抛出异常。

在任何一种情况下,错误都会成为从构造函数返回的 Promise 的拒绝。

利弊:

  • 这种方法的好处是,只有在完全初始化后才能访问实例。没有其他方法可以创建DataContainer的实例。
  • 一个缺点是构造函数返回一个 Promise 而不是一个实例。
13.2.1 使用立即调用的异步箭头函数

不是直接使用 Promise API 来创建从构造函数返回的 Promise,我们也可以使用一个异步箭头函数,我们立即调用

constructor() {
 return (async () => {
 this.#data = await Promise.resolve('downloaded');
 return this;
 })();
}

13.3 解决方案:静态工厂方法

C静态工厂方法创建C的实例,是使用new C()的替代方法。JavaScript 中静态工厂方法的常见名称:

  • .create(): 创建一个新实例。示例:Object.create()
  • .from(): 创建一个基于不同对象的新实例,通过复制和/或转换它。示例:Array.from()
  • .of(): 通过使用参数指定的值创建一个新实例。示例:Array.of()

在下面的示例中,DataContainer.create()是一个静态工厂方法。它返回DataContainer的实例的 Promise:

class DataContainer {
 #data;
 static async create() {
 const data = await Promise.resolve('downloaded');
 return new this(data);
 }
 constructor(data) {
 this.#data = data;
 }
 getData() {
 return 'DATA: '+this.#data;
 }
}
DataContainer.create()
 .then(dc => assert.equal(
 dc.getData(), 'DATA: downloaded'));

这次,所有异步功能都包含在.create()中,这使得类的其余部分完全同步,因此更简单。

优缺点:

  • 这种方法的一个好处是构造函数变得简单。
  • 这种方法的一个缺点是现在可能会创建不正确设置的实例,通过new DataContainer()
13.3.1 改进:通过秘密令牌的私有构造函数

如果我们想要确保实例始终正确设置,我们必须确保只有DataContainer.create()可以调用DataContainer的构造函数。我们可以通过一个秘密令牌来实现:

const secretToken = Symbol('secretToken');
class DataContainer {
 #data;
 static async create() {
 const data = await Promise.resolve('downloaded');
 return new this(secretToken, data);
 }
 constructor(token, data) {
 if (token !== secretToken) {
 throw new Error('Constructor is private');
 }
 this.#data = data;
 }
 getData() {
 return 'DATA: '+this.#data;
 }
}
DataContainer.create()
 .then(dc => assert.equal(
 dc.getData(), 'DATA: downloaded'));

如果secretTokenDataContainer位于同一个模块中,并且只有后者被导出,那么外部方无法访问secretToken,因此无法创建DataContainer的实例。

优缺点:

  • 优点:安全且直观。
  • 缺点:稍微冗长。
13.3.2 改进:构造函数抛出,工厂方法借用类原型

我们解决方案的以下变体禁用了DataContainer的构造函数,并使用一个技巧以另一种方式创建它的实例(行 A):

class DataContainer {
 static async create() {
 const data = await Promise.resolve('downloaded');
 return Object.create(this.prototype)._init(data); // (A)
 }
 constructor() {
 throw new Error('Constructor is private');
 }
 _init(data) {
 this._data = data;
 return this;
 }
 getData() {
 return 'DATA: '+this._data;
 }
}
DataContainer.create()
 .then(dc => {
 assert.equal(dc instanceof DataContainer, true); // (B)
 assert.equal(
 dc.getData(), 'DATA: downloaded');
 });

在内部,DataContainer的实例是其原型为DataContainer.prototype的任何对象。这就是为什么我们可以通过Object.create()(行 A)创建实例,也是为什么instanceof在行 B 中起作用的原因。

优缺点:

  • 优点:优雅;instanceof有效。
  • 缺点:
  • 无法完全阻止创建实例。不过公平地说,通过Object.create()的解决方案也可以用于我们之前的解决方案。
  • 我们不能在DataContainer中使用私有字段私有方法,因为这些只对通过构造函数创建的实例正确设置。
13.3.3 改进:实例默认处于非活动状态,由工厂方法激活

另一种更冗长的变体是,默认情况下,实例通过标志.#active关闭。将它们打开的初始化方法.#init()不能从外部访问,但Data.container()可以调用它:

class DataContainer {
 #data;
 static async create() {
 const data = await Promise.resolve('downloaded');
 return new this().#init(data);
 }
 #active = false;
 constructor() {
 }
 #init(data) {
 this.#active = true;
 this.#data = data;
 return this;
 }
 getData() {
 this.#check();
 return 'DATA: '+this.#data;
 }
 #check() {
 if (!this.#active) {
 throw new Error('Not created by factory');
 }
 }
}
DataContainer.create()
 .then(dc => assert.equal(
 dc.getData(), 'DATA: downloaded'));

标志.#active通过私有方法.#check()强制执行,必须在每个方法的开始处调用它。

这种解决方案的主要缺点是冗长。还有一个风险,就是在每个方法中忘记调用.#check()

13.3.4 变体:单独的工厂函数

为了完整起见,我将展示另一种变体:不使用静态方法作为工厂,您也可以使用一个单独的独立函数。

const secretToken = Symbol('secretToken');
class DataContainer {
 #data;
 constructor(token, data) {
 if (token !== secretToken) {
 throw new Error('Constructor is private');
 }
 this.#data = data;
 }
 getData() {
 return 'DATA: '+this.#data;
 }
}
async function createDataContainer() {
 const data = await Promise.resolve('downloaded');
 return new DataContainer(secretToken, data);
}
createDataContainer()
 .then(dc => assert.equal(
 dc.getData(), 'DATA: downloaded'));

独立函数作为工厂偶尔是有用的,但在这种情况下,我更喜欢静态方法:

  • 独立函数无法访问DataContainer的私有成员。
  • 我更喜欢DataContainer.create()的方式。

13.4 以 Promise 为基础的构造函数进行子类化(可选)

一般来说,子类化是一种需要谨慎使用的东西。

使用单独的工厂函数,相对容易扩展DataContainer

然而,使用基于 Promise 的构造函数扩展类会导致严重的限制。在下面的示例中,我们对DataContainer进行子类化。子类SubDataContainer有自己的私有字段.#moreData,它通过连接到其超类的构造函数返回的 Promise 来异步初始化。

class DataContainer {
 #data;
 constructor() {
 return Promise.resolve('downloaded')
 .then(data => {
 this.#data = data;
 return this; // (A)
 });
 }
 getData() {
 return 'DATA: '+this.#data;
 }
}
class SubDataContainer extends DataContainer {
 #moreData;
 constructor() {
 super();
 const promise = this;
 return promise
 .then(_this => {
 return Promise.resolve('more')
 .then(moreData => {
 _this.#moreData = moreData;
 return _this;
 });
 });
 }
 getData() {
 return super.getData() + ', ' + this.#moreData;
 }
}

哎呀,我们无法实例化这个类:

assert.rejects(
 () => new SubDataContainer(),
 {
 name: 'TypeError',
 message: 'Cannot write private member #moreData ' +
 'to an object whose class did not declare it',
 }
);

为什么会失败?构造函数总是将其私有字段添加到其 this 中。然而,在这里,子构造函数中的 this 是由超级构造函数返回的 Promise(而不是通过 Promise 交付的 SubDataContainer 实例)。

然而,如果 SubDataContainer 没有任何私有字段,这种方法仍然有效。

13.5 结论

在本章研究的场景中,我更喜欢使用基于 Promise 的构造函数或静态工厂方法加上通过秘密令牌的私有构造函数。

然而,这里介绍的其他技术在其他场景中仍然可能有用。

13.6 进一步阅读

  • 异步编程:
  • 《JavaScript 程序员的急切指南》中的“用于异步编程的 Promise”章节
  • 《JavaScript 程序员的急切指南》中的“异步函数”章节
  • 《JavaScript 程序员的急切指南》中的“立即调用的异步箭头函数”部分
  • 面向对象编程:
  • 《JavaScript 程序员的急切指南》中的“原型链和类”章节
  • “JavaScript 类中的私有字段的 ES 提案”博文
  • “JavaScript 类中的私有方法和访问器的 ES 提案”博文

评论

十四、复制类的实例:.clone() vs. 复制构造函数

原文:exploringjs.com/deep-js/ch_copying-class-instances.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 14.1 .clone()方法
  • 14.2 静态工厂方法
  • 14.3 致谢

在本章中,我们将介绍两种实现类实例复制的技术:

  • .clone()方法
  • 所谓的复制构造函数,即接收当前类的另一个实例并用它来初始化当前实例的构造函数。

14.1 .clone()方法

这种技术为每个需要被复制的类引入了一个.clone()方法。它返回this的深复制。下面的例子展示了三个可以被克隆的类。

class Point {
 constructor(x, y) {
 this.x = x;
 this.y = y;
 }
 clone() {
 return new Point(this.x, this.y);
 }
}
class Color {
 constructor(name) {
 this.name = name;
 }
 clone() {
 return new Color(this.name);
 }
}
class ColorPoint extends Point {
 constructor(x, y, color) {
 super(x, y);
 this.color = color;
 }
 clone() {
 return new ColorPoint(
 this.x, this.y, this.color.clone()); // (A)
 }
}

A 行展示了这种技术的一个重要方面:复合实例属性值也必须递归地被克隆。

14.2 静态工厂方法

复制构造函数是一种使用当前类的另一个实例来设置当前实例的构造函数。复制构造函数在静态语言(如 C++和 Java)中很受欢迎,因为你可以通过静态重载提供构造函数的多个版本。在这里,静态意味着选择使用哪个版本是在编译时做出的。

在 JavaScript 中,我们必须在运行时做出决定,这导致了不优雅的代码:

class Point {
 constructor(...args) {
 if (args[0] instanceof Point) {
 // Copy constructor
 const [other] = args;
 this.x = other.x;
 this.y = other.y;
 } else {
 const [x, y] = args;
 this.x = x;
 this.y = y;
 }
 }
}

这是你如何使用这个类的方式:

const original = new Point(-1, 4);
const copy = new Point(original);
assert.deepEqual(copy, original);

静态工厂方法是构造函数的一种替代方法,在这种情况下效果更好,因为我们可以直接调用所需的功能。(这里,静态意味着这些工厂方法是类方法。)

在下面的例子中,三个类PointColorColorPoint都有一个静态工厂方法.from()

class Point {
 constructor(x, y) {
 this.x = x;
 this.y = y;
 }
 static from(other) {
 return new Point(other.x, other.y);
 }
}
class Color {
 constructor(name) {
 this.name = name;
 }
 static from(other) {
 return new Color(other.name);
 }
}
class ColorPoint extends Point {
 constructor(x, y, color) {
 super(x, y);
 this.color = color;
 }
 static from(other) {
 return new ColorPoint(
 other.x, other.y, Color.from(other.color)); // (A)
 }
}

在 A 行,我们再次进行递归复制。

这就是ColorPoint.from()的工作原理:

const original = new ColorPoint(-1, 4, new Color('red'));
const copy = ColorPoint.from(original);
assert.deepEqual(copy, original);

14.3 致谢

  • Ron Korvig提醒我在 JavaScript 中进行深复制时要使用静态工厂方法而不是重载构造函数。

评论

十五、不可变集合的包装器

原文:exploringjs.com/deep-js/ch_immutable-collection-wrappers.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 15.1 包装对象
  • 15.1.1 通过包装使集合变为不可变
  • 15.2 Map 的不可变包装器
  • 15.3 数组的不可变包装器

通过包装集合的不可变包装器使该集合变为不可变。在本章中,我们将探讨其工作原理以及其有用之处。

15.1 包装对象

如果有一个我们想要减少接口的对象,我们可以采取以下方法:

  • 创建一个新对象,将原始对象存储在私有字段中。新对象被称为包装器,原始对象被称为被包装对象
  • 包装器只将它接收到的一些方法调用转发给被包装对象。

包装的样子如下:

class Wrapper {
 #wrapped;
 constructor(wrapped) {
 this.#wrapped = wrapped;
 }
 allowedMethod1(...args) {
 return this.#wrapped.allowedMethod1(...args);
 }
 allowedMethod2(...args) {
 return this.#wrapped.allowedMethod2(...args);
 }
}

相关软件设计模式:

  • 包装与四人帮设计模式Facade有关。
  • 我们使用转发来实现委托。委托意味着一个对象让另一个对象(委托)处理它的一些工作。这是共享代码的继承的替代方法。
15.1.1 通过包装使集合变为不可变

要使集合变为不可变,我们可以使用包装并从其接口中删除所有破坏性操作。

这种技术的一个重要用例是一个具有内部可变数据结构的对象,它希望安全地导出而不复制它。导出是“活动的”也可能是一个目标。对象可以通过包装内部数据结构并使其不可变来实现其目标。

接下来的两节展示了 Map 和数组的不可变包装器。它们都有以下限制:

  • 它们只是草图。需要更多的工作使它们适用于实际使用:更好的检查,支持更多的方法等。
  • 它们的工作是浅层的:每个都使被包装对象不可变,但不影响其返回的数据。这可以通过包装一些方法返回的结果来修复。

15.2 不可变的 Map 包装器

ImmutableMapWrapper生成 Map 的包装器:

class ImmutableMapWrapper {
 static _setUpPrototype() {
 // Only forward non-destructive methods to the wrapped Map:
 for (const methodName of ['get', 'has', 'keys', 'size']) {
 ImmutableMapWrapper.prototype[methodName] = function (...args) {
 return this.#wrappedMapmethodName;
 }
 }
 }
 #wrappedMap;
 constructor(wrappedMap) {
 this.#wrappedMap = wrappedMap;
 }
}
ImmutableMapWrapper._setUpPrototype();

原型的设置必须由静态方法执行,因为我们只能从类内部访问私有字段.#wrappedMap

这是ImmutableMapWrapper的实际应用:

const map = new Map([[false, 'no'], [true, 'yes']]);
const wrapped = new ImmutableMapWrapper(map);
// Non-destructive operations work as usual:
assert.equal(
 wrapped.get(true), 'yes');
assert.equal(
 wrapped.has(false), true);
assert.deepEqual(
 [...wrapped.keys()], [false, true]);
// Destructive operations are not available:
assert.throws(
 () => wrapped.set(false, 'never!'),
 /^TypeError: wrapped.set is not a function$/);
assert.throws(
 () => wrapped.clear(),
 /^TypeError: wrapped.clear is not a function$/);

15.3 不可变的数组包装器

对于数组arr,普通的包装是不够的,因为我们不仅需要拦截方法调用,还需要拦截属性访问,比如arr[1] = trueJavaScript 代理使我们能够做到这一点:

const RE_INDEX_PROP_KEY = /^[0-9]+$/;
const ALLOWED_PROPERTIES = new Set([
 'length', 'constructor', 'slice', 'concat']);
function wrapArrayImmutably(arr) {
 const handler = {
 get(target, propKey, receiver) {
 // We assume that propKey is a string (not a symbol)
 if (RE_INDEX_PROP_KEY.test(propKey) // simplified check!
 || ALLOWED_PROPERTIES.has(propKey)) {
 return Reflect.get(target, propKey, receiver);
 }
 throw new TypeError(`Property "${propKey}" can’t be accessed`);
 },
 set(target, propKey, value, receiver) {
 throw new TypeError('Setting is not allowed');
 },
 deleteProperty(target, propKey) {
 throw new TypeError('Deleting is not allowed');
 },
 };
 return new Proxy(arr, handler);
}

让我们来包装一个数组:

const arr = ['a', 'b', 'c'];
const wrapped = wrapArrayImmutably(arr);
// Non-destructive operations are allowed:
assert.deepEqual(
 wrapped.slice(1), ['b', 'c']);
assert.equal(
 wrapped[1], 'b');
// Destructive operations are not allowed:
assert.throws(
 () => wrapped[1] = 'x',
 /^TypeError: Setting is not allowed$/);
assert.throws(
 () => wrapped.shift(),
 /^TypeError: Property "shift" can’t be accessed$/);

评论

第六部分:正则表达式

原文:exploringjs.com/deep-js/pt_regular-expressions.html

译者:飞龙

协议:CC BY-NC-SA 4.0

接下来:16 正则表达式:通过示例了解环视断言

十六、正则表达式:通过示例了解先行断言

原文:exploringjs.com/deep-js/ch_regexp-lookaround-assertions.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 16.1 速查表:先行断言
  • 16.2 本章警告
  • 16.3 示例:指定匹配项之前或之后的内容(正向先行断言)
  • 16.4 示例:指定匹配项之前或之后的内容(负向先行断言)
  • 16.4.1 没有简单的替代方案来使用负向先行断言
  • 16.5 插曲:将先行断言指向内部
  • 16.6 示例:匹配不以’abc’开头的字符串
  • 16.7 示例:匹配不包含’.mjs’的子字符串
  • 16.8 示例:跳过带有注释的行
  • 16.9 示例:智能引号
  • 16.9.1 通过反斜杠支持转义
  • 16.10 致谢
  • 16.11 进一步阅读

在本章中,我们使用示例来探讨正则表达式中的先行断言。先行断言是非捕获的,必须匹配(或不匹配)输入字符串中当前位置之前(或之后)的内容。

16.1 速查表:先行断言

表 4:可用先行断言的概述。

模式 名称
(?=«pattern») 正向先行断言 ES3
(?!«pattern») 负向先行断言 ES3
(?<=«pattern») 正向后行断言 ES2018
(?<!«pattern») 负向后行断言 ES2018

有四个先行断言(表格 4)

  • 先行断言(ECMAScript 3):
  • 正向先行断言:(?=«pattern») 如果pattern匹配输入字符串中当前位置之后的内容,则匹配成功。
  • 负向先行断言:(?!«pattern») 如果pattern不匹配输入字符串中当前位置之后的内容,则匹配成功。
  • 后行断言(ECMAScript 2018):
  • 正向后行断言:(?<=«pattern») 如果pattern匹配输入字符串中当前位置之前的内容,则匹配成功。
  • 负向后行断言:(?<!«pattern») 如果pattern不匹配输入字符串中当前位置之前的内容,则匹配成功。

16.2 本章警告

  • 这些示例展示了通过先行断言可以实现的内容。然而,正则表达式并不总是最佳解决方案。另一种技术,比如适当的解析,可能是更好的选择。
  • 后行断言是一个相对较新的功能,可能不被您所针对的所有 JavaScript 引擎支持。
  • 先行断言可能会对性能产生负面影响,特别是如果它们的模式匹配长字符串。

16.3 示例:指定匹配项之前或之后的内容(正向先行断言)

在以下交互中,我们提取带引号的单词:

> 'how "are" "you" doing'.match(/(?<=")[a-z]+(?=")/g)
[ 'are', 'you' ]

两个先行断言在这里帮助了我们:

  • (?<=") “必须由一个引号前导”
  • (?=") “必须跟随一个引号”

环视断言在.match()中特别方便,因为它在/g模式下返回整个匹配(捕获组 0)。环视断言的模式匹配的内容不会被捕获。没有环视断言,引号会出现在结果中:

> 'how "are" "you" doing'.match(/"([a-z]+)"/g)
[ '"are"', '"you"' ]

16.4 示例:指定匹配之前或之后不出现的内容(负环视)

我们如何实现与前一节相反的操作,并从字符串中提取所有未引用的单词?

  • 输入:'how "are" "you" doing'
  • 输出:['how', 'doing']

我们的第一个尝试是将正环视断言简单地转换为负环视断言。然而,这种方法失败了:

> 'how "are" "you" doing'.match(/(?<!")[a-z]+(?!")/g)
[ 'how', 'r', 'o', 'doing' ]

问题在于我们提取了不被引号括起来的字符序列。这意味着在字符串'"are"'中,“are”中间的“r”被认为是未引用的,因为它前面是“a”,后面是“e”。

我们可以通过声明前缀和后缀不能是引号或字母来解决这个问题:

> 'how "are" "you" doing'.match(/(?<!["a-z])[a-z]+(?!["a-z])/g)
[ 'how', 'doing' ]

另一个解决方案是通过\b要求字符序列[a-z]+在单词边界开始和结束:

> 'how "are" "you" doing'.match(/(?<!")\b[a-z]+\b(?!")/g)
[ 'how', 'doing' ]

负回顾断言和负前瞻断言的一个好处是它们也可以分别在字符串的开头或结尾工作,正如示例中所演示的那样。

16.4.1 没有负环视断言的简单替代方案

负环视断言是一个强大的工具,通常无法通过其他正则表达式手段来模拟。

如果我们不想使用它们,通常必须采取完全不同的方法。例如,在这种情况下,我们可以将字符串拆分为(带引号和不带引号的)单词,然后过滤这些单词:

const str = 'how "are" "you" doing';
const allWords = str.match(/"?[a-z]+"?/g);
const unquotedWords = allWords.filter(
 w => !w.startsWith('"') || !w.endsWith('"'));
assert.deepEqual(unquotedWords, ['how', 'doing']);

这种方法的好处:

  • 它适用于旧版引擎。
  • 这很容易理解。

16.5 插曲:指向内部的环视断言

到目前为止,我们所看到的所有示例都有一个共同点,即环视断言规定了匹配之前或之后必须出现的内容,但不包括这些字符在匹配中。

本章其余部分显示的正则表达式是不同的:它们的环视断言指向内部并限制了匹配中的内容。

16.6 示例:匹配不以'abc'开头的字符串

假设我们想匹配所有不以'abc'开头的字符串。我们的第一次尝试可能是正则表达式/^(?!abc)/

这对.test()来说效果很好:

> /^(?!abc)/.test('xyz')
true

然而,.exec()给了我们一个空字符串:

> /^(?!abc)/.exec('xyz')
{ 0: '', index: 0, input: 'xyz', groups: undefined }

问题在于像环视断言这样的断言不会扩展匹配的文本。也就是说,它们不会捕获输入字符,它们只是对输入中的当前位置提出要求。

因此,解决方案是添加一个能够捕获输入字符的模式:

> /^(?!abc).*$/.exec('xyz')
{ 0: 'xyz', index: 0, input: 'xyz', groups: undefined }

如期望的那样,这个新的正则表达式拒绝了以'abc'为前缀的字符串:

> /^(?!abc).*$/.exec('abc')
null
> /^(?!abc).*$/.exec('abcd')
null

它接受了不具有完整前缀的字符串:

> /^(?!abc).*$/.exec('ab')
{ 0: 'ab', index: 0, input: 'ab', groups: undefined }

16.7 示例:匹配不包含'.mjs'的子字符串

在下面的示例中,我们想要找到

import ··· from '«module-specifier»';

其中module-specifier不以'.mjs'结尾。

const code = `
import {transform} from './util';
import {Person} from './person.mjs';
import {zip} from 'lodash';
`.trim();
assert.deepEqual(
 code.match(/^import .*? from '[^']+(?<!\.mjs)';$/umg),
 [
 "import {transform} from './util';",
 "import {zip} from 'lodash';",
 ]);

在这里,回顾断言(?<!\.mjs)充当了一个保护,防止正则表达式匹配包含'.mjs’的字符串。

16.8 示例:跳过带有注释的行

场景:我们想解析带有设置的行,同时跳过注释。例如:

const RE_SETTING = /^(?!#)([^:]*):(.*)$/
const lines = [
 'indent: 2', // setting
 '# Trim trailing whitespace:', // comment
 'whitespace: trim', // setting
];
for (const line of lines) {
 const match = RE_SETTING.exec(line);
 if (match) {
 const key = JSON.stringify(match[1]);
 const value = JSON.stringify(match[2]);
 console.log(`KEY: ${key} VALUE: ${value}`);
 }
}
// Output:
// 'KEY: "indent" VALUE: " 2"'
// 'KEY: "whitespace" VALUE: " trim"'

我们是如何得到正则表达式RE_SETTING的?

我们从以下正则表达式开始处理设置:

/^([^:]*):(.*)$/

直观地,它是以下部分的序列:

  • 行的开头
  • 非冒号(零个或多个)
  • 一个冒号
  • 任何字符(零个或多个)
  • 行的末尾

这个正则表达式确实拒绝了一些注释:

> /^([^:]*):(.*)$/.test('# Comment')
false

但它接受其他的(其中有冒号):

> /^([^:]*):(.*)$/.test('# Comment:')
true

我们可以通过在前面加上(?!#)来修复这个问题。直观地,它的意思是:“输入字符串中的当前位置不能紧跟着字符#。”

新的正则表达式按预期工作:

> /^(?!#)([^:]*):(.*)$/.test('# Comment:')
false

16.9 示例:智能引号

假设我们想将成对的直引号转换为弯引号:

  • 输入:"yes" and "no"
  • 输出:“yes” and “no”

这是我们的第一次尝试:

> `The words "must" and "should".`.replace(/"(.*)"/g, '“$1”')
'The words “must" and "should”.'

只有第一个引号和最后一个引号是卷曲的。问题在于*量词会贪婪地匹配(尽可能多地匹配)。

如果我们在*后面加上一个问号,它会勉强地匹配:

> `The words "must" and "should".`.replace(/"(.*?)"/g, '“$1”')
'The words “must” and “should”.'
16.9.1 通过反斜杠支持转义

如果我们想要通过反斜杠允许引号的转义怎么办?我们可以在引号之前使用保护符(?<!\\)来做到这一点:

> const regExp = /(?<!\\)"(.*?)(?<!\\)"/g;
> String.raw`\"straight\" and "curly"`.replace(regExp, '“$1”')
'\\"straight\\" and “curly”'

作为后处理步骤,我们仍然需要做:

.replace(/\\"/g, `"`)

然而,当有一个反斜杠转义的反斜杠时,这个正则表达式可能会失败:

> String.raw`Backslash: "\\"`.replace(/(?<!\\)"(.*?)(?<!\\)"/g, '“$1”')
'Backslash: "\\\\"'

第二个反斜杠阻止了引号变成卷曲的形状。

如果我们让我们的保护符更复杂一些,我们可以解决这个问题(?:使得该组不被捕获):

(?<=^\\*)

新的保护符允许在引号之前有一对反斜杠:

> const regExp = /(?<=^\\*)"(.*?)(?<=^\\*)"/g;
> String.raw`Backslash: "\\"`.replace(regExp, '“$1”')
'Backslash: “\\\\”'

还有一个问题。这个保护符会阻止第一个引号在字符串开头时被匹配到:

> const regExp = /(?<=^\\*)"(.*?)(?<=^\\*)"/g;
> `"abc"`.replace(regExp, '“$1”')
'"abc"'

我们可以通过将第一个保护符改为:(?<=^\\*|^)来解决这个问题

> const regExp = /(?<=^\\*|^)"(.*?)(?<=^\\*)"/g;
> `"abc"`.replace(regExp, '“$1”')
'“abc”'

16.10 致谢

16.11 进一步阅读

评论

第七部分:杂项主题

原文:exploringjs.com/deep-js/pt_miscellaneous.html

译者:飞龙

协议:CC BY-NC-SA 4.0

接下来:17 通过实现来探索 Promises

十七、通过实现 Promise 来探索 Promise

原文:exploringjs.com/deep-js/ch_implementing-promises.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 17.1 复习:Promise 的状态
  • 17.2 版本 1:独立的 Promise
  • 17.2.1 方法.then()
  • 17.2.2 方法.resolve()
  • 17.3 版本 2:链接.then()调用
  • 17.4 便捷方法.catch()
  • 17.5 省略反应
  • 17.6 实现
  • 17.7 版本 3:扁平化从.then()回调返回的 Promise
  • 17.7.1 从.then()的回调中返回 Promise
  • 17.7.2 扁平化使 Promise 状态更加复杂
  • 17.7.3 实现 Promise 扁平化
  • 17.8 版本 4:在反应回调中抛出异常
  • 17.9 版本 5:揭示构造函数模式

所需知识:Promise

在本章中,您应该对 Promise 有一定了解,但这里也复习了许多相关知识。如果需要,您可以阅读“JavaScript for impatient programmers”中关于 Promise 的章节

在这一章中,我们将从不同的角度来接触 Promise:我们将创建一个简单的实现。这种不同的角度曾经帮助我很大地理解 Promise。

Promise 的实现是ToyPromise类。为了更容易理解,它并不完全匹配 API。但它足够接近,仍然能让我们深入了解 Promise 的工作原理。

带有代码的存储库

ToyPromise可以在 GitHub 上找到,存储在toy-promise存储库中。

17.1 复习:Promise 的状态


图 11:Promise 的状态(简化版本):Promise 最初是 pending 状态。如果我们解决它,它就会变成 fulfilled。如果我们拒绝它,它就会变成 rejected。

我们从一个简化版本开始解释 Promise 状态的工作方式(图 11):

  • Promise 最初是pending状态。
  • 如果一个 Promise 被值vresolved,它就会变成fulfilled(稍后,我们将看到解决也可以拒绝)。v现在是 Promise 的fulfillment value
  • 如果一个 Promise 被错误erejected,它就会变成rejectede现在是 Promise 的rejection value

17.2 版本 1:独立的 Promise

我们的第一个实现是一个独立的 Promise,具有最小的功能:

  • 我们可以创建一个 Promise。
  • 我们可以解决或拒绝一个 Promise,而且只能做一次。
  • 我们可以通过.then()注册reactions(回调)。注册必须独立于 Promise 是否已经解决或未解决而做正确的事情。
  • .then()目前不支持链接,它不返回任何东西。

ToyPromise1是一个具有三个原型方法的类:

  • ToyPromise1.prototype.resolve(value)
  • ToyPromise1.prototype.reject(reason)
  • ToyPromise1.prototype.then(onFulfilled, onRejected)

也就是说,resolvereject是方法(而不是传递给构造函数回调参数的函数)。

这是第一个实现的用法:

// .resolve() before .then()
const tp1 = new ToyPromise1();
tp1.resolve('abc');
tp1.then((value) => {
 assert.equal(value, 'abc');
});
// .then() before .resolve()
const tp2 = new ToyPromise1();
tp2.then((value) => {
 assert.equal(value, 'def');
});
tp2.resolve('def');

图 12 说明了我们的第一个ToyPromise是如何工作的。

Promises 中的数据流图是可选的

图表的动机是为 Promises 的工作原理提供一个视觉解释。但它们是可选的。如果你觉得它们令人困惑,你可以忽略它们,专注于代码。


图 12:ToyPromise1:如果 Promise 被解决,提供的值将传递给满足反应.then()的第一个参数)。如果 Promise 被拒绝,提供的值将传递给拒绝反应.then()的第二个参数)。

17.2.1 方法.then()

让我们先来看一下.then()。它必须处理两种情况:

  • 如果 Promise 仍处于挂起状态,则会排队调用onFulfilledonRejected。它们将在 Promise 解决时使用。
  • 如果 Promise 已经被满足或拒绝,onFulfilledonRejected可以立即被调用。
then(onFulfilled, onRejected) {
 const fulfillmentTask = () => {
 if (typeof onFulfilled === 'function') {
 onFulfilled(this._promiseResult);
 }
 };
 const rejectionTask = () => {
 if (typeof onRejected === 'function') {
 onRejected(this._promiseResult);
 }
 };
 switch (this._promiseState) {
 case 'pending':
 this._fulfillmentTasks.push(fulfillmentTask);
 this._rejectionTasks.push(rejectionTask);
 break;
 case 'fulfilled':
 addToTaskQueue(fulfillmentTask);
 break;
 case 'rejected':
 addToTaskQueue(rejectionTask);
 break;
 default:
 throw new Error();
 }
}

上一个代码片段使用以下辅助函数:

function addToTaskQueue(task) {
 setTimeout(task, 0);
}

Promise 必须始终异步解决。这就是为什么我们不直接执行任务,而是将它们添加到事件循环的任务队列中(浏览器、Node.js 等)。请注意,真正的 Promise API 不使用普通任务(如setTimeout()),它使用微任务,它们与当前普通任务紧密耦合,并且总是直接执行。

17.2.2 方法.resolve()

.resolve()的工作方式如下:如果 Promise 已经解决,它什么也不做(确保 Promise 只能解决一次)。否则,Promise 的状态将更改为'fulfilled',结果将缓存在this.promiseResult中。接下来,将调用到目前为止已排队的所有满足反应。

resolve(value) {
 if (this._promiseState !== 'pending') return this;
 this._promiseState = 'fulfilled';
 this._promiseResult = value;
 this._clearAndEnqueueTasks(this._fulfillmentTasks);
 return this; // enable chaining
}
_clearAndEnqueueTasks(tasks) {
 this._fulfillmentTasks = undefined;
 this._rejectionTasks = undefined;
 tasks.map(addToTaskQueue);
}

reject()类似于resolve()

17.3 版本 2:链接.then()调用


图 13:ToyPromise2链接.then()调用:.then()现在返回一个 Promise,该 Promise 由满足反应或拒绝反应返回的任何值解决。

我们实现的下一个特性是链接(图 13):我们从满足反应或拒绝反应中返回的值可以由后续的.then()调用中的满足反应处理。(在下一个版本中,由于特殊支持返回 Promises,链接将变得更加有用。)

在下面的示例中:

  • 第一个.then():我们在满足反应中返回一个值。
  • 第二个.then():我们通过满足反应接收该值。
new ToyPromise2()
 .resolve('result1')
 .then(x => {
 assert.equal(x, 'result1');
 return 'result2';
 })
 .then(x => {
 assert.equal(x, 'result2');
 });

在下面的示例中:

  • 第一个.then():我们在拒绝反应中返回一个值。
  • 第二个.then():我们通过满足反应接收该值。
new ToyPromise2()
 .reject('error1')
 .then(null,
 x => {
 assert.equal(x, 'error1');
 return 'result2';
 })
 .then(x => {
 assert.equal(x, 'result2');
 });

17.4 便利方法.catch()

新版本引入了一个方便的方法.catch(),使得只提供拒绝反应更容易。请注意,只提供满足反应已经很容易 - 我们只需省略.then()的第二个参数(参见上一个示例)。

如果我们使用它,上一个示例看起来更好(A 行):

new ToyPromise2()
 .reject('error1')
 .catch(x => { // (A)
 assert.equal(x, 'error1');
 return 'result2';
 })
 .then(x => {
 assert.equal(x, 'result2');
 });

以下两个方法调用是等效的:

.catch(rejectionReaction)
.then(null, rejectionReaction)

这就是.catch()的实现方式:

catch(onRejected) { // [new]
 return this.then(null, onRejected);
}

17.5 省略反应

新版本还会在我们省略满足反应时转发满足,并在我们省略拒绝反应时转发拒绝。这有什么用呢?

以下示例演示了如何传递拒绝:

someAsyncFunction()
 .then(fulfillmentReaction1)
 .then(fulfillmentReaction2)
 .catch(rejectionReaction);

rejectionReaction现在可以处理someAsyncFunction()fulfillmentReaction1fulfillmentReaction2的拒绝。

以下示例演示了如何传递满足:

someAsyncFunction()
 .catch(rejectionReaction)
 .then(fulfillmentReaction);

如果someAsyncFunction()拒绝了它的 Promise,rejectionReaction可以修复任何问题并返回一个完成值,然后由fulfillmentReaction处理。

如果someAsyncFunction()实现了它的 Promise,fulfillmentReaction也可以处理它,因为.catch()被跳过了。

17.6 实现

所有这些是如何在底层处理的?

  • .then()返回一个 Promise,该 Promise 解析为onFulfilledonRejected返回的内容。
  • 如果onFulfilledonRejected丢失,无论它们将接收到什么都会传递给由.then()返回的 Promise。

只有.then()发生了变化:

then(onFulfilled, onRejected) {
 const resultPromise = new ToyPromise2(); // [new]
 const fulfillmentTask = () => {
 if (typeof onFulfilled === 'function') {
 const returned = onFulfilled(this._promiseResult);
 resultPromise.resolve(returned); // [new]
 } else { // [new]
 // `onFulfilled` is missing
 // => we must pass on the fulfillment value
 resultPromise.resolve(this._promiseResult);
 } 
 };
 const rejectionTask = () => {
 if (typeof onRejected === 'function') {
 const returned = onRejected(this._promiseResult);
 resultPromise.resolve(returned); // [new]
 } else { // [new]
 // `onRejected` is missing
 // => we must pass on the rejection value
 resultPromise.reject(this._promiseResult);
 }
 };
 ···
 return resultPromise; // [new]
}

.then()创建并返回一个新的 Promise(方法的第一行和最后一行)。另外:

  • fulfillmentTask的工作方式不同。现在完成后会发生什么:
  • 如果提供了onFullfilled,则调用它并使用其结果来解析resultPromise
  • 如果onFulfilled丢失,我们使用当前 Promise 的完成值来解析resultPromise
  • rejectionTask的工作方式不同。这是拒绝后现在发生的事情:
  • 如果提供了onRejected,则调用它并使用其结果来解析resultPromise。请注意,resultPromise不会被拒绝:我们假设onRejected()修复了任何问题。
  • 如果onRejected丢失,我们使用当前 Promise 的拒绝值来拒绝resultPromise

17.7 版本 3:扁平化从.then()回调返回的 Promises

17.7.1 从.then()回调返回 Promises

Promise 扁平化主要是为了使链接更加方便:如果我们想要将一个值从一个.then()回调传递到下一个回调,我们在前者中返回它。之后,.then()将其放入它已经返回的 Promise 中。

如果我们从.then()回调返回一个 Promise,这种方法就变得不方便。例如,基于 Promise 的函数的结果(A 行):

asyncFunc1()
.then((result1) => {
 assert.equal(result1, 'Result of asyncFunc1()');
 return asyncFunc2(); // (A)
})
.then((result2Promise) => {
 result2Promise
 .then((result2) => { // (B)
 assert.equal(
 result2, 'Result of asyncFunc2()');
 });
});

这一次,将 A 行返回的值放入由.then()返回的 Promise 中,迫使我们在 B 行解开该 Promise。如果能够让 A 行返回的 Promise 替换由.then()返回的 Promise,那将是很好的。如何确切地做到这一点并不立即清楚,但如果成功,我们可以像这样编写我们的代码:

asyncFunc1()
.then((result1) => {
 assert.equal(result1, 'Result of asyncFunc1()');
 return asyncFunc2(); // (A)
})
.then((result2) => {
 // result2 is the fulfillment value, not the Promise
 assert.equal(
 result2, 'Result of asyncFunc2()');
});

在 A 行,我们返回了一个 Promise。由于 Promise 扁平化,result2是该 Promise 的完成值,而不是 Promise 本身。

17.7.2 扁平化使 Promise 状态变得更加复杂

在 ECMAScript 规范中扁平化 Promises

在 ECMAScript 规范中,扁平化 Promises 的细节在“Promise Objects”部分中有描述。

Promise API 如何处理扁平化?

如果 Promise P 用 Promise Q 解析,那么 P 不会包装 Q,P“变成”Q:P 的状态和解决值现在总是与 Q 的相同。这有助于我们理解.then(),因为.then()将其返回的 Promise 解析为其回调之一返回的值。

P 如何变成 Q?通过锁定Q:P 变得外部无法解决,Q 的解决会触发 P 的解决。锁定是一个额外的不可见的 Promise 状态,使状态变得更加复杂。

Promise API 还有一个额外的特性:Q 不必是一个 Promise,只需是一个所谓的thenable。thenable 是一个带有方法.then()的对象。增加这种灵活性的原因是为了使不同的 Promise 实现能够一起工作(当 Promise 首次添加到语言中时很重要)。

图[14](#fig:promise-states-all)可视化了新的状态。


图 14:Promise 的所有状态:Promise 扁平化引入了不可见的伪状态“锁定”。如果 Promise P 用 thenable Q 解析,那么 P 的状态和解决值总是与 Q 相同。

请注意,解决的概念也变得更加复杂。现在解决一个 Promise 只意味着它不能直接被解决:

  • 解决可能会拒绝一个 Promise:我们可以用一个被拒绝的 Promise 来解决一个 Promise。
  • 解决甚至可能不会解决一个 Promise:我们可以用另一个始终处于挂起状态的 Promise 来解决一个 Promise。

ECMAScript 规范是这样规定的:“一个未解决的 Promise 始终处于挂起状态。已解决的 Promise 可能是挂起的、已实现的或已拒绝的。”

17.7.3 实现 Promise 扁平化

图 15 显示了ToyPromise3如何处理扁平化。


图 15:ToyPromise3扁平化已解决的 Promises:如果第一个 Promise 以一个 thenable x1解决,它将锁定在x1上,并以x1的解决值解决。如果第一个 Promise 以一个非 thenable 值解决,一切都与以前一样。

我们通过这个函数检测 thenables:

function isThenable(value) { // [new]
 return typeof value === 'object' && value !== null
 && typeof value.then === 'function';
}

为了实现锁定,我们引入了一个新的布尔标志._alreadyResolved。将其设置为true会停用.resolve().reject(),例如:

resolve(value) { // [new]
 if (this._alreadyResolved) return this;
 this._alreadyResolved = true;
 if (isThenable(value)) {
 // Forward fulfillments and rejections from `value` to `this`.
 // The callbacks are always executed asynchronously
 value.then(
 (result) => this._doFulfill(result),
 (error) => this._doReject(error));
 } else {
 this._doFulfill(value);
 }
 return this; // enable chaining
}

如果value是一个 thenable,那么我们将当前 Promise 锁定在它上面:

  • 如果value以结果实现,当前 Promise 也将以该结果实现。
  • 如果value以错误拒绝,当前 Promise 也将以该错误拒绝。

通过私有方法._doFulfill()._doReject()执行结算,以绕过._alreadyResolved的保护。

._doFulfill()相对简单:

_doFulfill(value) { // [new]
 assert.ok(!isThenable(value));
 this._promiseState = 'fulfilled';
 this._promiseResult = value;
 this._clearAndEnqueueTasks(this._fulfillmentTasks);
}

这里没有显示.reject()。它唯一的新功能是它现在也遵守._alreadyResolved

17.8 版本 4:反应回调中抛出的异常


图 16:ToyPromise4将 Promise 反应中的异常转换为.then()返回的 Promise 的拒绝。

作为我们的最终特性,我们希望我们的 Promises 能够将用户代码中的异常作为拒绝处理(图 16)。在本章中,“用户代码”指的是.then()的两个回调参数。

new ToyPromise4()
 .resolve('a')
 .then((value) => {
 assert.equal(value, 'a');
 throw 'b'; // triggers a rejection
 })
 .catch((error) => {
 assert.equal(error, 'b');
 })

.then()现在通过辅助方法._runReactionSafely()安全地运行 Promise 反应onFulfilledonRejected,例如:

const fulfillmentTask = () => {
 if (typeof onFulfilled === 'function') {
 this._runReactionSafely(resultPromise, onFulfilled); // [new]
 } else {
 // `onFulfilled` is missing
 // => we must pass on the fulfillment value
 resultPromise.resolve(this._promiseResult);
 } 
 };

._runReactionSafely()的实现如下:

_runReactionSafely(resultPromise, reaction) { // [new]
 try {
 const returned = reaction(this._promiseResult);
 resultPromise.resolve(returned);
 } catch (e) {
 resultPromise.reject(e);
 }
}

17.9 版本 5:揭示构造函数模式

我们跳过了最后一步:如果我们想要将ToyPromise转换为一个实际的 Promise 实现,我们仍然需要实现揭示构造函数模式:JavaScript Promises 不是通过方法而是通过函数来解决和拒绝的,这些函数被传递给执行程序,构造函数的回调参数。

const promise = new Promise(
 (resolve, reject) => { // executor
 // ···
 });

如果执行程序抛出异常,则promise将被拒绝。

Comments

十八、元编程与代理

原文:exploringjs.com/deep-js/ch_proxies.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 18.1 概述
  • 18.2 编程与元编程
  • 18.2.1 元编程的种类
  • 18.3 代理解释
  • 18.3.1 一个例子
  • 18.3.2 特定函数陷阱
  • 18.3.3 拦截方法调用
  • 18.3.4 可撤销代理
  • 18.3.5 代理作为原型
  • 18.3.6 转发拦截操作
  • 18.3.7 陷阱:并非所有对象都可以被代理透明包装
  • 18.4 代理的用例
  • 18.4.1 跟踪属性访问(getset
  • 18.4.2 关于未知属性的警告(getset
  • 18.4.3 负数组索引(get
  • 18.4.4 数据绑定(set
  • 18.4.5 访问 restful web 服务(方法调用)
  • 18.4.6 可撤销引用
  • 18.4.7 在 JavaScript 中实现 DOM
  • 18.4.8 更多用例
  • 18.4.9 使用代理的库
  • 18.5 代理 API 的设计
  • 18.5.1 分层:保持基本级别和元级别分开
  • 18.5.2 虚拟对象与包装器
  • 18.5.3 透明虚拟化和处理程序封装
  • 18.5.4 元对象协议和代理陷阱
  • 18.5.5 强制代理的不变性
  • 18.6 常见问题:代理
  • 18.6.1 enumerate陷阱在哪里?
  • 18.7 参考:代理 API
  • 18.7.1 创建代理
  • 18.7.2 处理程序方法
  • 18.7.3 处理程序方法的不变性
  • 18.7.4 影响原型链的操作
  • 18.7.5 反射
  • 18.8 结论
  • 18.9 进一步阅读

18.1 概述

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

在以下示例中:

  • proxy是一个空对象。
  • 通过实现特定方法,handler可以拦截对proxy执行的操作。
  • 如果处理程序不拦截操作,则将其转发到target

我们只拦截一个操作 - 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执行许多操作,例如:

  • 获取对象obj的属性propobj.prop
  • 检查对象obj是否具有属性prop'prop' in obj

代理是特殊的对象,允许我们定制其中一些操作。代理是由两个参数创建的:

  • handler:对于每个操作,都有一个相应的处理程序方法,如果存在,就执行该操作。这样的方法拦截了操作(在其传递到目标的途中),并被称为陷阱——这个术语是从操作系统的领域借来的。
  • target:如果处理程序不拦截操作,那么它将在目标上执行。也就是说,它充当处理程序的后备。在某种程度上,代理包装了目标。

注意:“介入”的动词形式是“介入”。介入是双向的。拦截是单向的。

18.3.1 一个示例

在下面的示例中,处理程序拦截了gethas操作。

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:进行函数调用。通过以下方式触发:
  • proxy(···)
  • proxy.call(···)
  • proxy.apply(···)
  • construct:进行构造函数调用。通过以下方式触发:
  • new proxy(···)

之所以只为函数目标启用这些陷阱的原因很简单:否则,我们将无法转发操作applyconstruct

18.3.3 拦截方法调用

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

  • get检索函数
  • 一个apply来调用那个函数

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

  • 首先,我们拦截get并返回一个函数。
  • 其次,我们拦截该函数的调用。

以下代码演示了如何实现这一点。

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 ReflecttrapName;
 };
 },
});

对于每个陷阱,代理通过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陷阱:

  • 如果在代理之后的原型链中甚至不存在属性,则确实缺少该属性,我们会抛出异常。
  • 否则,我们返回继承属性的值。我们通过将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 防止意外创建属性

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

  • 我们可以将代理包装在捕获set的对象周围。
  • 或者我们可以通过Object.preventExtensions(obj)使对象obj不可扩展,这意味着 JavaScript 不允许我们向obj添加新的(自有)属性。
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 中实现它对于以下情况很有用:

  • 模拟浏览器环境,例如在 Node.js 中操作 HTML。jsdom 是一个可以实现这一功能的库。
  • 加快 DOM 的速度(在 JavaScript 和 C++之间切换需要时间)。

然而,标准的 DOM 可以做一些在 JavaScript 中不容易复制的事情。例如,大多数 DOM 集合都是对 DOM 当前状态的动态更改的实时视图。因此,纯 JavaScript 实现的 DOM 并不是非常高效的。向 JavaScript 添加代理的原因之一是为了实现更高效的 DOM。

18.4.8 更多用例

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

  • 远程:本地占位符对象将方法调用转发到远程对象。这个用例类似于 Web 服务的例子。
  • 数据库的数据访问对象:读取和写入对象会读取和写入数据库。这个用例类似于 Web 服务的例子。
  • 分析:拦截方法调用以跟踪每个方法花费的时间。这个用例类似于跟踪的例子。
18.4.9 使用代理的库
  • Immer(由 Michel Weststrate)有助于非破坏性地更新数据。应用的更改是通过调用方法、设置属性、设置数组元素等来指定的。草案状态是通过代理实现的。
  • MobX 让您观察数据结构(如对象、数组和类实例)的更改。这是通过代理实现的。
  • Alpine.js(由 Caleb Porzio)是一个前端库,通过代理实现数据绑定。
  • on-change(由 Sindre Sorhus)观察对象的更改(通过代理)并报告它们。
  • Env utility(由 Nicholas C. Zakas)允许您通过属性访问环境变量,并在它们不存在时抛出异常。这是通过代理实现的。
  • LDflex(由 Ruben Verborgh 和 Ruben Taelman)提供了一个用于链接数据(考虑语义网络)的查询语言。流畅的查询 API 是通过代理实现的。

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__的工作方式类似于代理陷阱。与代理相反,陷阱是我们想要拦截其操作的对象的自有或继承方法。这种方法的问题在于基本级别(普通方法)和元级别(__noSuchMethod__)混合在一起。基本级别的代码可能会意外调用或看到元级别的方法,并且可能会意外定义一个元级别的方法。

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

  • obj.hasOwnProperty(propKey): 如果原型链中的属性覆盖了内置实现,则此调用可能会失败。例如,在以下代码中,obj会导致失败:
const obj = { hasOwnProperty: null };
assert.throws(
 () => obj.hasOwnProperty('width'),
 /^TypeError: obj.hasOwnProperty is not a function/
);
  • 这些是调用.hasOwnProperty()的安全方式:
assert.equal(
 Object.prototype.hasOwnProperty.call(obj, 'width'), false);
// Abbreviated version:
assert.equal(
 {}.hasOwnProperty.call(obj, 'width'), false);
  • func.call(···), func.apply(···): 对于这两种方法,问题和解决方案与.hasOwnProperty()相同。
  • obj.__proto__: 在普通对象中,__proto__是一个特殊属性,它允许我们获取和设置接收者的原型。因此,当我们将普通对象用作字典时,我们必须避免将__proto__作为属性键

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

18.5.2 虚拟对象与包装器

代理有两种角色:

  • 作为包装器,它们包装它们的目标,控制对它们的访问。包装器的示例包括:可撤销资源和通过代理进行跟踪。
  • 作为虚拟对象,它们只是具有特殊行为的对象,它们的目标并不重要。一个例子是代理,它将方法调用转发到远程对象。

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

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

代理有两种方式进行屏蔽:

  • 无法确定对象是否是代理(透明虚拟化)。
  • 我们无法通过其代理访问处理程序(处理程序封装)。

这两个原则赋予了代理模式相当大的权力,可以模拟其他对象。强制执行不变量(稍后解释)的一个原因是为了控制这种权力。

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

// 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来跟踪代理。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 元对象协议和代理陷阱

在本节中,我们将研究 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 方法有:

  • [[GetOwnProperty]](陷阱getOwnPropertyDescriptor
  • [[GetPrototypeOf]](陷阱getPrototypeOf
  • [[Get]](陷阱get
  • [[Call]](陷阱apply

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

**基本与派生操作。**我们可以看到.[[Get]]()调用其他 MOP 操作。这样做的操作称为派生。不依赖其他操作的操作称为基本

18.5.4.1 代理的元对象协议

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

哪些操作应该通过代理进行拦截?

  • 一种可能性是只为基本操作提供陷阱。
  • 另一种选择是包括一些派生操作。

这样做的好处是可以提高性能并更加方便。例如,如果没有get的陷阱,我们将不得不通过getOwnPropertyDescriptor来实现其功能。

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

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

代理的拦截是选择性的:我们无法拦截每个语言操作。为什么有些操作被排除在外?让我们看两个原因。

首先,稳定操作不太适合拦截。如果一个操作总是对相同的参数产生相同的结果,则该操作是稳定的。如果代理可以拦截稳定操作,它可能会变得不稳定,因此不可靠。严格相等===)就是这样一个稳定操作。它无法被拦截,其结果是通过将代理本身视为另一个对象来计算的。另一种保持稳定性的方法是将操作应用于目标而不是代理。稍后将在我们看如何对代理执行不变性时解释,当Object.getPrototypeOf()应用于目标不可扩展的代理时会发生这种情况。

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

18.5.4.3 陷阱:getinvoke

如果我们想通过代理创建虚拟方法,我们必须从get陷阱中返回函数。这引发了一个问题:为什么不引入一个额外的陷阱来处理方法调用(例如invoke)?这样我们就可以区分:

  • 通过obj.prop获取属性(陷阱get
  • 通过obj.prop()调用方法(陷阱invoke

有两个原因不这样做。

首先,并非所有实现都区分getinvoke。例如,苹果的 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时才能完成。因此,这些事情在当前的代理 API 中是不可能的。两个例子是:自动绑定和拦截丢失的方法。让我们看看如果代理支持invoke,我们将如何实现它们。

**自动绑定。**通过将代理设置为对象obj的原型,我们可以自动绑定方法:

  • 通过obj.m获取方法m的值将返回一个this绑定到obj的函数。
  • obj.m()执行方法调用。

自动绑定有助于使用方法作为回调。例如,前面示例中的第 2 个变体变得更简单:

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

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

  • 如果通过obj.prop读取该属性,则不会发生拦截,返回undefined
  • 如果我们进行方法调用obj.prop(),那么代理会拦截,并且,例如,通知一个回调。
18.5.5 强制执行代理的不变量

在我们讨论不变量是什么以及如何通过代理来强制执行它们之前,让我们回顾一下通过非可扩展性和非可配置性来保护对象的方法。

18.5.5.1 保护对象

保护对象的两种方法:

  • 非可扩展性保护对象:如果一个对象是非可扩展的,我们就不能添加属性,也不能改变它的原型。
  • 非可配置性保护属性(或者说,它们的属性):
  • 布尔属性writable控制属性的值是否可以更改。
  • 布尔属性configurable控制属性的属性是否可以更改。

有关此主题的更多信息,请参见§10“保护对象免受更改”。

18.5.5.2 强制执行不变量

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

  • 通用:它们适用于所有对象。
  • 单调:一旦打开,就不能再关闭。

这些以及其他在语言操作面前保持不变的特征被称为不变量。通过代理很容易违反不变量,因为它们不是通过非可扩展性等固有地受限制的。代理 API 通过检查目标对象和处理程序方法的结果来防止这种情况发生。

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

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

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

  • 不变性:如果Object.preventExtensions(obj)返回true,则所有未来的调用必须返回false,并且obj现在必须是不可扩展的。
  • 通过抛出TypeError来强制执行代理,如果处理程序返回true,但目标对象不可扩展。
  • 不变性:一旦对象被设置为不可扩展,Object.isExtensible(obj)必须始终返回false
  • 通过抛出TypeError来强制执行代理,如果处理程序返回的结果(在强制转换后)与Object.isExtensible(target)不同。
18.5.5.4 通过检查返回值强制执行的两个不变性

通过检查返回值强制执行的两个不变性是:

  • 不变性:Object.isExtensible(obj)必须返回一个布尔值。
  • 通过强制处理程序返回的值转换为布尔值来强制执行代理。
  • 不变性:Object.getOwnPropertyDescriptor(obj, ···)必须返回一个对象或undefined
  • 通过抛出TypeError来强制执行代理,如果处理程序没有返回适当的值。
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 最初有一个名为enumerate的陷阱,它由for-in循环触发。但最近已经删除,以简化代理。Reflect.enumerate()也被删除了。(来源:TC39 笔记)

18.7 参考:代理 API

本节是代理 API 的快速参考:

  • 全局对象Proxy
  • 全局对象Reflect

引用使用以下自定义类型:

type PropertyKey = string | symbol;
18.7.1 创建代理

有两种创建代理的方法:

  • const proxy = new Proxy(target, handler)
    使用给定的目标和给定的处理程序创建一个新的代理对象。
  • const {proxy, revoke} = Proxy.revocable(target, handler)
    创建一个可以通过函数revoke撤销的代理。revoke可以被多次调用,但只有第一次调用会产生效果并关闭proxy。之后,对proxy执行的任何操作都会导致抛出TypeError
18.7.2 处理程序方法

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

所有对象的陷阱:

  • defineProperty(target, propKey, propDesc): boolean
  • Object.defineProperty(proxy, propKey, propDesc)
  • deleteProperty(target, propKey): boolean
  • delete proxy[propKey]
  • delete proxy.someProp
  • get(target, propKey, receiver): any
  • receiver[propKey]
  • receiver.someProp
  • getOwnPropertyDescriptor(target, propKey): undefined|PropDesc
  • Object.getOwnPropertyDescriptor(proxy, propKey)
  • getPrototypeOf(target): null|object
  • Object.getPrototypeOf(proxy)
  • has(target, propKey): boolean
  • propKey in proxy
  • isExtensible(target): boolean
  • Object.isExtensible(proxy)
  • ownKeys(target): Array<PropertyKey>
  • Object.getOwnPropertyPropertyNames(proxy)(仅使用字符串键)
  • Object.getOwnPropertyPropertySymbols(proxy)(仅使用符号键)
  • Object.keys(proxy)(仅使用可枚举的字符串键;通过Object.getOwnPropertyDescriptor检查可枚举性)
  • preventExtensions(target): boolean
  • Object.preventExtensions(proxy)
  • set(target, propKey, value, receiver): boolean
  • receiver[propKey] = value
  • receiver.someProp = value
  • setPrototypeOf(target, proto): boolean
  • Object.setPrototypeOf(proxy, proto)

函数的陷阱(仅当目标是函数时可用):

  • apply(target, thisArgument, argumentsList): any
  • proxy.apply(thisArgument, argumentsList)
  • proxy.call(thisArgument, ...argumentsList)
  • proxy(...argumentsList)
  • construct(target, argumentsList, newTarget): object
  • new proxy(..argumentsList)
18.7.2.1 基本操作与派生操作

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

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

18.7.3 处理程序方法的不变量

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

  • 如果期望布尔值,则使用强制转换将非布尔值转换为合法值。
  • 在所有其他情况下,非法值会导致TypeError

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

  • apply(target, thisArgument, argumentsList): any
  • 不强制执行任何不变量。
  • 仅当目标可调用时才激活。
  • construct(target, argumentsList, newTarget): object
  • 处理程序返回的结果必须是对象(而不是null或任何其他原始值)。
  • 仅当目标可构造时才激活。
  • defineProperty(target, propKey, propDesc): boolean
  • 如果目标不可扩展,则无法添加新属性。
  • 如果propDescconfigurable属性设置为false,则目标必须具有一个不可配置的自有属性,其键为propKey
  • 如果propDescconfigurablewritable属性都设置为false,则目标必须具有一个键为propKey的自有属性,该属性不可配置且不可写。
  • 如果目标具有键为propKey的自有属性,则propDesc必须与该属性兼容:如果我们使用描述符重新定义目标属性,则不得抛出异常。
  • deleteProperty(target, propKey): boolean
  • 如果:
  • 目标对象具有一个键为propKey的不可配置的自有属性。
  • 目标对象是不可扩展的,并且具有一个键为propKey的自有属性。
  • get(target, propKey, receiver): any
  • 如果目标对象具有一个自有的、不可写的、不可配置的数据属性,其键为propKey,则处理程序必须返回该属性的值。
  • 如果目标对象有一个自有的、不可配置的、没有 getter 的访问器属性,那么处理程序必须返回undefined
  • getOwnPropertyDescriptor(target, propKey): undefined|PropDesc
  • 处理程序必须返回undefined或一个对象。
  • 目标对象的不可配置的自有属性不能被处理程序报告为不存在。
  • 如果目标对象是不可扩展的,则处理程序必须报告目标对象的自有属性存在。
  • 如果处理程序报告一个属性为不可配置,则该属性必须是目标对象的不可配置的自有属性。
  • 如果处理程序报告一个属性为不可配置且不可写,那么该属性必须是目标对象的不可配置不可写的自有属性。
  • getPrototypeOf(target): null|object
  • 结果必须是null或者一个对象。
  • 如果目标对象不可扩展,则处理程序必须返回目标对象的原型。
  • has(target, propKey): boolean
  • 目标对象的不可配置的自有属性不能被处理程序报告为不存在。
  • 如果目标对象是不可扩展的,那么目标对象的自有属性不能被报告为不存在。
  • isExtensible(target): boolean
  • 在转换为布尔值后,处理程序返回的值必须与target.isExtensible()相同。
  • ownKeys(target): Array<PropertyKey>
  • 处理程序必须返回一个对象,该对象被视为类似数组,并转换为数组。
  • 生成的数组不能包含重复条目。
  • 结果的每个元素必须是字符串或符号。
  • 结果必须包含目标对象的所有不可配置的自有属性的键。
  • 如果目标对象不可扩展,则结果必须恰好包含目标对象的自有属性的键(没有其他值)。
  • preventExtensions(target): boolean
  • 如果target.isExtensible()false,则处理程序只能返回一个真值(表示成功更改)。
  • set(target, propKey, value, receiver): boolean
  • 如果目标对象具有一个不可写的、不可配置的数据属性,其键为propKey,则处理程序必须返回该属性的值。
  • 如果相应的目标对象属性是一个不可配置的访问器且没有 setter,则无法以任何方式设置该属性。
  • setPrototypeOf(target, proto): boolean
  • 如果目标对象不可扩展,则原型不能被更改。这是如何实施的:如果目标对象不可扩展且处理程序返回一个真值(表示成功更改),则proto必须与目标对象的原型相同。否则,将抛出TypeError

ECMAScript 规范中的不变量

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

18.7.4 影响原型链的操作

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

  • target.get(propertyKey, receiver)
    如果target没有具有给定键的自有属性,则在target的原型上调用get
  • target.has(propertyKey)
    类似于get,如果target没有具有给定键的自有属性,则在target的原型上调用has
  • target.set(propertyKey, value, receiver)
    类似于get,如果target没有具有给定键的自有属性,则在target的原型上调用set

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

ECMAScript 规范中的内部操作

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

18.7.5 Reflect

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

  • Reflect.apply(target, thisArgument, argumentsList): any
    类似于Function.prototype.apply()
  • Reflect.construct(target, argumentsList, newTarget=target): object
    new操作符作为一个函数。target是要调用的构造函数,可选参数newTarget指向启动当前构造函数调用链的构造函数。
  • Reflect.defineProperty(target, propertyKey, propDesc): boolean
    类似于Object.defineProperty()
  • Reflect.deleteProperty(target, propertyKey): boolean
    delete操作符作为一个函数。但它的工作方式略有不同:如果成功删除属性或属性从未存在,则返回true。如果属性无法删除且仍然存在,则返回false。保护属性免受删除的唯一方法是使它们不可配置。在松散模式下,delete操作符返回相同的结果。但在严格模式下,它会抛出TypeError而不是返回false
  • Reflect.get(target, propertyKey, receiver=target): any
    一个获取属性的函数。可选参数receiver指向获取开始的对象。当get在原型链中后面达到 getter 时,需要它。然后它为this提供值。
  • Reflect.getOwnPropertyDescriptor(target, propertyKey): undefined|PropDesc
    Object.getOwnPropertyDescriptor()相同。
  • Reflect.getPrototypeOf(target): null|object
    Object.getPrototypeOf()相同。
  • Reflect.has(target, propertyKey): boolean
    in操作符作为一个函数。
  • Reflect.isExtensible(target): boolean
    Object.isExtensible()相同。
  • Reflect.ownKeys(target): Array<PropertyKey>
    以数组形式返回所有自有属性键:所有自有可枚举和不可枚举属性的字符串键和符号键。
  • Reflect.preventExtensions(target): boolean
    类似于Object.preventExtensions()
  • Reflect.set(target, propertyKey, value, receiver=target): boolean
    一个设置属性的函数。
  • Reflect.setPrototypeOf(target, proto): boolean
    设置对象原型的新标准方式。目前大多数引擎中有效的非标准方式是设置特殊属性__proto__

几种方法具有布尔结果。对于.has().isExtensible(),它们是操作的结果。对于其余的方法,它们指示操作是否成功。

18.7.5.1 Reflect的用例除了转发

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

  • 不同的返回值:Reflect复制了Object的以下方法,但其方法返回布尔值,指示操作是否成功(而Object方法返回被修改的对象)。
  • Object.defineProperty(obj, propKey, propDesc): object
  • Object.preventExtensions(obj): object
  • Object.setPrototypeOf(obj, proto): object
  • 作为函数的运算符:以下Reflect方法实现了通过运算符才能实现的功能:
  • Reflect.construct(target, argumentsList, newTarget=target): object
  • Reflect.deleteProperty(target, propertyKey): boolean
  • Reflect.get(target, propertyKey, receiver=target): any
  • Reflect.has(target, propertyKey): boolean
  • Reflect.set(target, propertyKey, value, receiver=target): boolean
  • apply()的简短版本:如果我们想完全安全地调用函数的apply()方法,我们不能通过动态分发来做到这一点,因为函数可能具有一个具有键'apply'的自有属性:
func.apply(thisArg, argArray) // not safe
Function.prototype.apply.call(func, thisArg, argArray) // safe
  • 使用Reflect.apply()比安全版本更短:
Reflect.apply(func, thisArg, argArray)
  • 删除属性时不会抛出异常:在严格模式下,如果我们尝试删除一个不可配置的自有属性,delete运算符会抛出异常。在这种情况下,Reflect.deleteProperty()会返回false
18.7.5.2 Object.*Reflect.*

未来,Object将承载对普通应用程序有兴趣的操作,而Reflect将承载更低级的操作。

18.8 结论

这结束了我们对代理 API 的深入研究。需要注意的一点是,代理会减慢代码。如果性能很重要,这可能很重要。

另一方面,性能通常并不是关键,拥有代理赋予我们的元编程能力是很好的。


致谢:

  • Allen Wirfs-Brock 指出了§18.3.7“陷阱:并非所有对象都可以被代理透明地包装”中解释的陷阱。
  • §18.4.3“通过代理使用负数组索引(get)”的想法来自Hemanth.HM博客文章
  • André Jaenisch 为使用代理的库列表做出了贡献。

18.9 进一步阅读

评论

十九、剩下的章节在哪里?


您正在阅读本书的免费在线版本。

  • 离线版本 - 包括三个额外的奖励章节 - 可以购买
  • 您可以查看完整的目录(也链接到书的主页)。


相关文章
|
1月前
|
Web App开发 JavaScript 前端开发
Node.js 是一种基于 Chrome V8 引擎的后端开发技术,以其高效、灵活著称。本文将介绍 Node.js 的基础概念
Node.js 是一种基于 Chrome V8 引擎的后端开发技术,以其高效、灵活著称。本文将介绍 Node.js 的基础概念,包括事件驱动、单线程模型和模块系统;探讨其安装配置、核心模块使用、实战应用如搭建 Web 服务器、文件操作及实时通信;分析项目结构与开发流程,讨论其优势与挑战,并通过案例展示 Node.js 在实际项目中的应用,旨在帮助开发者更好地掌握这一强大工具。
46 1
|
2月前
|
JavaScript 前端开发 中间件
探索后端技术:Node.js与Express框架的完美融合
【10月更文挑战第7天】 在当今数字化时代,Web应用已成为日常生活不可或缺的一部分。本文将深入探讨后端技术的两大重要角色——Node.js和Express框架,分析它们如何通过其独特的特性和优势,为现代Web开发提供强大支持。我们将从Node.js的非阻塞I/O和事件驱动机制,到Express框架的简洁路由和中间件特性,全面解析它们的工作原理及应用场景。此外,本文还将分享一些实际开发中的小技巧,帮助你更有效地利用这些技术构建高效、可扩展的Web应用。无论你是刚入门的新手,还是经验丰富的开发者,相信这篇文章都能为你带来新的启发和思考。
|
2月前
|
JavaScript 前端开发 API
探索后端技术:Node.js的优势和实际应用
【10月更文挑战第6天】 在当今数字化时代,后端开发是任何成功软件应用的关键组成部分。本文将深入探讨一种流行的后端技术——Node.js,通过分析其核心优势和实际应用案例,揭示其在现代软件开发中的重要性和潜力。
178 2
|
1月前
|
缓存 JavaScript 前端开发
JavaScript 与 DOM 交互的基础及进阶技巧,涵盖 DOM 获取、修改、创建、删除元素的方法,事件处理,性能优化及与其他前端技术的结合,助你构建动态交互的网页应用
本文深入讲解了 JavaScript 与 DOM 交互的基础及进阶技巧,涵盖 DOM 获取、修改、创建、删除元素的方法,事件处理,性能优化及与其他前端技术的结合,助你构建动态交互的网页应用。
47 5
|
1月前
|
移动开发 前端开发 JavaScript
前端实训,刚入门,我用原生技术(H5、C3、JS、JQ)手写【网易游戏】页面特效
于辰在大学期间带领团队参考网易游戏官网的部分游戏页面,开发了一系列前端实训作品。项目包括首页、2021校园招聘页面和明日之后游戏页面,涉及多种特效实现,如动态图片切换和人物聚合效果。作品源码已上传至CSDN,视频效果可在CSDN预览。
45 0
|
1月前
|
JavaScript 前端开发 中间件
JS服务端技术—Node.js知识点
本文介绍了Node.js中的几个重要模块,包括NPM、Buffer、fs模块、path模块、express模块、http模块以及mysql模块。每部分不仅提供了基础概念,还推荐了相关博文供深入学习。特别强调了express模块的使用,包括响应相关函数、中间件、Router和请求体数据解析等内容。文章还讨论了静态资源无法访问的问题及其解决方案,并总结了一些通用设置。适合Node.js初学者参考学习。
44 1
|
2月前
|
前端开发 JavaScript 安全
JavaScript前端开发技术
JavaScript(简称JS)是一种广泛使用的脚本语言,特别在前端开发领域,它几乎成为了网页开发的标配。从简单的表单验证到复杂的单页应用(SPA),JavaScript都扮演着不可或缺的角色。
34 3
|
2月前
|
缓存 前端开发 JavaScript
探索JavaScript的现代前端技术:从基础到进阶
探索JavaScript的现代前端技术:从基础到进阶
66 1
|
4月前
|
机器学习/深度学习 人工智能 自然语言处理
深度学习中的图像识别技术深入理解Node.js事件循环及其在后端开发中的应用
【8月更文挑战第27天】本文将介绍深度学习中的图像识别技术,包括其原理、应用领域及未来发展。我们将探讨如何通过神经网络实现图像识别,并分析其在医疗、交通等领域的应用。最后,我们将展望图像识别技术的发展前景。
|
4月前
|
运维 Cloud Native JavaScript
云端新纪元:云原生技术深度解析深入理解Node.js事件循环及其在异步编程中的应用
【8月更文挑战第27天】随着云计算技术的飞速发展,云原生已成为推动现代软件开发和运维的关键力量。本文将深入探讨云原生的基本概念、核心价值及其在实际业务中的应用,帮助读者理解云原生如何重塑IT架构,提升企业的创新能力和市场竞争力。通过具体案例分析,我们将揭示云原生技术背后的哲学思想,以及它如何影响企业决策和操作模式。