探索-JavaScript-ES2025-版--七-
探索 JavaScript(ES2025 版)(七)
原文:
exploringjs.com/js/book/index.html译者:飞龙
31 类 ES6
-
31.1 类速查表
-
31.2 类的基本要素
-
31.2.1 人员类
-
31.2.2 类表达式
-
31.2.3
instanceof操作符 -
31.2.4 公共槽位(属性)与私有槽位
-
31.2.5 私有槽位详细说明(ES2022)(高级)
-
31.2.6 JavaScript 类的优缺点
-
31.2.7 使用类的技巧
-
-
31.3 类的内部机制
-
31.3.1 类实际上是由两个连接的对象组成
-
31.3.2 类为其实例设置原型链
-
31.3.3
.__proto__与.prototype -
31.3.4
Person.prototype.constructor(高级) -
31.3.5 分发调用与直接方法调用(高级)
-
31.3.6 类是从普通函数演变而来的(高级)
-
-
31.4 类的原型成员
-
31.4.1 公共原型方法和访问器
-
31.4.2 私有方法和访问器(ES2022)
-
-
31.5 类的实例成员(ES2022)
-
31.5.1 实例公共字段
-
31.5.2 实例私有字段
-
31.5.3 在 ES2022 之前私有实例数据(高级)
-
31.5.4 通过 WeakMaps 模拟受保护的可视性和友元可视性(高级)
-
-
31.6 类的静态成员
-
31.6.1 静态公共方法和访问器
-
31.6.2 静态公共字段(ES2022)
-
31.6.3 静态私有方法、访问器和字段(ES2022)
-
31.6.4 类中的静态初始化块(ES2022)
-
31.6.5 漏洞:使用
this访问静态私有字段 -
31.6.6 所有成员(静态、原型、实例)都可以访问所有私有成员
-
31.6.7 在 ES2022 之前静态私有方法和数据
-
31.6.8 静态工厂方法
-
-
31.7 子类化
-
31.7.1 通过
extends定义子类 -
31.7.2 子类化的内部机制(高级)
-
31.7.3
instanceof运算符的详细说明(高级) -
31.7.4 并非所有对象都是
Object的实例(高级) -
31.7.5 基类与派生类(高级)
-
31.7.6 普通对象和数组的原型链(高级)
-
-
31.8 混入类(高级)
-
31.8.1 示例:用于名称管理的混入
-
31.8.2 混入类的优势
-
-
31.9
Object.prototype的方法和访问器(高级)-
31.9.1 安全使用
Object.prototype方法 -
31.9.2
Object.prototype.toString()(ES1) -
31.9.3
Object.prototype.toLocaleString()(ES3) -
31.9.4
Object.prototype.valueOf()(ES1) -
31.9.5
Object.prototype.isPrototypeOf()(ES3) -
31.9.6
Object.prototype.propertyIsEnumerable()(ES3) -
31.9.7
Object.prototype.__proto__(访问器) (ES6) -
31.9.8
Object.prototype.hasOwnProperty()(ES3)
-
-
31.10 快速参考:
Object.prototype.*-
31.10.1
Object.prototype.*: 配置对象转换为原始值的方式 -
31.10.2
Object.prototype.*: 有用的方法及其陷阱 -
31.10.3
Object.prototype.*: 需要避免的方法
-
-
31.11 常见问题:类
-
31.11.1 为什么本书中称其为“实例私有字段”而不是“私有实例字段”?
-
31.11.2 为什么标识符前缀是
#?为什么不通过private声明私有字段?
-
在本书中,JavaScript 的面向对象编程(OOP)风格分四步介绍。本章涵盖第 3 步和第 4 步,前一章涵盖第 1 步和第 2 步。步骤如下(图 31.1):
-
单个对象(前一章): JavaScript 的基本 OOP 构建块对象在独立状态下是如何工作的?
-
原型链(前一章): 每个对象都有一个零个或多个原型对象的链。原型是 JavaScript 的核心继承机制。
-
类(本章): JavaScript 的类是对象的工厂。类与其实例之间的关系基于原型继承(第 2 步)。
-
子类化(本章): 子类与其超类之间的关系也基于原型继承。

图 31.1:本书通过四个步骤介绍了 JavaScript 中的面向对象编程。
31.1 速查表:类
一个 JavaScript 类:
class Person {
constructor(firstName) { // (A)
this.firstName = firstName; // (B)
}
describe() { // (C)
return 'Person named ' + this.firstName;
}
}
const tarzan = new Person('Tarzan');
assert.equal(
tarzan.firstName, 'Tarzan'
);
assert.equal(
tarzan.describe(),
'Person named Tarzan'
);
// One property (public slot)
assert.deepEqual(
Reflect.ownKeys(tarzan), ['firstName']
);
说明:
-
在类内部,
this指的是当前实例 -
行 A:类的构造函数
-
行 B:属性
.firstName(一个公共槽位)被创建(不需要先声明)。 -
行 C:方法
.describe()
公共实例数据,如 .firstName,在 JavaScript 中相对常见。
与之相同的 Person 类,但带有私有实例数据:
class Person {
#firstName; // (A)
constructor(firstName) {
this.#firstName = firstName; // (B)
}
describe() {
return 'Person named ' + this.#firstName;
}
}
const tarzan = new Person('Tarzan');
assert.equal(
tarzan.describe(),
'Person named Tarzan'
);
// No properties, only a private field
assert.deepEqual(
Reflect.ownKeys(tarzan), []
);
说明:
- 行 A:私有字段
.#firstName。与属性不同,私有字段必须在它们可以使用之前声明(行 A)。私有字段只能在其声明的类内部访问。它甚至不能被子类访问。
类 Employee 是 Person 的子类:
class Employee extends Person {
#title;
constructor(firstName, title) {
super(firstName); // (A)
this.#title = title;
}
describe() {
return `${super.describe()} (${this.#title})`; // (B)
}
}
const jane = new Employee('Jane', 'CTO');
assert.equal(
jane.describe(),
'Person named Jane (CTO)'
);
-
行 A:在子类中,我们可以省略构造函数。如果我们不省略,我们必须调用
super()。 -
行 B:我们可以通过
super来引用重写的方法。
下一个类演示了如何通过 公共字段 来创建属性(行 A):
class StringBuilderClass {
string = ''; // (A)
add(str) {
this.string += str;
return this;
}
}
const sb = new StringBuilderClass();
sb.add('Hello').add(' everyone').add('!');
assert.equal(
sb.string, 'Hello everyone!'
);
JavaScript 也支持 static 成员,但外部函数和变量通常更受欢迎。
31.2 类的基本知识
类基本上是设置原型链(在 上一章 中解释)的紧凑语法。在底层,JavaScript 的类是非传统的。但这是我们很少在处理它们时看到的。对于使用过其他面向对象编程语言的人来说,它们应该感觉很熟悉。
注意,我们不需要类来创建对象。我们也可以通过 对象字面量 来这样做。这就是为什么在 JavaScript 中不需要单例模式,并且类比许多其他有它们的语言使用得少。
31.2.1 个人类
我们之前已经与 jane 和 tarzan 一起工作过,它们是代表个人的单个对象。让我们使用 类声明 来实现此类对象的工厂:
class Person {
#firstName; // (A)
constructor(firstName) {
this.#firstName = firstName; // (B)
}
describe() {
return `Person named ${this.#firstName}`;
}
static extractNames(persons) {
return persons.map(person => person.#firstName);
}
}
jane 和 tarzan 现在可以通过 new Person() 来创建:
const jane = new Person('Jane');
const tarzan = new Person('Tarzan');
让我们检查类 Person 的内部结构。
-
.constructor()是一个特殊的方法,在创建新实例之后被调用。在其中,this指的是该实例。 -
.#firstName(ES2022) 是一个 实例私有字段:此类字段存储在实例中。它们以类似于属性的方式访问,但它们的名称是分开的——它们总是以哈希符号(#)开头。并且它们对类外部的世界是不可见的:assert.deepEqual( Reflect.ownKeys(jane), [] );在我们可以在构造函数(行 B)中初始化
.#firstName之前,我们需要通过在类体中提及它来声明它(行 A)。 -
.describe()是一个方法。如果我们通过obj.describe()来调用它,那么.describe()体内的this指向obj。assert.equal( jane.describe(), 'Person named Jane' ); assert.equal( tarzan.describe(), 'Person named Tarzan' ); -
.extractName()是一个静态方法。“静态”意味着它属于类,而不是实例:assert.deepEqual( Person.extractNames([jane, tarzan]), ['Jane', 'Tarzan'] );
我们也可以在构造函数中创建实例属性(公共字段):
class Container {
constructor(value) {
this.value = value;
}
}
const abcContainer = new Container('abc');
assert.equal(
abcContainer.value, 'abc'
);
与实例私有字段不同,实例属性不必在类体中声明。
31.2.2 类表达式
类的定义有两种(定义类的方式):
-
类声明,我们在上一节中已经见过。
-
类表达式,我们将在下一节中看到。
类表达式可以是匿名的和命名的:
// Anonymous class expression
const Person = class { ··· };
// Named class expression
const Person = class MyClass { ··· };
命名类表达式的名称与命名函数表达式的名称类似:它只能在类体内部访问,并且保持不变,无论类被分配给什么。
31.2.3 instanceof 操作符
instanceof 操作符告诉我们一个值是否是给定类的实例:
> new Person('Jane') instanceof Person
true
> {} instanceof Person
false
> {} instanceof Object
true
> [] instanceof Array
true
在我们研究了子类化之后,我们将在“属性属性和属性描述符(ES5)(高级)”(§30.10)中更详细地探讨 instanceof 操作符。
31.2.4 公共槽位(属性)与私有槽位
在 JavaScript 语言中,对象可以有两大类“槽位”。
-
公共槽位(也称为属性)。例如,方法就是公共槽位。
-
私有槽位(ES2022)。例如,私有字段是私有槽位。
这些是我们需要了解的关于属性和私有槽位最重要的规则:
-
在类中,我们可以使用公共和私有版本的字段、方法、获取器和设置器。所有这些都是对象的槽位。它们放置在哪个对象中取决于是否使用了关键字
static以及其他因素。 -
一个具有相同键的获取器和设置器创建一个单一的访问器槽位。访问器也可以只包含获取器或只包含设置器。
-
属性和私有槽位非常不同——例如:
-
它们被单独存储。
-
它们的键是不同的。私有槽位的键甚至不能直接访问(参见本章后面的“每个私有槽位都有一个唯一的键(一个私有名称)”(§31.2.5.2))。
-
属性是从原型继承的,私有槽位不是。
-
私有槽位只能通过类来创建。
-
以下类演示了两种槽位。它的每个实例都有一个私有字段和一个属性:
class MyClass {
#instancePrivateField = 1;
instanceProperty = 2;
getInstanceValues() {
return [
this.#instancePrivateField,
this.instanceProperty,
];
}
}
const inst = new MyClass();
assert.deepEqual(
inst.getInstanceValues(), [1, 2]
);
如预期的那样,在 MyClass 之外,我们只能看到属性:
assert.deepEqual(
Reflect.ownKeys(inst),
['instanceProperty']
);
关于属性的更多信息
本章没有涵盖所有属性细节(只包含基本内容)。如果您想深入了解,可以在“属性属性和属性描述符(ES5)(高级)”(§30.10)中找到相关信息。
接下来,我们将探讨一些私有槽位的细节。
31.2.5 私有槽位更详细地说明^(ES2022) (高级)
31.2.5.1 私有槽位在子类中无法访问
私有槽位实际上只能在其声明的类内部访问。我们甚至无法从子类中访问它:
class SuperClass {
#superProp = 'superProp';
}
class SubClass extends SuperClass {
getSuperProp() {
return this.#superProp;
}
}
// SyntaxError: Private field '#superProp'
// must be declared in an enclosing class
[通过extends进行子类化]将在本章后面解释。如何解决这个问题将在“通过 WeakMaps 模拟受保护可见性和友元可见性(高级)”(§31.5.4)中解释。
31.2.5.2 每个私有槽位都有一个唯一的键(一个私有名称)
私有槽位具有独特的键,类似于 symbols。考虑以下之前提到的类:
class MyClass {
#instancePrivateField = 1;
instanceProperty = 2;
getInstanceValues() {
return [
this.#instancePrivateField,
this.instanceProperty,
];
}
}
在内部,MyClass的私有字段大致是这样处理的:
let MyClass;
{ // Scope of the body of the class
const instancePrivateFieldKey = Symbol();
MyClass = class {
__PrivateElements__ = new Map([
[instancePrivateFieldKey, 1],
]);
instanceProperty = 2;
getInstanceValues() {
return [
this.__PrivateElements__.get(instancePrivateFieldKey),
this.instanceProperty,
];
}
}
}
instancePrivateFieldKey的值被称为私有名称。我们无法直接在 JavaScript 中使用私有名称,我们只能通过私有字段、私有方法和私有访问器的固定标识符间接使用它们。公共槽位的固定标识符(如getInstanceValues)被解释为字符串键,而私有槽位的固定标识符(如#instancePrivateField)指向私有名称(类似于变量名称指向值)。
ECMAScript 语言规范中的私有槽位
ECMAScript 语言规范中的“对象内部方法和内部槽位”部分解释了私有槽位是如何工作的。搜索“[[PrivateElements]]”。
31.2.5.3 私有名称是静态作用域的(像变量一样)
可调用实体只能在其名称声明的范围内访问私有槽位的名称。然而,如果它后来移动到其他地方,它不会失去这种能力:
class MyClass {
#privateData = 'hello';
static createGetter() {
return (obj) => obj.#privateData; // (A)
}
}
const myInstance = new MyClass();
const getter = MyClass.createGetter();
assert.equal(
getter(myInstance), 'hello' // (B)
);
箭头函数getter是在MyClass(行 A)内部创建的,但即使它离开了其出生的作用域(行 B),它仍然可以访问私有名称#privateData。
31.2.5.4 相同的私有标识符在不同类中指向不同的私有名称
因为私有槽位的标识符不作为键使用,所以在不同的类中使用相同的标识符会产生不同的槽位(行 A 和行 C):
class Color {
#name; // (A)
constructor(name) {
this.#name = name; // (B)
}
static getName(obj) {
return obj.#name;
}
}
class Person {
#name; // (C)
constructor(name) {
this.#name = name;
}
}
assert.equal(
Color.getName(new Color('green')), 'green'
);
// We can’t access the private slot #name of a Person in line B:
assert.throws(
() => Color.getName(new Person('Jane')),
{
name: 'TypeError',
message: 'Cannot read private member #name from'
+ ' an object whose class did not declare it',
}
);
31.2.5.5 私有字段的名称永远不会冲突
即使子类使用相同的名称为私有字段命名,这两个名称也永远不会冲突,因为它们指向私有名称(这些名称始终是唯一的)。在以下示例中,SuperClass中的.#privateField不会与SubClass中的.#privateField冲突,尽管这两个槽位都直接存储在inst中:
class SuperClass {
#privateField = 'super';
getSuperPrivateField() {
return this.#privateField;
}
}
class SubClass extends SuperClass {
#privateField = 'sub';
getSubPrivateField() {
return this.#privateField;
}
}
const inst = new SubClass();
assert.equal(
inst.getSuperPrivateField(), 'super'
);
assert.equal(
inst.getSubPrivateField(), 'sub'
);
通过extends进行子类化将在本章后面解释。
31.2.5.6 使用in检查对象是否具有特定的私有槽
in运算符可以用来检查是否存在私有槽(行 A):
class Color {
#name;
constructor(name) {
this.#name = name;
}
static check(obj) {
return #name in obj; // (A)
}
}
让我们看看将in应用于私有槽的更多示例。
私有方法。以下代码显示私有方法在实例中创建了私有槽位:
class C1 {
#priv() {}
static check(obj) {
return #priv in obj;
}
}
assert.equal(C1.check(new C1()), true);
静态私有字段。我们也可以使用in来检查静态私有字段:
class C2 {
static #priv = 1;
static check(obj) {
return #priv in obj;
}
}
assert.equal(C2.check(C2), true);
assert.equal(C2.check(new C2()), false);
静态私有方法。我们还可以检查静态私有方法的槽位:
class C3 {
static #priv() {}
static check(obj) {
return #priv in obj;
}
}
assert.equal(C3.check(C3), true);
在不同类中使用相同的私有标识符。在下一个示例中,两个类Color和Person都有一个标识符为#name的槽位。in运算符可以正确地区分它们:
class Color {
#name;
constructor(name) {
this.#name = name;
}
static check(obj) {
return #name in obj;
}
}
class Person {
#name;
constructor(name) {
this.#name = name;
}
static check(obj) {
return #name in obj;
}
}
// Detecting Color’s #name
assert.equal(
Color.check(new Color()), true
);
assert.equal(
Color.check(new Person()), false
);
// Detecting Person’s #name
assert.equal(
Person.check(new Person()), true
);
assert.equal(
Person.check(new Color()), false
);
31.2.6 JavaScript 中类的优缺点
我建议出于以下原因使用类:
-
类是对象创建和继承的常见标准,现在在库和框架中得到广泛支持。这与之前的情况相比是一个改进,因为在之前,几乎每个框架都有自己的继承库。
-
它们帮助 IDE 和类型检查器等工具完成工作,并使新功能成为可能。
-
如果你来自其他语言,并且习惯了类,那么你可以更快地开始。
-
JavaScript 引擎对它们进行了优化。也就是说,使用类的代码几乎总是比使用自定义继承库的代码更快。
-
我们可以将内置构造函数如
Error进行子类化。
这并不意味着类是完美的:
-
过度使用继承存在风险。
-
在类中放入过多的功能存在风险(当其中一些功能更适合放在函数中时)。
-
对于来自其他语言的程序员来说,类看起来很熟悉,但它们的工作方式和用途不同(参见下一小节)。因此,这些程序员编写出的代码可能不符合 JavaScript 的风格。
-
类表面上看起来是如何工作的,与它们实际工作方式相当不同。换句话说,语法和语义之间存在不连接。两个例子是:
-
在类
C内部定义的方法会在对象C.prototype中创建一个方法。 -
类是函数。
这种不连接的原因是向后兼容性。幸运的是,在实践中,这种不连接造成的问题很少;如果我们遵循类所表现出的行为,通常不会有问题。
-
这是我们对类的第一次了解。我们很快将探索更多功能。
练习:编写一个类
exercises/classes/point_class_test.mjs
31.2.7 使用类的技巧
-
适度使用继承——它往往会使代码更加复杂,并将相关功能分散到多个位置。
-
与静态成员相比,通常更好的做法是使用外部函数和变量。我们甚至可以通过不导出它们来将这些函数和变量私有化。这个规则的两个重要例外是:
-
需要访问私有槽位的操作
-
静态工厂方法
-
-
只应在原型方法中放置核心功能。其他功能最好通过函数实现,尤其是涉及多个类实例的算法。
31.3 类的内部结构
31.3.1 类实际上是两个连接的对象
在底层,一个类变成了两个连接的对象。让我们回顾一下 Person 类,看看它是如何工作的:
class Person {
#firstName;
constructor(firstName) {
this.#firstName = firstName;
}
describe() {
return `Person named ${this.#firstName}`;
}
static extractNames(persons) {
return persons.map(person => person.#firstName);
}
}
类创建的第一个对象存储在 Person 中。它有四个属性:
assert.deepEqual(
Reflect.ownKeys(Person),
['length', 'name', 'prototype', 'extractNames']
);
// The number of parameters of the constructor
assert.equal(
Person.length, 1
);
// The name of the class
assert.equal(
Person.name, 'Person'
);
剩下的两个属性是:
-
Person.extractNames是我们之前已经看到过作用的静态方法。 -
Person.prototype指向由类定义创建的第二个对象。
这些是 Person.prototype 的内容:
assert.deepEqual(
Reflect.ownKeys(Person.prototype),
['constructor', 'describe']
);
有两个属性:
-
Person.prototype.constructor指向构造函数。 -
Person.prototype.describe是我们已经使用过的那个方法。
31.3.2 类的实例原型链的设置
Person.prototype 对象是所有实例的原型:
const jane = new Person('Jane');
assert.equal(
Object.getPrototypeOf(jane), Person.prototype
);
const tarzan = new Person('Tarzan');
assert.equal(
Object.getPrototypeOf(tarzan), Person.prototype
);
这解释了实例如何获得它们的方法:它们从 Person.prototype 对象继承。
图 31.2 可视化了所有连接的方式。

图 31.2:类 Person 有一个属性 .prototype,它指向一个对象,该对象是 Person 所有实例的原型。jane 和 tarzan 是这样的两个实例。
31.3.3 .__proto__ 与 .prototype
容易混淆 .__proto__ 和 .prototype。希望图 31.2 能清楚地说明它们之间的区别:
-
Object.prototype.__proto__是一个访问器,大多数对象都继承它,用于获取和设置接收者的原型。因此,以下两个表达式是等价的:someObj.__proto__ Object.getPrototypeOf(someObj)如下两个表达式也是一样:
someObj.__proto__ = anotherObj Object.setPrototypeOf(someObj, anotherObj) -
SomeClass.prototype存储了将成为SomeClass所有实例原型的对象。.prototype的更好名称可能是.instancePrototype。这个属性之所以特别,是因为new操作符使用它来设置SomeClass的实例。class SomeClass {} const inst = new SomeClass(); assert.equal( Object.getPrototypeOf(inst), SomeClass.prototype );
31.3.4 Person.prototype.constructor(高级)
在图 31.2 中有一个细节我们还没有看过:Person.prototype.constructor 指向 Person:
> Person.prototype.constructor === Person
true
这种设置存在是因为向后兼容性。但它有两个额外的优点。
首先,类的每个实例都继承属性 .constructor。因此,给定一个实例,我们可以通过它创建“类似”的对象:
const jane = new Person('Jane');
const cheeta = new jane.constructor('Cheeta');
// cheeta is also an instance of Person
assert.equal(cheeta instanceof Person, true);
其次,我们可以获取创建给定实例的类的名称:
const tarzan = new Person('Tarzan');
assert.equal(tarzan.constructor.name, 'Person');
31.3.5 分发与直接方法调用(高级)
在本小节中,我们将学习两种调用方法的不同方式:
-
分发方法调用
-
直接方法调用
理解这两者将使我们深入了解方法的工作原理。
在本章中我们还需要第二种方式稍后:它将允许我们从 Object.prototype 中借用有用的方法。
31.3.5.1 分发方法调用
让我们来看看类中方法调用是如何工作的。我们将重新审视之前提到的 jane:
class Person {
#firstName;
constructor(firstName) {
this.#firstName = firstName;
}
describe() {
return 'Person named '+this.#firstName;
}
}
const jane = new Person('Jane');
图 31.3 展示了 jane 的原型链。

图 31.3:jane 的原型链从 jane 开始,然后继续到 Person.prototype。
正常方法调用是分发的——方法调用
jane.describe()
发生在两个步骤中:
-
分发:JavaScript 从
jane开始遍历原型链,寻找第一个具有键'describe'的自有属性的对象:它首先查看jane并未找到自有属性.describe。然后继续到jane的原型,Person.prototype,并找到一个自有属性describe,其值被返回。const func = jane.describe; -
调用:方法调用与函数调用不同,它不仅调用括号前的内容,并将括号内的参数作为参数传递,而且还设置
this为方法调用的接收者(在这种情况下,jane):func.call(jane);
这种动态查找方法并调用它的方式被称为动态分发。
31.3.5.2 直接方法调用
我们也可以直接进行方法调用,而不进行分发:
Person.prototype.describe.call(jane)
这次,我们通过 Person.prototype.describe 直接指向该方法,而不是在原型链中搜索它。我们还通过 .call() 以不同的方式指定 this。
this 总是指向实例
无论方法位于实例原型链的哪个位置,this 总是指向实例(原型链的开始)。这使得 .describe() 能够在示例中访问 .#firstName。
直接方法调用何时有用?当我们想要从其他地方借用一个给定对象没有的方法时——例如:
const obj = Object.create(null);
// `obj` is not an instance of Object and doesn’t inherit
// its prototype method .toString()
assert.throws(
() => obj.toString(),
/^TypeError: obj.toString is not a function$/
);
assert.equal(
Object.prototype.toString.call(obj),
'[object Object]'
);
31.3.6 类从普通函数演变而来(高级)
在 ECMAScript 6 之前,JavaScript 没有类。相反,使用普通函数作为构造函数:
function StringBuilderConstr(initialString) {
this.string = initialString;
}
StringBuilderConstr.prototype.add = function (str) {
this.string += str;
return this;
};
const sb = new StringBuilderConstr('¡');
sb.add('Hola').add('!');
assert.equal(
sb.string, '¡Hola!'
);
类提供了更好的语法来实现这种方法:
class StringBuilderClass {
constructor(initialString) {
this.string = initialString;
}
add(str) {
this.string += str;
return this;
}
}
const sb = new StringBuilderClass('¡');
sb.add('Hola').add('!');
assert.equal(
sb.string, '¡Hola!'
);
在构造函数中,继承尤其复杂。类还提供了比更方便的语法更多的好处:
-
内置构造函数,如
Error,可以被继承。 -
我们可以通过
super访问重写的属性。 -
类不能被函数调用。
-
方法不能被
new调用,并且没有.prototype属性。 -
支持私有实例数据。
-
还有更多。
类与构造函数的兼容性如此之高,以至于它们甚至可以扩展它们:
function SuperConstructor() {}
class SubClass extends SuperConstructor {}
assert.equal(
new SubClass() instanceof SuperConstructor, true
);
extends 和子类化将在本章 后面 解释。
31.3.6.1 类是构造函数
这引出了一个有趣的见解。一方面,StringBuilderClass 通过 StringBuilderClass.prototype.constructor 引用其构造函数。
另一方面,类 就是 构造函数(一个函数):
> StringBuilderClass.prototype.constructor === StringBuilderClass
true
> typeof StringBuilderClass
'function'
构造函数(函数)与类
由于它们非常相似,我交替使用术语 构造函数(函数) 和 类。
31.4 类的原型成员
31.4.1 公共原型方法和访问器
以下类声明体中的所有成员都创建 PublicProtoClass.prototype 的属性。
class PublicProtoClass {
constructor(args) {
// (Do something with `args` here.)
}
publicProtoMethod() {
return 'publicProtoMethod';
}
get publicProtoAccessor() {
return 'publicProtoGetter';
}
set publicProtoAccessor(value) {
assert.equal(value, 'publicProtoSetter');
}
}
assert.deepEqual(
Reflect.ownKeys(PublicProtoClass.prototype),
['constructor', 'publicProtoMethod', 'publicProtoAccessor']
);
const inst = new PublicProtoClass('arg1', 'arg2');
assert.equal(
inst.publicProtoMethod(), 'publicProtoMethod'
);
assert.equal(
inst.publicProtoAccessor, 'publicProtoGetter'
);
inst.publicProtoAccessor = 'publicProtoSetter';
31.4.1.1 所有类型的公共原型方法和访问器(高级)
const accessorKey = Symbol('accessorKey');
const syncMethodKey = Symbol('syncMethodKey');
const syncGenMethodKey = Symbol('syncGenMethodKey');
const asyncMethodKey = Symbol('asyncMethodKey');
const asyncGenMethodKey = Symbol('asyncGenMethodKey');
class PublicProtoClass2 {
// Identifier keys
get accessor() {}
set accessor(value) {}
syncMethod() {}
* syncGeneratorMethod() {}
async asyncMethod() {}
async * asyncGeneratorMethod() {}
// Quoted keys
get 'an accessor'() {}
set 'an accessor'(value) {}
'sync method'() {}
* 'sync generator method'() {}
async 'async method'() {}
async * 'async generator method'() {}
// Computed keys
get [accessorKey]() {}
set accessorKey {}
[syncMethodKey]() {}
* [syncGenMethodKey]() {}
async [asyncMethodKey]() {}
async * [asyncGenMethodKey]() {}
}
// Quoted and computed keys are accessed via square brackets:
const inst = new PublicProtoClass2();
inst['sync method']();
inst[syncMethodKey]();
引号键和计算键也可以用在对象字面量中:
-
“对象字面量中的引号键” (§30.9.1)
-
“对象字面量中的计算键” (§30.9.2)
关于访问器(通过获取器和/或设置器定义)、生成器、异步方法和异步生成器方法的更多信息:
-
“对象字面量:访问器” (§30.3.6)
-
“同步生成器 (ES6)(高级)”(§33)
-
“异步函数 (ES2017)” (§44)
-
“异步生成器” (§45.2)
31.4.2 私有方法和访问器 (ES2022)
私有方法(和访问器)是原型成员和实例成员的一个有趣混合。
一方面,私有方法存储在实例的槽位中(行 A):
class MyClass {
#privateMethod() {}
static check() {
const inst = new MyClass();
assert.equal(
#privateMethod in inst, true // (A)
);
assert.equal(
#privateMethod in MyClass.prototype, false
);
assert.equal(
#privateMethod in MyClass, false
);
}
}
MyClass.check();
为什么它们不存储在 .prototype 对象中?私有槽位不继承,只有属性才继承。
另一方面,私有方法在实例之间共享——就像原型公共方法一样:
class MyClass {
#privateMethod() {}
static check() {
const inst1 = new MyClass();
const inst2 = new MyClass();
assert.equal(
inst1.#privateMethod,
inst2.#privateMethod
);
}
}
由于这一点以及它们的语法与原型公共方法相似,因此它们在这里被涵盖。
以下代码演示了私有方法和访问器是如何工作的:
class PrivateMethodClass {
#privateMethod() {
return 'privateMethod';
}
get #privateAccessor() {
return 'privateGetter';
}
set #privateAccessor(value) {
assert.equal(value, 'privateSetter');
}
callPrivateMembers() {
assert.equal(this.#privateMethod(), 'privateMethod');
assert.equal(this.#privateAccessor, 'privateGetter');
this.#privateAccessor = 'privateSetter';
}
}
assert.deepEqual(
Reflect.ownKeys(new PrivateMethodClass()), []
);
31.4.2.1 所有类型的私有方法和访问器(高级)
使用私有槽位,键始终是标识符:
class PrivateMethodClass2 {
get #accessor() {}
set #accessor(value) {}
#syncMethod() {}
* #syncGeneratorMethod() {}
async #asyncMethod() {}
async * #asyncGeneratorMethod() {}
}
关于访问器(通过获取器和/或设置器定义)、生成器、异步方法和异步生成器方法的更多信息:
-
“对象字面量:访问器” (§30.3.6)
-
“同步生成器(ES6)”(§33)
-
“异步函数(ES2017)”(§44)
-
“异步生成器”(§45.2)
31.5 类实例成员(ES2022)
31.5.1 实例公共字段
以下类的实例有两个实例属性(在行 A 和行 B 创建):
class InstPublicClass {
// Instance public field
instancePublicField = 0; // (A)
constructor(value) {
// We don’t need to mention .property elsewhere!
this.property = value; // (B)
}
}
const inst = new InstPublicClass('constrArg');
assert.deepEqual(
Reflect.ownKeys(inst),
['instancePublicField', 'property']
);
assert.equal(
inst.instancePublicField, 0
);
assert.equal(
inst.property, 'constrArg'
);
如果我们在构造函数内创建一个实例属性(行 B),我们就不需要在其他地方“声明”它。正如我们之前看到的,这与实例私有字段不同。
注意,实例属性在 JavaScript 中相对常见;比例如 Java 中的实例状态私有得多。
31.5.1.1 具有引号和计算键的实例公共字段(高级)
const computedFieldKey = Symbol('computedFieldKey');
class InstPublicClass2 {
'quoted field key' = 1;
[computedFieldKey] = 2;
}
const inst = new InstPublicClass2();
assert.equal(inst['quoted field key'], 1);
assert.equal(inst[computedFieldKey], 2);
31.5.1.2 实例公共字段中的this的值是什么?(高级)
在实例公共字段的初始化器中,this指向新创建的实例:
class MyClass {
instancePublicField = this;
}
const inst = new MyClass();
assert.equal(
inst.instancePublicField, inst
);
31.5.1.3 实例公共字段何时执行?(高级)
实例公共字段的执行大致遵循以下两个规则:
-
在基类(没有超类的类)中,实例公共字段在构造函数调用之前立即执行。
-
在派生类(有超类的类)中:
-
当调用
super()时,超类设置其实例槽位。 -
实例公共字段在
super()之后立即执行。
-
以下示例演示了这些规则:
class SuperClass {
superProp = console.log('superProp');
constructor() {
console.log('super-constructor');
}
}
class SubClass extends SuperClass {
subProp = console.log('subProp');
constructor() {
console.log('BEFORE super()');
super();
console.log('AFTER super()');
}
}
new SubClass();
输出:
BEFORE super()
superProp
super-constructor
subProp
AFTER super()
extends和子类化将在本章后面解释。
31.5.2 实例私有字段
以下类包含两个实例私有字段(行 A 和行 B):
class InstPrivateClass {
#privateField1 = 'private field 1'; // (A)
#privateField2; // (B) required!
constructor(value) {
this.#privateField2 = value; // (C)
}
/**
* Private fields are not accessible outside the class body.
*/
checkPrivateValues() {
assert.equal(
this.#privateField1, 'private field 1'
);
assert.equal(
this.#privateField2, 'constructor argument'
);
}
}
const inst = new InstPrivateClass('constructor argument');
inst.checkPrivateValues();
// No instance properties were created
assert.deepEqual(
Reflect.ownKeys(inst),
[]
);
注意,我们只能在行 C 中使用.#privateField2,前提是我们已经在类体中声明了它。
31.5.3 ES2022 之前的私有实例数据(高级)
在本节中,我们探讨两种保持实例数据私有的技术。因为它们不依赖于类,所以也可以用于以其他方式创建的对象——例如,通过对象字面量。
31.5.3.1 在 ES6 之前:通过命名约定使用私有成员
第一种技术通过在属性名称前加上下划线使其成为私有属性。这并不能以任何方式保护属性;它仅仅向外界发出信号:“你不需要了解这个属性。”
在下面的代码中,属性._counter和._action是私有的。
class Countdown {
constructor(counter, action) {
this._counter = counter;
this._action = action;
}
dec() {
this._counter--;
if (this._counter === 0) {
this._action();
}
}
}
// The two properties aren’t really private:
assert.deepEqual(
Object.keys(new Countdown()),
['_counter', '_action']);
使用这种技术,我们不会得到任何保护,私有名称可能会冲突。优点是易于使用。
私有方法的工作方式类似:它们是正常方法,其名称以下划线开头。
31.5.3.2 ES6 及以后版本:通过 WeakMaps 的私有实例数据
我们还可以通过 WeakMaps 管理私有实例数据:
const _counter = new WeakMap();
const _action = new WeakMap();
class Countdown {
constructor(counter, action) {
_counter.set(this, counter);
_action.set(this, action);
}
dec() {
let counter = _counter.get(this);
counter--;
_counter.set(this, counter);
if (counter === 0) {
_action.get(this)();
}
}
}
// The two pseudo-properties are truly private:
assert.deepEqual(
Object.keys(new Countdown()),
[]);
这究竟是如何工作的,将在关于 WeakMaps 的章节中解释。
这种技术为我们提供了相当大的外部访问保护,并且不会出现任何名称冲突。但使用起来也更复杂。
我们通过控制谁可以访问伪属性 _superProp 来控制其可见性 – 例如:如果变量在模块内部存在且未导出,则模块内部的所有人和模块外部的人都无法访问它。换句话说:在这种情况下,隐私的范围不是类,而是模块。尽管我们可以缩小范围:
let Countdown;
{ // class scope
const _counter = new WeakMap();
const _action = new WeakMap();
Countdown = class {
// ···
}
}
这种技术实际上并不支持私有方法。但可以访问 _superProp 的模块本地函数是次优选择:
const _counter = new WeakMap();
const _action = new WeakMap();
class Countdown {
constructor(counter, action) {
_counter.set(this, counter);
_action.set(this, action);
}
dec() {
privateDec(this);
}
}
function privateDec(_this) { // (A)
let counter = _counter.get(_this);
counter--;
_counter.set(_this, counter);
if (counter === 0) {
_action.get(_this)();
}
}
注意,this 变成了显式函数参数 _this(行 A)。
31.5.4 通过 WeakMaps 模拟受保护的可见性和朋友可见性(高级)
如前所述,实例私有字段仅在它们的类内部可见,甚至在子类中也不可见。因此,没有内置的方法可以获取:
-
受保护的可见性:一个类及其所有子类都可以访问一部分实例数据。
-
朋友可见性:一个类及其“朋友”(指定的函数、对象或类)可以访问一部分实例数据。
在前面的子节中,我们通过 WeakMaps 模拟了“模块可见性”(模块内部的人可以访问实例数据的一部分)。因此:
-
如果我们将一个类及其子类放入同一个模块中,我们将获得受保护的可见性。
-
如果我们将一个类及其朋友放入同一个模块中,我们将获得朋友可见性。
下一个示例演示了受保护的可见性:
const _superProp = new WeakMap();
class SuperClass {
constructor() {
_superProp.set(this, 'superProp');
}
}
class SubClass extends SuperClass {
getSuperProp() {
return _superProp.get(this);
}
}
assert.equal(
new SubClass().getSuperProp(),
'superProp'
);
通过 extends 进行子类化将在本章后面进行解释。
31.6 类的静态成员
31.6.1 静态公共方法和访问器
在以下类声明体中的所有成员都创建所谓的 静态 属性 – StaticClass 自身的属性。
class StaticPublicMethodsClass {
static staticMethod() {
return 'staticMethod';
}
static get staticAccessor() {
return 'staticGetter';
}
static set staticAccessor(value) {
assert.equal(value, 'staticSetter');
}
}
assert.equal(
StaticPublicMethodsClass.staticMethod(), 'staticMethod'
);
assert.equal(
StaticPublicMethodsClass.staticAccessor, 'staticGetter'
);
StaticPublicMethodsClass.staticAccessor = 'staticSetter';
31.6.1.1 所有静态公共方法和访问器(高级)
const accessorKey = Symbol('accessorKey');
const syncMethodKey = Symbol('syncMethodKey');
const syncGenMethodKey = Symbol('syncGenMethodKey');
const asyncMethodKey = Symbol('asyncMethodKey');
const asyncGenMethodKey = Symbol('asyncGenMethodKey');
class StaticPublicMethodsClass2 {
// Identifier keys
static get accessor() {}
static set accessor(value) {}
static syncMethod() {}
static * syncGeneratorMethod() {}
static async asyncMethod() {}
static async * asyncGeneratorMethod() {}
// Quoted keys
static get 'an accessor'() {}
static set 'an accessor'(value) {}
static 'sync method'() {}
static * 'sync generator method'() {}
static async 'async method'() {}
static async * 'async generator method'() {}
// Computed keys
static get [accessorKey]() {}
static set accessorKey {}
static [syncMethodKey]() {}
static * [syncGenMethodKey]() {}
static async [asyncMethodKey]() {}
static async * [asyncGenMethodKey]() {}
}
// Quoted and computed keys are accessed via square brackets:
StaticPublicMethodsClass2['sync method']();
StaticPublicMethodsClass2[syncMethodKey]();
引号键和计算键也可以用于对象字面量:
-
“对象字面量中的引号键”(§30.9.1)
-
“对象字面量中的计算键”(§30.9.2)
关于访问器(通过获取器和/或设置器定义)、生成器、异步方法和异步生成器方法的更多信息:
-
“对象字面量:访问器”(§30.3.6)
-
“同步生成器(ES6)(高级)”(§33)
-
“异步函数(ES2017)” (§44)
-
“异步生成器”(§45.2)
31.6.2 静态公共字段^(ES2022)
以下代码演示了静态公共字段。StaticPublicFieldClass有三个这样的字段:
const computedFieldKey = Symbol('computedFieldKey');
class StaticPublicFieldClass {
static identifierFieldKey = 1;
static 'quoted field key' = 2;
static [computedFieldKey] = 3;
}
assert.deepEqual(
Reflect.ownKeys(StaticPublicFieldClass),
[
'length', // number of constructor parameters
'name', // 'StaticPublicFieldClass'
'prototype',
'identifierFieldKey',
'quoted field key',
computedFieldKey,
],
);
assert.equal(StaticPublicFieldClass.identifierFieldKey, 1);
assert.equal(StaticPublicFieldClass['quoted field key'], 2);
assert.equal(StaticPublicFieldClass[computedFieldKey], 3);
31.6.3 静态私有方法、访问器和字段^(ES2022)
以下类有两个静态私有槽位(行 A 和行 B):
class StaticPrivateClass {
// Declare and initialize
static #staticPrivateField = 'hello'; // (A)
static #twice() { // (B)
const str = StaticPrivateClass.#staticPrivateField;
return str + ' ' + str;
}
static getResultOfTwice() {
return StaticPrivateClass.#twice();
}
}
assert.deepEqual(
Reflect.ownKeys(StaticPrivateClass),
[
'length', // number of constructor parameters
'name', // 'StaticPublicFieldClass'
'prototype',
'getResultOfTwice',
],
);
assert.equal(
StaticPrivateClass.getResultOfTwice(),
'hello hello'
);
这是一个所有静态私有槽位的完整列表:
class MyClass {
static #staticPrivateMethod() {}
static * #staticPrivateGeneratorMethod() {}
static async #staticPrivateAsyncMethod() {}
static async * #staticPrivateAsyncGeneratorMethod() {}
static get #staticPrivateAccessor() {}
static set #staticPrivateAccessor(value) {}
}
31.6.4 类中的静态初始化块^(ES2022)
要通过类设置实例数据,我们有两种构造:
-
字段,用于创建和可选地初始化实例数据
-
构造函数,每次创建新实例时执行的代码块
对于静态数据,我们有:
-
静态字段
-
静态块,在创建类时执行
以下代码演示了静态块(行 A):
class Translator {
static translations = {
yes: 'ja',
no: 'nein',
maybe: 'vielleicht',
};
static englishWords = [];
static germanWords = [];
static { // (A)
for (const [english, german] of Object.entries(this.translations)) {
this.englishWords.push(english);
this.germanWords.push(german);
}
}
}
我们也可以在类(在顶层)之后执行静态块中的代码。然而,使用静态块有两个好处:
-
所有与类相关的代码都位于类内部。
-
静态块中的代码可以访问私有槽位。
31.6.4.1 静态初始化块的规则
静态初始化块如何工作的规则相对简单:
-
每个类可以有多个静态块。
-
静态块的执行与静态字段初始化的执行交织在一起。
-
超类中的静态成员在子类中的静态成员之前执行。
以下代码演示了这些规则:
class SuperClass {
static superField1 = console.log('superField1');
static {
assert.equal(this, SuperClass);
console.log('static block 1 SuperClass');
}
static superField2 = console.log('superField2');
static {
console.log('static block 2 SuperClass');
}
}
class SubClass extends SuperClass {
static subField1 = console.log('subField1');
static {
assert.equal(this, SubClass);
console.log('static block 1 SubClass');
}
static subField2 = console.log('subField2');
static {
console.log('static block 2 SubClass');
}
}
输出:
superField1
static block 1 SuperClass
superField2
static block 2 SuperClass
subField1
static block 1 SubClass
subField2
static block 2 SubClass
通过extends进行子类化将在本章后面解释。
31.6.5 陷阱:使用this访问静态私有字段
在静态公共成员中,我们可以通过this访问静态公共槽位。然而,我们不应该用它来访问静态私有槽位。
31.6.5.1 this和静态公共字段
考虑以下代码:
class SuperClass {
static publicData = 1;
static getPublicViaThis() {
return this.publicData;
}
}
class SubClass extends SuperClass {
}
通过extends进行子类化将在本章后面解释。
静态公共字段是属性。如果我们调用方法
assert.equal(SuperClass.getPublicViaThis(), 1);
然后this指向SuperClass,一切按预期工作。我们也可以通过子类调用.getPublicViaThis():
assert.equal(SubClass.getPublicViaThis(), 1);
SubClass从其原型SuperClass继承了.getPublicViaThis()。this指向SubClass,一切继续正常,因为SubClass也继承了属性.publicData。
作为旁注,如果我们把this.publicData分配到getPublicViaThis()中并通过SubClass.getPublicViaThis()调用它,那么我们就会创建一个SubClass的新自有属性,该属性(非破坏性地)覆盖了从SuperClass继承的属性。
31.6.5.2 this和静态私有字段
考虑以下代码:
class SuperClass {
static #privateData = 2;
static getPrivateDataViaThis() {
return this.#privateData;
}
static getPrivateDataViaClassName() {
return SuperClass.#privateData;
}
}
class SubClass extends SuperClass {
}
通过SuperClass调用.getPrivateDataViaThis()是有效的,因为this指向SuperClass:
assert.equal(SuperClass.getPrivateDataViaThis(), 2);
然而,通过 SubClass 调用 .getPrivateDataViaThis() 不会工作,因为 this 现在指向 SubClass,而 SubClass 没有静态私有字段 .#privateData(原型链中的私有槽位不会继承):
assert.throws(
() => SubClass.getPrivateDataViaThis(),
{
name: 'TypeError',
message: 'Cannot read private member #privateData from'
+ ' an object whose class did not declare it',
}
);
一种解决方案是直接通过 SuperClass 访问 .#privateData:
assert.equal(SubClass.getPrivateDataViaClassName(), 2);
使用静态私有方法时,我们面临相同的问题。
31.6.6 所有成员(静态、原型、实例)都可以访问所有私有成员
类内部的所有成员都可以访问该类内部的所有其他成员——无论是公共的还是私有的:
class DemoClass {
static #staticPrivateField = 1;
#instPrivField = 2;
static staticMethod(inst) {
// A static method can access static private fields
// and instance private fields
assert.equal(DemoClass.#staticPrivateField, 1);
assert.equal(inst.#instPrivField, 2);
}
protoMethod() {
// A prototype method can access instance private fields
// and static private fields
assert.equal(this.#instPrivField, 2);
assert.equal(DemoClass.#staticPrivateField, 1);
}
}
相反,外部没有人可以访问私有成员:
// Accessing private fields outside their classes triggers
// syntax errors (before the code is even executed).
assert.throws(
() => eval('DemoClass.#staticPrivateField'),
{
name: 'SyntaxError',
message: "Private field '#staticPrivateField' must"
+ " be declared in an enclosing class",
}
);
// Accessing private fields outside their classes triggers
// syntax errors (before the code is even executed).
assert.throws(
() => eval('new DemoClass().#instPrivField'),
{
name: 'SyntaxError',
message: "Private field '#instPrivField' must"
+ " be declared in an enclosing class",
}
);
31.6.7 ES2022 之前的静态私有方法和数据
以下代码仅在 ES2022 中有效——因为每一行都包含一个哈希符号(#):
export class StaticClass {
static #secret = 'Rumpelstiltskin';
static #getSecretInParens() {
return `(${StaticClass.#secret})`;
}
static callStaticPrivateMethod() {
return StaticClass.#getSecretInParens();
}
}
由于私有槽位在每个类中只存在一次,我们可以将 .#secret 和 .#getSecretInParens 移到类周围的范围内,并使用模块来隐藏它们,使其对模块外部的世界不可见:
-
.#secret成为模块中的顶级变量。 -
.getSecretInParens()成为模块中的顶级函数。
结果看起来如下:
const secret = 'Rumpelstiltskin';
function getSecretInParens() {
return `(${secret})`;
}
// Only the class is accessible outside the module
export class StaticClass {
static callStaticPrivateMethod() {
return getSecretInParens();
}
}
31.6.8 静态工厂方法
有时,一个类可以有多种实例化的方式。然后我们可以实现如 Point.fromPolar() 这样的静态工厂方法:
class Point {
static fromPolar(radius, angle) {
const x = radius * Math.cos(angle);
const y = radius * Math.sin(angle);
return new Point(x, y);
}
constructor(x=0, y=0) {
this.x = x;
this.y = y;
}
}
assert.deepEqual(
Point.fromPolar(13, 0.39479111969976155),
new Point(12, 5)
);
我喜欢静态工厂方法的描述性:fromPolar 描述了实例是如何创建的。JavaScript 的标准库也有这样的工厂方法——例如:
-
Array.from() -
Object.create()
我更喜欢要么没有静态工厂方法,要么 只有 静态工厂方法。在后一种情况下需要考虑的事项:
-
一个工厂方法可能会直接调用构造函数(但有一个描述性的名称)。
-
我们需要找到一种方法来防止构造函数从外部被调用。
在以下代码中,我们使用一个密钥令牌(行 A)来防止构造函数从当前模块外部被调用。
// Only accessible inside the current module
const secretToken = Symbol('secretToken'); // (A)
export class Point {
static create(x=0, y=0) {
return new Point(secretToken, x, y);
}
static fromPolar(radius, angle) {
const x = radius * Math.cos(angle);
const y = radius * Math.sin(angle);
return new Point(secretToken, x, y);
}
constructor(token, x, y) {
if (token !== secretToken) {
throw new TypeError('Must use static factory method');
}
this.x = x;
this.y = y;
}
}
Point.create(3, 4); // OK
assert.throws(
() => new Point(3, 4),
TypeError
);
31.7 子类化
31.7.1 通过 extends 定义子类
类可以扩展现有的类。例如,以下类 Employee 继承自 Person:
class Person {
#firstName;
constructor(firstName) {
this.#firstName = firstName;
}
describe() {
return `Person named ${this.#firstName}`;
}
static extractNames(persons) {
return persons.map(person => person.#firstName);
}
}
class Employee extends Person {
constructor(firstName, title) {
super(firstName);
this.title = title;
}
describe() {
return super.describe() +
` (${this.title})`;
}
}
const jane = new Employee('Jane', 'CTO');
assert.equal(
jane.title,
'CTO'
);
assert.equal(
jane.describe(),
'Person named Jane (CTO)'
);
与扩展相关的术语:
-
另一个表示 扩展 的词是 子类化。
-
Person是Employee的超类。 -
Employee是Person的子类。 -
一个 基类 是一个没有超类的类。
-
一个 派生类 是一个具有超类的类。
在派生类的 .constructor() 中,我们必须在可以访问 this 之前通过 super() 调用超构造函数。为什么是这样?
让我们考虑一个类链:
-
基类
A -
类
B继承自A。 -
类
C继承自B。
如果我们调用new C(),C的构造函数会调用B的构造函数,而B的构造函数又会调用A的构造函数。实例总是在基类中创建,在子类的构造函数添加它们的槽位之前。因此,在调用super()之前,实例还不存在,我们也不能通过this来访问它。
注意,静态公共槽位是继承的。例如,Employee继承了静态方法.extractNames():
> 'extractNames' in Employee
true
练习:子类化
exercises/classes/color_point_class_test.mjs
31.7.2 子类化的内部机制(高级)

图 31.4:这些是组成Person类及其子类Employee的对象。左侧是关于类的。右侧是Employee实例jane及其原型链。
上一节中的Person和Employee类由几个对象组成(图 31.4)。理解这些对象之间关系的一个关键洞察是存在两个原型链:
-
实例原型链,在右侧。
-
类原型链,在左侧。
每个类都向实例原型链贡献一个原型,但同时也在其自己的原型链中。
31.7.2.1 实例原型链(右侧列)
实例原型链从jane开始,然后是Employee.prototype和Person.prototype。原则上,原型链在这里结束,但我们又得到了一个对象:
> Object.getPrototypeOf(Person.prototype) === Object.prototype
true
Object.prototype为几乎所有对象提供服务,这也是它被包括在这里的原因。Object.prototype的原型是null:
> Object.getPrototypeOf(Object.prototype)
null
31.7.2.2 类原型链(左侧列)
在类原型链中,Employee排在第一位,Person排在第二位。原则上,原型链在这里结束,但Person确实有一个原型:
> Object.getPrototypeOf(Person) === Function.prototype
true
Person只有这个原型,因为它是一个函数,而Function.prototype是所有函数的原型。(顺便提一下,Function.prototype的原型是Object.prototype。)
31.7.3 instanceof运算符的详细说明(高级)
我们还没有学会instanceof真正是如何工作的:instanceof是如何确定一个值x是否是类C的实例?请注意,“instance of C”意味着C的直接实例或者C的子类的直接实例。
instanceof检查C.prototype是否在x的原型链中。也就是说,以下两个表达式是等价的:
x instanceof C
C.prototype.isPrototypeOf(x)
如果我们回到图 31.4,我们可以确认原型链确实引导我们得到了以下正确的答案:
> jane instanceof Employee
true
> jane instanceof Person
true
> jane instanceof Object
true
注意,如果instanceof的自身一侧是原始值,它总是返回false:
> 'abc' instanceof String
false
> 123 instanceof Number
false
31.7.4 并非所有对象都是Object的实例(高级)
一个对象(一个非原始值)只有在 Object.prototype 在其原型链中时才是一个 Object 的实例(参见前面的子节)(见前一小节)。几乎所有的对象都是 Object 的实例——例如:
assert.equal(
{a: 1} instanceof Object, true
);
assert.equal(
['a'] instanceof Object, true
);
assert.equal(
/abc/g instanceof Object, true
);
assert.equal(
new Map() instanceof Object, true
);
class C {}
assert.equal(
new C() instanceof Object, true
);
在下一个示例中,obj1 和 obj2 都是对象(行 A 和行 C),但它们不是 Object 的实例(行 B 和行 D):因为它们没有任何原型,所以 Object.prototype 不在它们的原型链中。
const obj1 = {__proto__: null};
assert.equal(
typeof obj1, 'object' // (A)
);
assert.equal(
obj1 instanceof Object, false // (B)
);
const obj2 = Object.create(null);
assert.equal(
typeof obj2, 'object' // (C)
);
assert.equal(
obj2 instanceof Object, false // (D)
);
Object.prototype 是结束大多数原型链的对象。它的原型是 null,这意味着它也不是 Object 的一个实例:
> typeof Object.prototype
'object'
> Object.getPrototypeOf(Object.prototype)
null
> Object.prototype instanceof Object
false
31.7.5 基类与派生类(高级)
基类与扩展 Object 的类不同。
以下类是从类 Object 派生的。图 31.5 展示了其对象图。
class DerivedClass extends Object {}
const derivedInstance = new DerivedClass();

图 31.5:类 DerivedClass 及其实例 derivedInstance 的对象图:Object 和 DerivedClass 都出现在类原型链中(左侧列)。它们都向实例原型链贡献了原型(右侧列)。
相反,以下类是一个基类。图 31.6 展示了其对象图。
class BaseClass {}
const baseInstance = new BaseClass();

图 31.6:类 BaseClass 及其实例 baseInstance 的对象图:实例原型链(右侧列)与 derivedInstance 相同。然而,Object 不出现在类原型链中(左侧列)。
31.7.6 平凡对象和数组的原型链(高级)
接下来,我们将利用我们对子类化的知识来理解平凡对象和数组的原型链。以下工具函数 p() 帮助我们进行探索:
const p = Object.getPrototypeOf.bind(Object);
我们从 Object 中提取了方法 .getPrototypeOf() 并将其分配给 p。
31.7.6.1 平凡对象的原型链
让我们探索平凡对象的原型链:
> const p = Object.getPrototypeOf.bind(Object);
> p({}) === Object.prototype
true
> p(p({}))
null
图 31.7 展示了平凡对象及其类 Object 的图示。

图 31.7:空平凡对象 {} 的原型是 Object.prototype —— 这使其成为 Object 类的一个实例。
31.7.6.2 数组的原型链
让我们探索数组的原型链:
> const p = Object.getPrototypeOf.bind(Object);
> p([]) === Array.prototype
true
> p(p([])) === Object.prototype
true
> p(p(p([]))) === null
true
图 31.8 展示了数组和其类 Array 的图示。

图 31.8:空数组 [] 是 Array 的一个实例(通过 Array.prototype)以及 Object 的一个实例(通过 Object.prototype)。然而,类 Array 并不是从类 Object 派生的 —— 它是一个基类。
很有趣的是,Array 是一个基类:
> Object.getPrototypeOf(Array) === Function.prototype
true
因此,所有 Array 的实例也都是 Object 的实例——然而 Array 并不是 Object 的子类。这种差异之所以可能,仅仅是因为 JavaScript 中实例原型链与类原型链是分开的。
为什么 Object 不是 Array 的原型?一个原因是自从在 JavaScript 中添加类之前就是这样的,由于 JavaScript 中向后兼容性的重要性,实际上无法改变。另一个原因是基类是实例实际创建的地方。Array 需要创建自己的实例,因为它们有所谓的“内部槽位”,这些槽位不能添加到由 Object 创建的实例中。
31.7.6.3 函数的原型链
函数与数组类似。一方面,Function 是一个基类:
> Object.getPrototypeOf(() => {}) === Function.prototype
true
另一方面,函数对象是 Object 的实例:
> const p = Object.getPrototypeOf.bind(Object);
> p(() => {}) === Function.prototype
true
> p(p(() => {})) === Object.prototype
true
> p(p(p(() => {})))
null
31.8 混合类(高级)
JavaScript 的类系统只支持 单一继承。也就是说,每个类最多只能有一个超类。绕过这种限制的一种方法是通过称为 混合类(简称:mixins)的技术。
理念如下:假设我们想让类 C 从两个超类 S1 和 S2 继承。这将是一个 多重继承,而 JavaScript 不支持。
我们的解决方案是将 S1 和 S2 转换为 混合类,子类的工厂:
const S1 = (Sup) => class extends Sup { /*···*/ };
const S2 = (Sup) => class extends Sup { /*···*/ };
这两个函数中的每一个都返回一个扩展给定超类 Sup 的类。我们创建类 C 如下:
class C extends S2(S1(Object)) {
/*···*/
}
现在我们有一个类 C,它扩展了由 S2() 返回的类,该类扩展了由 S1() 返回的类,该类又扩展了 Object。
31.8.1 示例:用于名称管理的混合类
我们实现了一个混合 Named,它为其超类添加了一个属性 .name 和一个方法 .toString():
const Named = (Sup) => class extends Sup {
name = '(Unnamed)';
toString() {
const className = this.constructor.name;
return `${className} named ${this.name}`;
}
};
我们使用这个混合类来实现一个具有名称的类 City。
class City extends Named(Object) {
constructor(name) {
super();
this.name = name;
}
}
以下代码确认了混合类的有效性:
const paris = new City('Paris');
assert.equal(
paris.name, 'Paris'
);
assert.equal(
paris.toString(), 'City named Paris'
);
31.8.2 混合类的优势
混合类使我们摆脱了单一继承的约束:
-
同一个类可以扩展一个单一的超类和零个或多个混合类。
-
同一个混合类可以被多个类使用。
31.9 Object.prototype 的方法和访问器(高级)
31.9.1 使用 Object.prototype 方法的安全方式
在任意对象上调用 Object.prototype 的一个方法并不总是有效。为了说明原因,我们使用方法 Object.prototype.hasOwnProperty,如果对象有一个具有给定键的自有属性,则它返回 true:
> {ownProp: true}.hasOwnProperty('ownProp')
true
> {ownProp: true}.hasOwnProperty('abc')
false
在任意对象上调用 .hasOwnProperty() 可能以两种方式失败。一方面,如果对象不是 Object 的实例,则此方法不可用(参见“并非所有对象都是 Object 的实例(高级)” (§31.7.4))):
const obj = Object.create(null);
assert.equal(obj instanceof Object, false);
assert.throws(
() => obj.hasOwnProperty('prop'),
{
name: 'TypeError',
message: 'obj.hasOwnProperty is not a function',
}
);
另一方面,如果一个对象用自有属性覆盖了.hasOwnProperty()(行 A),我们就不能使用.hasOwnProperty()。
const obj = {
hasOwnProperty: 'yes' // (A)
};
assert.throws(
() => obj.hasOwnProperty('prop'),
{
name: 'TypeError',
message: 'obj.hasOwnProperty is not a function',
}
);
然而,有一个安全的方法可以使用.hasOwnProperty():
function hasOwnProp(obj, propName) {
return Object.prototype.hasOwnProperty.call(obj, propName); // (A)
}
assert.equal(
hasOwnProp(Object.create(null), 'prop'), false
);
assert.equal(
hasOwnProp({hasOwnProperty: 'yes'}, 'prop'), false
);
assert.equal(
hasOwnProp({hasOwnProperty: 'yes'}, 'hasOwnProperty'), true
);
行 A 中的方法调用在“分派方法调用与直接方法调用(高级)”(§31.3.5)中解释。
我们还可以使用.bind()来实现hasOwnProp():
const hasOwnProp = Function.prototype.call
.bind(Object.prototype.hasOwnProperty);
这段代码是如何工作的?在上面的代码示例之前的行 A 中,我们使用了函数方法.call()将具有一个隐式参数(this)和一个显式参数(propName)的函数hasOwnProperty转换为一个具有两个显式参数(obj和propName)的函数。
换句话说——.call()方法通过其接收者(this)调用函数f:
-
.call()的第一个(显式)参数成为f的this。 -
.call()的第二个(显式)参数成为f的第一个参数。 -
等等。
我们使用.bind()创建一个版本.call(),其this始终指向Object.prototype.hasOwnProperty。这个新版本以与我们在行 A 中相同的方式调用.hasOwnProperty()——这正是我们想要的。
是否永远都不应该通过动态分派使用Object.prototype方法?
在某些情况下,我们可以偷懒,像调用普通方法一样调用Object.prototype方法:如果我们知道接收者并且它们是固定布局对象。
如果,另一方面,我们不知道它们的接收者并且/或者它们是字典对象,那么我们需要采取预防措施。
31.9.2 Object.prototype.toString() (ES1)
通过在子类或实例中覆盖.toString(),我们可以配置对象如何转换为字符串:
> String({toString() { return 'Hello!' }})
'Hello!'
> String({})
'[object Object]'
将对象转换为字符串时,最好使用String(),因为它也适用于undefined和null:
> undefined.toString()
TypeError: Cannot read properties of undefined (reading 'toString')
> null.toString()
TypeError: Cannot read properties of null (reading 'toString')
> String(undefined)
'undefined'
> String(null)
'null'
31.9.3 Object.prototype.toLocaleString() (ES3)
.toLocaleString()是.toString()的一个版本,可以通过区域设置和通常的附加选项进行配置。任何类或实例都可以实现此方法。在标准库中,以下类实现了此方法:
-
Number.prototype.toLocaleString()> 123.45.toLocaleString('en') // English '123.45' > 123.45.toLocaleString('fr') // French '123,45' -
BigInt.prototype.toLocaleString() -
Array.prototype.toLocaleString()> [1.25, 3].toLocaleString('en') // English '1.25,3' > [1.25, 3].toLocaleString('fr') // French '1,25,3' -
TypedArray.prototype.toLocaleString() -
Date.prototype.toLocaleString()
多亏了ECMAScript 国际化 API(Intl等),有各种各样的格式化选项:
assert.equal(
17.50.toLocaleString('en', {
style: 'currency',
currency: 'USD',
}),
'$17.50'
);
assert.equal(
17.50.toLocaleString('fr', {
style: 'currency',
currency: 'USD',
}),
'17,50 $US'
);
assert.equal(
17.50.toLocaleString('de', {
style: 'currency',
currency: 'USD',
}),
'17,50 $'
);
assert.equal(
17.50.toLocaleString('en', {
style: 'currency',
currency: 'EUR',
}),
'€17.50'
);
31.9.4 Object.prototype.valueOf() (ES1)
通过在子类或实例中覆盖.valueOf(),我们可以配置对象如何转换为非字符串值(通常是数字):
> Number({valueOf() { return 123 }})
123
> Number({})
NaN
31.9.5 Object.prototype.isPrototypeOf() (ES3)
proto.isPrototypeOf(obj)如果proto在obj的原型链中返回true,否则返回false。
const a = {};
const b = {__proto__: a};
const c = {__proto__: b};
assert.equal(a.isPrototypeOf(b), true);
assert.equal(a.isPrototypeOf(c), true);
assert.equal(a.isPrototypeOf(a), false);
assert.equal(c.isPrototypeOf(a), false);
这就是如何安全地使用这种方法(详情请见“安全使用Object.prototype方法”(§31.9.1)):
const obj = {
// Overrides Object.prototype.isPrototypeOf
isPrototypeOf: true,
};
// Doesn’t work in this case:
assert.throws(
() => obj.isPrototypeOf(Object.prototype),
{
name: 'TypeError',
message: 'obj.isPrototypeOf is not a function',
}
);
// Safe way of using .isPrototypeOf():
assert.equal(
Object.prototype.isPrototypeOf.call(obj, Object.prototype), false
);
如果C.prototype在其原型链中,则一个对象是类C的实例:
function isInstanceOf(obj, aClass) {
return {}.isPrototypeOf.call(aClass.prototype, obj);
}
assert.equal(
isInstanceOf([], Object), true
);
assert.equal(
isInstanceOf([], Array), true
);
assert.equal(
isInstanceOf(/x/, Array), false
);
31.9.6 Object.prototype.propertyIsEnumerable() (ES3)
obj.propertyIsEnumerable(propKey)如果obj有一个自己的可枚举属性,其键为propKey,则返回true,否则返回false。
const proto = {
enumerableProtoProp: true,
};
const obj = {
__proto__: proto,
enumerableObjProp: true,
nonEnumObjProp: true,
};
Object.defineProperty(
obj, 'nonEnumObjProp',
{
enumerable: false,
}
);
assert.equal(
obj.propertyIsEnumerable('enumerableProtoProp'),
false // not an own property
);
assert.equal(
obj.propertyIsEnumerable('enumerableObjProp'),
true
);
assert.equal(
obj.propertyIsEnumerable('nonEnumObjProp'),
false // not enumerable
);
assert.equal(
obj.propertyIsEnumerable('unknownProp'),
false // not a property
);
这就是如何安全地使用这种方法(详情请见“安全使用Object.prototype方法”(§31.9.1)):
const obj = {
// Overrides Object.prototype.propertyIsEnumerable
propertyIsEnumerable: true,
enumerableProp: 'yes',
};
// Doesn’t work in this case:
assert.throws(
() => obj.propertyIsEnumerable('enumerableProp'),
{
name: 'TypeError',
message: 'obj.propertyIsEnumerable is not a function',
}
);
// Safe way of using .propertyIsEnumerable():
assert.equal(
Object.prototype.propertyIsEnumerable.call(obj, 'enumerableProp'),
true
);
另一个安全的替代方案是使用属性描述符:
assert.deepEqual(
Object.getOwnPropertyDescriptor(obj, 'enumerableProp'),
{
value: 'yes',
writable: true,
enumerable: true,
configurable: true,
}
);
31.9.7 Object.prototype.__proto__ (访问器) (ES6)
属性__proto__存在两种版本:
-
所有
Object实例都具有的访问器。 -
对象字面量中的一个属性,用于设置它们创建的对象的原型。
我建议避免使用前者功能:
-
如“安全使用
Object.prototype方法”(§31.9.1)中所述,它并不适用于所有对象。 -
ECMAScript 规范已经弃用了它,并称其为“可选”和“遗留”。
相比之下,对象字面量中的__proto__始终有效,并且没有被弃用。
如果你对访问器__proto__的工作方式感兴趣,请继续阅读。
__proto__是Object.prototype的一个访问器,它被所有Object实例继承。通过类实现它看起来像这样:
class Object {
get __proto__() {
return Object.getPrototypeOf(this);
}
set __proto__(other) {
Object.setPrototypeOf(this, other);
}
// ···
}
由于__proto__是从Object.prototype继承的,我们可以通过创建一个没有Object.prototype在其原型链中的对象来移除此功能(参见“并非所有对象都是Object的实例(高级)” (§31.7.4))):
> '__proto__' in {}
true
> '__proto__' in Object.create(null)
false
31.9.8 Object.prototype.hasOwnProperty() (ES3)
比.hasOwnProperty()更好的替代方案:Object.hasOwn() (ES2022)
请参阅“Object.hasOwn(): 给定的属性是自己的(非继承的)吗?(ES2022)” (§30.8.4)。
obj.hasOwnProperty(propKey)如果obj有一个自己的(非继承的)属性,其键为propKey,则返回true,否则返回false。
const obj = { ownProp: true };
assert.equal(
obj.hasOwnProperty('ownProp'), true // own
);
assert.equal(
'toString' in obj, true // inherited
);
assert.equal(
obj.hasOwnProperty('toString'), false
);
这就是如何安全地使用这种方法(详情请见“安全使用Object.prototype方法”(§31.9.1)):
const obj = {
// Overrides Object.prototype.hasOwnProperty
hasOwnProperty: true,
};
// Doesn’t work in this case:
assert.throws(
() => obj.hasOwnProperty('anyPropKey'),
{
name: 'TypeError',
message: 'obj.hasOwnProperty is not a function',
}
);
// Safe way of using .hasOwnProperty():
assert.equal(
Object.prototype.hasOwnProperty.call(obj, 'anyPropKey'), false
);
31.10 快速参考:Object.prototype.*
31.10.1 Object.prototype.*: 配置对象如何转换为原始值
以下方法具有默认实现,但通常在子类或实例中被覆盖。它们确定对象如何转换为原始值(例如,通过+运算符)。
-
Object.prototype.toString()ES1配置对象如何转换为字符串。
> 'Object: ' + {toString() {return 'Hello!'}} 'Object: Hello!'更多信息.
-
Object.prototype.toLocaleString()ES3一种可以通过参数以各种方式配置的
.toString()版本(语言、区域等)。更多信息. -
Object.prototype.valueOf()ES1配置对象如何转换为非字符串原始值(通常是数字)。
> 1 + {valueOf() {return 123}} 124更多信息.
31.10.2 Object.prototype.*:具有陷阱的有用方法
以下方法很有用,但如果在对象上调用它们,则无法使用:
-
Object.prototype不是该对象的原型。 -
在原型链的某个地方重写了
Object.prototype方法。
如何绕过这种限制在[“安全使用Object.prototype方法”(§31.9.1)]中解释。
这些是方法:
-
Object.prototype.isPrototypeOf()ES3接收器是否是给定对象的原型链中的一部分?
如果你在一个对象上调用此方法,通常没问题。如果你想确保安全,可以使用以下模式(行 A):
function isInstanceOf(obj, aClass) { return {}.isPrototypeOf.call(aClass.prototype, obj); // (A) } assert.equal( isInstanceOf([], Object), true ); assert.equal( isInstanceOf([], Array), true ); assert.equal( isInstanceOf(/x/, Array), false );更多信息.
-
Object.prototype.propertyIsEnumerable()ES3接收器是否具有给定键的可枚举自有属性?
如果你在一个对象上调用此方法,通常没问题。如果你想确保安全,可以使用以下模式:
> {}.propertyIsEnumerable.call(['a'], 'length') false > {}.propertyIsEnumerable.call(['a'], '0') true更多信息.
31.10.3 Object.prototype.*:要避免的方法
避免以下特性(有更好的替代方案):
-
get Object.prototype.__proto__ES6避免使用:
-
相反,使用
Object.getPrototypeOf()。 -
更多信息.
-
-
set Object.prototype.__proto__ES6避免使用:
-
相反,使用
Object.setPrototypeOf()。 -
更多信息.
-
-
Object.prototype.hasOwnPropertyES3避免使用:
-
相反,使用
Object.hasOwn()(ES2022) -
更多信息.
-
31.11 FAQ:类
31.11.1 为什么这本书中称其为“实例私有字段”而不是“私有实例字段”?
这样做是为了突出不同的属性(公共槽位)和私有槽位之间的差异:通过改变形容词的顺序,"public"和"field"以及"private"和"field"这些词总是同时出现。
31.11.2 为什么这本书中称其为“实例私有字段”而不是“私有实例字段”?
是否可以通过private声明私有字段并使用普通标识符?让我们看看如果这是可能的话会发生什么:
class MyClass {
private value; // (A)
compare(other) {
return this.value === other.value;
}
}
每当在MyClass的主体中出现类似other.value的表达式时,JavaScript 必须决定:
-
.value是一个属性吗? -
.value是一个私有字段吗?
在编译时,JavaScript 无法确定行 A 中的声明是否适用于 other(因为它是一个 MyClass 的实例)。这留下了两个做出决定的选项:
-
.value总是被解释为一个私有字段。 -
JavaScript 在运行时做出决定:
-
如果
other是MyClass的一个实例,那么.value被解释为一个私有字段。 -
否则
.value被解释为一个属性。
-
这两个选项都有缺点:
-
使用选项(1),我们不能再将
.value作为属性使用——对于任何对象。 -
使用选项(2)会负面影响性能。
因此引入了名称前缀 #。现在决定变得简单:如果我们使用 #,我们想要访问一个私有字段。如果我们不使用,我们想要访问一个属性。
private 对静态类型语言(如 TypeScript)有效,因为它们在编译时知道 other 是否是 MyClass 的实例,然后可以将 .value 作为私有或公共来处理。


浙公网安备 33010602011771号