14. 类之外的新 OOP 特性
目录
请支持本书:购买 (PDF, EPUB, MOBI)捐赠
(广告,请不要屏蔽。)

14. 类之外的新 OOP 特性

类(在下一章中解释)是 ECMAScript 6 中主要的 OOP 新特性。但是,它还包括对象字面量的特性和 Object 中的新实用方法。本章将对它们进行描述。



14.1 概述

14.1.1 新的对象字面量特性

方法定义

const obj = {
    myMethod(x, y) {
        ···
    }
};

属性值简写

const first = 'Jane';
const last = 'Doe';

const obj = { first, last };
// Same as:
const obj = { first: first, last: last };

计算属性键

const propKey = 'foo';
const obj = {
    [propKey]: true,
    ['b'+'ar']: 123
};

这种新语法也可以用于方法定义

const obj = {
    ['h'+'ello']() {
        return 'hi';
    }
};
console.log(obj.hello()); // hi

计算属性键的主要用例是简化将符号用作属性键的操作。

14.1.2 Object 中的新方法

Object 中最重要的新方法是 assign()。传统上,此功能在 JavaScript 世界中称为 extend()。与这种经典操作的工作方式相比,Object.assign() 只考虑*自身*(非继承)属性。

const obj = { foo: 123 };
Object.assign(obj, { bar: true });
console.log(JSON.stringify(obj));
    // {"foo":123,"bar":true}

14.2 对象字面量的新特性

14.2.1 方法定义

在 ECMAScript 5 中,方法是其值为函数的属性

var obj = {
    myMethod: function (x, y) {
        ···
    }
};

在 ECMAScript 6 中,方法仍然是函数值属性,但现在有一种更紧凑的方法来定义它们

const obj = {
    myMethod(x, y) {
        ···
    }
};

Getter 和 setter 继续像在 ECMAScript 5 中那样工作(请注意它们在语法上与方法定义的相似之处)

const obj = {
    get foo() {
        console.log('GET foo');
        return 123;
    },
    set bar(value) {
        console.log('SET bar to '+value);
        // return value is ignored
    }
};

让我们使用 obj

> obj.foo
GET foo
123
> obj.bar = true
SET bar to true
true

还有一种方法可以简洁地定义其值为生成器函数的属性

const obj = {
    * myGeneratorMethod() {
        ···
    }
};

此代码等效于

const obj = {
    myGeneratorMethod: function* () {
        ···
    }
};

14.2.2 属性值简写

属性值简写允许您缩写对象字面量中属性的定义:如果指定属性值的变量的名称也是属性键,则可以省略该键。如下所示。

const x = 4;
const y = 1;
const obj = { x, y };

最后一行等效于

const obj = { x: x, y: y };

属性值简写与解构配合良好

const obj = { x: 4, y: 1 };
const {x,y} = obj;
console.log(x); // 4
console.log(y); // 1

属性值简写的一个用例是多个返回值(在关于解构的章节中解释)。

14.2.3 计算属性键

请记住,在设置属性时,有两种方法可以指定键。

  1. 通过固定名称:obj.foo = true;
  2. 通过表达式:obj['b'+'ar'] = 123;

在对象字面量中,在 ECMAScript 5 中您只有选项 #1。ECMAScript 6 还提供了选项 #2(A 行)

const obj = {
    foo: true,
    ['b'+'ar']: 123
};

这种新语法也可以用于方法定义

const obj = {
    ['h'+'ello']() {
        return 'hi';
    }
};
console.log(obj.hello()); // hi

计算属性键的主要用例是符号:您可以定义一个公共符号并将其用作始终唯一的特殊属性键。一个突出的例子是存储在 Symbol.iterator 中的符号。如果一个对象有一个带有该键的方法,则它就变成*可迭代的*:该方法必须返回一个迭代器,该迭代器由 for-of 循环等结构用于迭代对象。以下代码演示了它是如何工作的。

const obj = {
    * [Symbol.iterator]() { // (A)
        yield 'hello';
        yield 'world';
    }
};
for (const x of obj) {
    console.log(x);
}
// Output:
// hello
// world

obj 是可迭代的,因为生成器方法定义从 A 行开始。

14.3 Object 的新方法

14.3.1 Object.assign(target, source_1, source_2, ···)

此方法将源合并到目标中:它通过首先将 source_1 的所有可枚举的*自身*(非继承)属性复制到其中,然后复制 source_2 的所有自身属性,依此类推,来修改 target。最后,它返回目标。

const obj = { foo: 123 };
Object.assign(obj, { bar: true });
console.log(JSON.stringify(obj));
    // {"foo":123,"bar":true}

让我们更仔细地看看 Object.assign() 是如何工作的

14.3.1.1 复制所有自身属性

这就是您如何复制*所有*自身属性(而不仅仅是可枚举的属性),同时正确地传输 getter 和 setter 并且不在目标上调用 setter

function copyAllOwnProperties(target, ...sources) {
    for (const source of sources) {
        for (const key of Reflect.ownKeys(source)) {
            const desc = Object.getOwnPropertyDescriptor(source, key);
            Object.defineProperty(target, key, desc);
        }
    }
    return target;
}

有关属性描述符(由 Object.getOwnPropertyDescriptor()Object.defineProperty() 使用)的更多信息,请参阅“Speaking JavaScript”中的“属性特性和属性描述符”一节。

14.3.1.2 警告:Object.assign() 不适用于移动方法

一方面,您不能移动使用 super 的方法:这种方法具有内部插槽 [[HomeObject]],将其绑定到创建它的对象。如果您通过 Object.assign() 移动它,它将继续引用原始对象的超级属性。详细信息在关于类的章节中的一节中解释。

另一方面,如果您将由对象字面量创建的方法移动到类的原型中,则可枚举性是错误的。前一种方法都是可枚举的(否则 Object.assign() 不会看到它们),但原型通常只有不可枚举的方法。

14.3.1.3 Object.assign() 的用例

让我们看几个用例。

14.3.1.3.1 this 添加属性

您可以在构造函数中使用 Object.assign()this 添加属性

class Point {
    constructor(x, y) {
        Object.assign(this, {x, y});
    }
}
14.3.1.3.2 为对象属性提供默认值

Object.assign() 对于为缺少的属性填写默认值也很有用。在以下示例中,我们有一个对象 DEFAULTS,其中包含属性的默认值,以及一个包含数据的对象 options

const DEFAULTS = {
    logLevel: 0,
    outputFormat: 'html'
};
function processContent(options) {
    options = Object.assign({}, DEFAULTS, options); // (A)
    ···
}

在 A 行中,我们创建了一个新对象,将默认值复制到其中,然后将 options 复制到其中,覆盖默认值。Object.assign() 返回这些操作的结果,我们将其分配给 options

14.3.1.3.3 向对象添加方法

另一个用例是向对象添加方法

Object.assign(SomeClass.prototype, {
    someMethod(arg1, arg2) {
        ···
    },
    anotherMethod() {
        ···
    }
});

您也可以手动分配函数,但这样您就没有了简洁的方法定义语法,并且每次都需要提及 SomeClass.prototype

SomeClass.prototype.someMethod = function (arg1, arg2) {
    ···
};
SomeClass.prototype.anotherMethod = function () {
    ···
};
14.3.1.3.4 克隆对象

Object.assign() 的最后一个用例是克隆对象的快速方法

function clone(orig) {
    return Object.assign({}, orig);
}

这种克隆方式也有些脏,因为它没有保留 orig 的属性特性。如果这是您需要的,则必须使用属性描述符,就像我们实现copyAllOwnProperties()那样。

如果您希望克隆具有与原始对象相同的原型,则可以使用 Object.getPrototypeOf()Object.create()

function clone(orig) {
    const origProto = Object.getPrototypeOf(orig);
    return Object.assign(Object.create(origProto), orig);
}

14.3.2 Object.getOwnPropertySymbols(obj)

Object.getOwnPropertySymbols(obj) 检索 obj 的所有*自身*(非继承)符号值属性键。它补充了 Object.getOwnPropertyNames(),后者检索所有字符串值自身属性键。有关遍历属性的更多详细信息,请参阅后面的章节

14.3.3 Object.is(value1, value2)

严格相等运算符 (===) 处理两个值的方式与人们预期的方式不同。

首先,NaN 不等于自身。

> NaN === NaN
false

这很不幸,因为它经常阻止我们检测 NaN

> [0,NaN,2].indexOf(NaN)
-1

其次,JavaScript 有两个零,但严格相等将它们视为相同的值

> -0 === +0
true

这样做通常是一件好事。

Object.is() 提供了一种比较值的方法,它比 === 更精确一些。它的工作原理如下

> Object.is(NaN, NaN)
true
> Object.is(-0, +0)
false

其他所有内容都与 === 一样进行比较。

14.3.3.1 使用 Object.is() 查找数组元素

在以下函数 myIndexOf() 中,我们将 Object.is() 与新的 ES6 数组方法findIndex()结合使用,以在数组中查找 NaN

function myIndexOf(arr, elem) {
    return arr.findIndex(x => Object.is(x, elem));
}

const myArray = [0,NaN,2];
myIndexOf(myArray, NaN); // 1
myArray.indexOf(NaN); // -1

正如您在最后一行中看到的,indexOf() 没有找到 NaN

14.3.4 Object.setPrototypeOf(obj, proto)

此方法将 obj 的原型设置为 proto。ECMAScript 5 中非标准的做法(许多引擎都支持)是通过赋值给 特殊属性 __proto__。设置原型的推荐方法与 ECMAScript 5 中相同:在创建对象期间,通过 Object.create()。这总是比先创建对象然后设置其原型更快。显然,如果您想更改现有对象的原型,这是行不通的。

14.4 在 ES6 中遍历属性

14.4.1 遍历属性的五种操作

在 ECMAScript 6 中,属性的键可以是字符串或符号。以下是遍历对象 obj 的属性键的五种操作

14.4.2 属性的遍历顺序

ES6 为属性定义了两种遍历顺序。

自身属性键

可枚举自身名称

for-in 遍历属性的顺序未定义。引用 Allen Wirfs-Brock 的话

历史上,for-in 的顺序没有定义,并且浏览器实现之间在它们产生的顺序(和其他细节)方面存在差异。ES5 增加了 Object.keys 以及它应该按照与 for-in 相同的顺序对键进行排序的要求。在 ES5 和 ES6 的开发过程中,都考虑过定义特定的 for-in 顺序,但由于 Web 遗留兼容性问题以及浏览器是否愿意对其当前产生的顺序进行更改的不确定性,因此没有采用。

14.4.2.1 整数索引

即使您通过整数索引访问数组元素,规范也将它们视为普通的字符串属性键

const arr=['a', 'b', 'c'];

console.log(arr['0']); // 'a'

// Operand 0 of [] is coerced to string:
console.log(arr[0]); // 'a'

整数索引只有两个特殊之处:它们会影响数组的 length,并且在列出属性键时它们排在第一位。

粗略地说,整数索引是一个字符串,如果转换为 53 位非负整数再转换回来,则值相同。因此

延伸阅读

14.4.2.2 示例

以下代码演示了遍历顺序“自身属性键”

const obj = {
    [Symbol('first')]: true,
    '02': true,
    '10': true,
    '01': true,
    '2': true,
    [Symbol('second')]: true,
};
Reflect.ownKeys(obj);
    // [ '2', '10', '02', '01',
    //   Symbol('first'), Symbol('second') ]

说明

14.4.2.3 为什么规范要标准化返回属性键的顺序?

Tab Atkins Jr. 的回答

因为,至少对于对象而言,所有实现都使用大致相同的顺序(与当前规范匹配),并且许多代码是在无意中编写的,这些代码依赖于该顺序,如果以不同的顺序枚举,则会中断。由于浏览器必须实现这种特定的顺序才能与 Web 兼容,因此将其指定为一项要求。

有一些关于在 Map/Set 中打破这种顺序的讨论,但这样做将要求我们指定一个不可能让代码依赖的顺序;换句话说,我们必须强制排序是随机的,而不仅仅是未指定的。这被认为是太多的努力,而且创建顺序相当有价值(例如,参见 Python 中的 OrderedDict),因此决定让 Map 和 Set 与 Object 相匹配。

14.4.2.4 规范中的属性顺序

规范的以下部分与本节相关

14.5 分配属性与定义属性

将属性 prop 添加到对象 obj 有两种类似的方法

在以下三种情况下,分配不会创建自身属性 prop,即使它尚不存在

  1. 原型链中存在只读属性 prop。然后,在严格模式下,赋值会导致 TypeError
  2. 原型链中存在 prop 的设置器。然后,将调用该设置器。
  3. 原型链中存在 prop 的获取器,但没有设置器。然后,在严格模式下,将抛出 TypeError。这种情况与第一种情况类似。

这些情况都不会阻止 Object.defineProperty() 创建自身属性。下一节将更详细地介绍情况 #3。

14.5.1 覆盖继承的只读属性

如果对象 obj 继承了只读属性 prop,则您不能为该属性赋值

const proto = Object.defineProperty({}, 'prop', {
    writable: false,
    configurable: true,
    value: 123,
});
const obj = Object.create(proto);
obj.prop = 456;
    // TypeError: Cannot assign to read-only property

这类似于具有获取器但没有设置器的继承属性的工作方式。这与将赋值视为更改继承属性的值是一致的。它是以非破坏性的方式进行的:不会修改原始属性,而是由新创建的自身属性覆盖。因此,继承的只读属性和继承的无设置器属性都会阻止通过赋值进行更改。但是,您可以通过定义属性来强制创建自身属性

const proto = Object.defineProperty({}, 'prop', {
    writable: false,
    configurable: true,
    value: 123,
});
const obj = Object.create(proto);
Object.defineProperty(obj, 'prop', {value: 456});
console.log(obj.prop); // 456

14.6 ECMAScript 6 中的 __proto__

属性 __proto__(发音为“dunder proto”)在大多数 JavaScript 引擎中已经存在一段时间了。本节解释了它在 ECMAScript 6 之前的行为方式以及 ECMAScript 6 中的变化。

对于本节,如果您了解什么是原型链,将有所帮助。如有必要,请参阅“Speaking JavaScript”中的“第 2 层:对象之间的原型关系”一节。

14.6.1 ECMAScript 6 之前的 __proto__

14.6.1.1 原型

JavaScript 中的每个对象都开始一个由一个或多个对象组成的链,称为原型链。每个对象都通过内部插槽 [[Prototype]] 指向其后继者,即其原型(如果没有后继者,则为 null)。该插槽被称为内部的,因为它只存在于语言规范中,不能从 JavaScript 直接访问。在 ECMAScript 5 中,获取对象 obj 的原型 p 的标准方法是

var p = Object.getPrototypeOf(obj);

没有标准的方法来更改现有对象的原型,但是您可以创建一个具有给定原型 p 的新对象 obj

var obj = Object.create(p);
14.6.1.2 __proto__

很久以前,Firefox 就有了非标准属性 __proto__。由于其流行,其他浏览器最终也复制了该功能。

在 ECMAScript 6 之前,__proto__ 的工作方式很模糊

14.6.1.3 通过 __proto__Array 进行子类化

__proto__ 之所以流行起来,主要是因为它是 ES5 中创建 Array 的子类 MyArray 的唯一方法:数组实例是异质对象,不能由普通构造函数创建。因此,使用了以下技巧

function MyArray() {
    var instance = new Array(); // exotic object
    instance.__proto__ = MyArray.prototype;
    return instance;
}
MyArray.prototype = Object.create(Array.prototype);
MyArray.prototype.customMethod = function (···) { ··· };

ES6 中的子类化 的工作方式与 ES5 不同,并且开箱即用地支持对内置对象进行子类化。

14.6.1.4 为什么 __proto__ 在 ES5 中有问题

主要问题是 __proto__ 混合了两个级别:对象级别(普通属性,保存数据)和元级别。

如果您不小心将 __proto__ 作为普通属性(对象级别!)来存储数据,就会遇到麻烦,因为这两个级别会发生冲突。由于您必须在 ES5 中滥用对象作为映射,因为 ES5 没有用于此目的的内置数据结构,因此情况更加复杂。映射应该能够保存任意键,但是您不能将键 '__proto__' 与对象作为映射一起使用。

从理论上讲,可以使用符号而不是特殊名称 __proto__ 来解决这个问题,但是完全分离元机制(如通过 Object.getPrototypeOf() 所做的那样)是最好的方法。

14.6.2 ECMAScript 6 中的两种 __proto__

由于 __proto__ 得到了如此广泛的支持,因此决定将其行为标准化为 ECMAScript 6。但是,由于其问题性质,它被添加为一个已弃用的功能。这些功能位于 ECMAScript 规范的附件 B 中,描述如下

当 ECMAScript 宿主是 Web 浏览器时,需要使用本附件中定义的 ECMAScript 语言语法和语义。如果 ECMAScript 宿主不是 Web 浏览器,则本附件的内容是规范性的,但可选的。

JavaScript 有几个不受欢迎的特性,但网络上有大量的代码需要用到这些特性。因此,网络浏览器必须实现它们,但其他 JavaScript 引擎则不必如此。

为了解释 __proto__ 背后的魔力,ES6 中引入了两种机制

14.6.2.1 Object.prototype.__proto__

ECMAScript 6 允许通过存储在 Object.prototype 中的 getter 和 setter 来获取和设置属性 __proto__。如果要手动实现它们,大致如下所示

Object.defineProperty(Object.prototype, '__proto__', {
    get() {
        const _thisObj = Object(this);
        return Object.getPrototypeOf(_thisObj);
    },
    set(proto) {
        if (this === undefined || this === null) {
            throw new TypeError();
        }
        if (!isObject(this)) {
            return undefined;
        }
        if (!isObject(proto)) {
            return undefined;
        }
        const status = Reflect.setPrototypeOf(this, proto);
        if (! status) {
            throw new TypeError();
        }
        return undefined;
    },
});
function isObject(value) {
    return Object(value) === value;
}
14.6.2.2 属性键 __proto__ 作为对象字面量中的运算符

如果 __proto__ 作为未加引号或加引号的属性键出现在对象字面量中,则由该字面量创建的对象的原型将设置为该属性值

> Object.getPrototypeOf({ __proto__: null })
null
> Object.getPrototypeOf({ '__proto__': null })
null

使用字符串值 '__proto__' 作为计算属性键不会更改原型,而是会创建一个自身属性

> const obj = { ['__proto__']: null };
> Object.getPrototypeOf(obj) === Object.prototype
true
> Object.keys(obj)
[ '__proto__' ]

14.6.3 避免 __proto__ 的魔力

14.6.3.1 定义(而非赋值)__proto__

在 ECMAScript 6 中,如果定义自身属性 __proto__,则不会触发任何特殊功能,并且 getter/setter Object.prototype.__proto__ 将被覆盖

const obj = {};
Object.defineProperty(obj, '__proto__', { value: 123 })

Object.keys(obj); // [ '__proto__' ]
console.log(obj.__proto__); // 123
14.6.3.2 原型链中没有 Object.prototype 的对象

__proto__ getter/setter 是通过 Object.prototype 提供的。因此,原型链中没有 Object.prototype 的对象也不会有 getter/setter。在以下代码中,dict 就是此类对象的一个示例 - 它没有原型。因此,__proto__ 现在的工作方式与任何其他属性一样

> const dict = Object.create(null);
> '__proto__' in dict
false
> dict.__proto__ = 'abc';
> dict.__proto__
'abc'
14.6.3.3 __proto__ 和字典对象

如果要将对象用作字典,则最好不要使用原型。这就是为什么无原型对象也称为字典对象。在 ES6 中,甚至不需要转义字典对象的属性键 '__proto__',因为它不会触发任何特殊功能。

__proto__ 作为对象字面量中的运算符,可以更简洁地创建字典对象

const dictObj = {
    __proto__: null,
    yes: true,
    no: false,
};

请注意,在 ES6 中,通常应优先使用内置数据结构 Map 而不是字典对象,尤其是在键不固定时。

14.6.3.4 __proto__ 和 JSON

在 ES6 之前,JavaScript 引擎中可能会发生以下情况

> JSON.parse('{"__proto__": []}') instanceof Array
true

由于 __proto__ 在 ES6 中是一个 getter/setter,因此 JSON.parse() 可以正常工作,因为它定义了属性,而不是赋值(如果实现正确,旧版本的 V8 确实进行了赋值)。

JSON.stringify() 也不会受到 __proto__ 的影响,因为它只考虑自身属性。具有名称为 __proto__ 的自身属性的对象可以正常工作

> JSON.stringify({['__proto__']: true})
'{"__proto__":true}'

14.6.4 检测对 ES6 风格 __proto__ 的支持

对 ES6 风格 __proto__ 的支持因引擎而异。有关现状的信息,请参阅 kangax 的 ECMAScript 6 兼容性表

以下两节介绍如何以编程方式检测引擎是否支持两种 __proto__ 中的任何一种。

14.6.4.1 特性:__proto__ 作为 getter/setter

对 getter/setter 的简单检查

var supported = {}.hasOwnProperty.call(Object.prototype, '__proto__');

更复杂的检查

var desc = Object.getOwnPropertyDescriptor(Object.prototype, '__proto__');
var supported = (
    typeof desc.get === 'function' && typeof desc.set === 'function'
);
14.6.4.2 特性:__proto__ 作为对象字面量中的运算符

可以使用以下检查

var supported = Object.getPrototypeOf({__proto__: null}) === null;

14.6.5 __proto__ 的发音为“dunder proto”

在 Python 中,用双下划线括起名称是一种常见的做法,用于避免元数据(例如 __proto__)和数据(用户定义的属性)之间的名称冲突。这种做法在 JavaScript 中永远不会普及,因为它现在有符号来实现此目的。但是,我们可以借鉴 Python 社区的经验,了解如何发音双下划线。

Ned Batchelder 建议使用以下发音

用 Python 编程时一件尴尬的事情是:有很多双下划线。例如,语法糖之下的标准方法名称类似于 __getattr__,构造函数是 __init__,内置运算符可以使用 __add__ 重载,等等。[…]

我对双下划线的问题是它很难说。你如何发音 __init__?“下划线下划线初始化下划线下划线”?“under under init under under”?仅仅是“init”似乎遗漏了一些重要的东西。

我有一个解决方案:双下划线应该发音为“dunder”。所以 __init__ 就是“dunder init dunder”,或者简称为“dunder init”。

因此,__proto__ 的发音为“dunder proto”。这种发音很有可能流行起来,JavaScript 的创造者 Brendan Eich 就使用这种发音。

14.6.6 __proto__ 的建议

ES6 如何出色地将 __proto__ 从一个晦涩难懂的东西变成一个易于理解的东西,这真是太好了。

但是,我仍然建议不要使用它。它实际上是一个已弃用的特性,不属于核心标准。不能指望它在必须在所有引擎上运行的代码中都存在。

更多建议

14.7 ECMAScript 6 中的可枚举性

可枚举性是对象属性的一个特性。本节介绍它在 ECMAScript 6 中的工作原理。让我们首先探讨一下什么是特性。

14.7.1 属性特性

每个对象都有零个或多个属性。每个属性都有一个键和三个或更多个特性,这些特性是存储属性数据的命名槽(换句话说,属性本身很像 JavaScript 对象或数据库中带有字段的记录)。

ECMAScript 6 支持以下特性(ES5 也支持)

可以通过 Object.getOwnPropertyDescriptor() 检索属性的特性,该方法以 JavaScript 对象的形式返回特性

> const obj = { foo: 123 };
> Object.getOwnPropertyDescriptor(obj, 'foo')
{ value: 123,
  writable: true,
  enumerable: true,
  configurable: true }

本节介绍了特性 enumerable 在 ES6 中的工作原理。所有其他特性以及如何更改特性在“Speaking JavaScript”的“属性特性和属性描述符”一节中进行了介绍。

14.7.2 受可枚举性影响的结构

ECMAScript 5

ECMAScript 6

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

14.7.3 可枚举性的用例

不幸的是,可枚举性是一个相当特殊的功能。本节介绍了它的几个用例,并论证了除了防止遗留代码损坏之外,它的用处有限。

14.7.3.1 用例:在 for-in 循环中隐藏属性

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

14.7.3.1.1 语言中的不可枚举性

不可枚举属性出现在语言中的以下位置

使所有这些属性不可枚举的主要原因是为了将它们(尤其是继承的属性)隐藏起来,不让使用 for-in 循环或 $.extend() 的遗留代码看到(以及类似的操作,这些操作会复制继承的属性和自身属性;请参阅下一节)。在 ES6 中应该避免这两种操作。隐藏它们可以确保遗留代码不会损坏。

14.7.3.2 用例:将属性标记为不可复制
14.7.3.2.1 历史先例

在复制属性方面,有两个重要的历史先例考虑到了可枚举性

这种复制属性方式的问题

标准库中唯一不可枚举的实例属性是数组的属性 length。但是,该属性只需要隐藏起来,因为它会通过其他属性神奇地更新自身。无法为自己的对象创建这种神奇的属性(除非使用代理)。

14.7.3.2.2 ES6:Object.assign()

在 ES6 中,可以使用 Object.assign(target, source_1, source_2, ···) 将源对象合并到目标对象中。所有源对象自身的 enumerable 属性都会被考虑在内(也就是说,键可以是字符串或符号)。Object.assign() 使用“get”操作从源对象读取值,并使用“set”操作将值写入目标对象。

关于 enumerability,Object.assign() 延续了 Object.extend()$.extend() 的传统。引用 Yehuda Katz 的话

Object.assign 将为所有现有的 extend() API 铺平道路。我们认为,在这些情况下不复制 enumerable 方法的先例足以成为 Object.assign 具有此行为的理由。

换句话说:创建 Object.assign() 时考虑到了从 $.extend()(以及类似方法)升级的路径。它的方法比 $.extend 更简洁,因为它忽略了继承的属性。

14.7.3.3 将属性标记为私有

如果将属性设置为 non-enumerable,则 Object.keys()for-in 循环将无法再看到它。对于这些机制,该属性是私有的。

但是,这种方法存在几个问题

14.7.3.4 JSON.stringify() 中隐藏自身属性

JSON.stringify() 的输出中不包含 non-enumerable 属性。因此,可以使用 enumerability 来确定哪些自身属性应导出到 JSON。此用例类似于将属性标记为私有(上一个用例)。但它也有所不同,因为它更多地是关于导出,并且适用略有不同的注意事项。例如:可以从 JSON 完全重建对象吗?

指定如何将对象转换为 JSON 的另一种方法是使用 toJSON()

const obj = {
    foo: 123,
    toJSON() {
        return { bar: 456 };
    },
};
JSON.stringify(obj); // '{"bar":456}'

我发现对于当前用例,toJSON() 比 enumerability 更简洁。它还提供了更多控制,因为您可以导出对象上不存在的属性。

14.7.4 命名不一致

通常,较短的名称表示仅考虑 enumerable 属性

但是,Reflect.ownKeys() 偏离了该规则,它忽略 enumerability 并返回所有属性的键。此外,从 ES6 开始,进行了以下区分

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

14.7.5 展望未来

在我看来,enumerability 仅适用于对 for-in 循环和 $.extend()(以及类似操作)隐藏属性。两者都是遗留功能,在新代码中应避免使用它们。至于其他用例

我不确定未来 enumerability 的最佳策略是什么。如果在 ES6 中,我们开始假装它不存在(除了使原型属性 non-enumerable 以便旧代码不会中断之外),我们最终可能已经能够弃用 enumerability。但是,Object.assign() 考虑 enumerability 与该策略相悖(但它这样做是有充分理由的,即向后兼容性)。

在我自己的 ES6 代码中,我没有使用 enumerability,除了(隐式地)用于其 prototype 方法 non-enumerable 的类。

最后,在使用交互式命令行时,我偶尔会错过返回对象所有属性键的操作,而不仅仅是自身属性键(Reflect.ownKeys)。这样的操作将提供对象内容的良好概览。

14.8 通过知名符号自定义基本语言操作

本节介绍如何通过使用以下知名符号作为属性键来自定义基本语言操作

14.8.1 属性键 Symbol.hasInstance(方法)

对象 C 可以通过键为 Symbol.hasInstance 的方法来自定义 instanceof 运算符的行为,该方法具有以下签名

[Symbol.hasInstance](potentialInstance : any)

x instanceof C 在 ES6 中的工作方式如下

14.8.1.1 标准库中的用途

标准库中唯一具有此键的方法是

这是所有函数(包括类)默认使用的 instanceof 的实现。引用规范

此属性是不可写和不可配置的,以防止可能用于全局公开绑定函数的目标函数的篡改。

篡改是可能的,因为如果遇到绑定函数,传统的 instanceof 算法 OrdinaryHasInstance() 会将 instanceof 应用于目标函数。

鉴于此属性是只读的,您不能使用赋值来覆盖它,如前所述

14.8.1.2 示例:检查值是否为对象

例如,让我们实现一个对象 ReferenceType,其“实例”是所有对象,而不仅仅是 Object 的实例(因此在其原型链中具有 Object.prototype)的对象。

const ReferenceType = {
    [Symbol.hasInstance](value) {
        return (value !== null
            && (typeof value === 'object'
                || typeof value === 'function'));
    }
};
const obj1 = {};
console.log(obj1 instanceof Object); // true
console.log(obj1 instanceof ReferenceType); // true

const obj2 = Object.create(null);
console.log(obj2 instanceof Object); // false
console.log(obj2 instanceof ReferenceType); // true

14.8.2 属性键 Symbol.toPrimitive(方法)

Symbol.toPrimitive 允许对象自定义其如何被强制(自动转换)为原始值。

许多 JavaScript 操作会将值强制转换为所需的类型。

以下是值最常被强制转换成的类型

因此,对于数字和字符串,第一步是确保值是任何类型的原始值。这由规范内部操作 ToPrimitive() 处理,该操作具有三种模式

默认模式仅由以下内容使用

如果该值是原始值,则 ToPrimitive() 已完成。否则,该值是一个对象 obj,它将按如下方式转换为原始值

可以通过为对象提供具有以下签名的方法来覆盖此正常算法

[Symbol.toPrimitive](hint : 'default' | 'string' | 'number')

在标准库中,有两种这样的方法

14.8.2.1 示例

以下代码演示了强制转换如何影响对象 obj

const obj = {
    [Symbol.toPrimitive](hint) {
        switch (hint) {
            case 'number':
                return 123;
            case 'string':
                return 'str';
            case 'default':
                return 'default';
            default:
                throw new Error();
        }
    }
};
console.log(2 * obj); // 246
console.log(3 + obj); // '3default'
console.log(obj == 'default'); // true
console.log(String(obj)); // 'str'

14.8.3 属性键 Symbol.toStringTag(字符串)

在 ES5 及更早版本中,每个对象都有一个内部自身属性 [[Class]],其值暗示了其类型。您无法直接访问它,但它的值是 Object.prototype.toString() 返回的字符串的一部分,这就是为什么该方法被用作类型检查(作为 typeof 的替代方法)的原因。

在 ES6 中,不再有内部插槽 [[Class]],并且不鼓励使用 Object.prototype.toString() 进行类型检查。为了确保该方法的向后兼容性,引入了键为 Symbol.toStringTag 的公共属性。您可以说它取代了 [[Class]]

Object.prototype.toString() 现在的工作方式如下

14.8.3.1 默认 toString 标签

下表显示了各种对象的默认值。

toString 标签
undefined 'Undefined'
null 'Null'
数组对象 'Array'
字符串对象 'String'
arguments 'Arguments'
可调用对象 '函数'
一个错误对象 '错误'
一个布尔对象 '布尔值'
一个数字对象 '数字'
一个日期对象 '日期'
一个正则表达式对象 '正则表达式'
(否则) '对象'

左栏中的大多数检查都是通过查看内部插槽来执行的。例如,如果一个对象具有内部插槽 [[Call]],则它是可调用的。

以下交互演示了默认的 toString 标签。

> Object.prototype.toString.call(null)
'[object Null]'
> Object.prototype.toString.call([])
'[object Array]'
> Object.prototype.toString.call({})
'[object Object]'
> Object.prototype.toString.call(Object.create(null))
'[object Object]'
14.8.3.2 覆盖默认的 toString 标签

如果一个对象具有(自身或继承的)键为 Symbol.toStringTag 的属性,则其值将覆盖默认的 toString 标签。例如

> ({}.toString())
'[object Object]'
> ({[Symbol.toStringTag]: 'Foo'}.toString())
'[object Foo]'

用户定义类的实例获取默认的 toString 标签(对象)

class Foo { }
console.log(new Foo().toString()); // [object Object]

覆盖默认值的一种选择是通过 getter

class Bar {
    get [Symbol.toStringTag]() {
      return 'Bar';
    }
}
console.log(new Bar().toString()); // [object Bar]

在 JavaScript 标准库中,存在以下自定义 toString 标签。没有全局名称的对象用百分号引起来(例如:%TypedArray%)。

所有键为 Symbol.toStringTag 的内置属性都具有以下属性描述符

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

如前所述,您不能使用赋值来覆盖这些属性,因为它们是只读的。

14.8.4 属性键 Symbol.unscopables(对象)

Symbol.unscopables 允许对象对 with 语句隐藏某些属性。

这样做的原因是,它允许 TC39 向 Array.prototype 添加新方法,而不会破坏旧代码。请注意,当前代码很少使用 with,这在严格模式下是被禁止的,因此 ES6 模块(隐式处于严格模式)也是如此。

为什么向 Array.prototype 添加方法会破坏使用 with 的代码(例如广泛部署的 Ext JS 4.2.1)?看一下以下代码。如果使用数组调用 foo(),则属性 Array.prototype.values 的存在会破坏 foo()

function foo(values) {
    with (values) {
        console.log(values.length); // abc (*)
    }
}
Array.prototype.values = { length: 'abc' };
foo([]);

with 语句内部,values 的所有属性都将成为局部变量,甚至会遮蔽 values 本身。因此,如果 values 具有属性 values,则第 * 行的语句将记录 values.values.length 而不是 values.length

Symbol.unscopables 在标准库中仅使用一次

14.9 常见问题解答:对象字面量

14.9.1 我可以在对象字面量中使用 super 吗?

是的,您可以!详细信息在关于类的章节中进行了说明。

下一页:15. 类