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

112 阅读43分钟

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

原文:exploringjs.com/deep-js/ch_property-assignment-vs-definition.html

译者:飞龙

协议:CC BY-NC-SA 4.0


  • 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-inES1

以下操作(在 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 被值v resolved,它就会变成fulfilled(稍后,我们将看到解决也可以拒绝)。v现在是 Promise 的fulfillment value

  • 如果一个 Promise 被错误e rejected,它就会变成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