天天看点

《JavaScript权威指南第7版》第9章 类9.1 类和原型9.2 类和构造函数9.3 class关键字创建类9.4 向现有类中添加方法9.5 子类9.6 总结

第9章 类

  • 9.1 类和原型
  • 9.2 类和构造函数
    • 9.2.1 构造函数,类的标识和instanceof
    • 9.2.2 constructor属性
  • 9.3 class关键字创建类
    • 9.3.1 静态方法
    • 9.3.2 Getter,Setter和其它方法形式
    • 9.3.3 公有,私有和静态字段
    • 9.3.4 示例:一个复数类
  • 9.4 向现有类中添加方法
  • 9.5 子类
    • 9.5.1 子类和原型
    • 9.5.2 使用extends和super来创建子类
    • 9.5.3 委托替代继承
    • 9.5.4 类层次结构和抽象类
  • 9.6 总结

第6章介绍了JavaScript对象。那一章把每个对象都看作是一组独特的属性集合,与任何其它对象都不同。但是,定义一类共享某些属性的对象通常是有用的。类的成员或实例有自己的属性来保存或定义其状态,但它们也有定义其行为的方法。这些方法由类定义,并由所有实例共享。例如,假设有一个名为Complex的类,它表示并执行对复数的算术运算。一个复杂实例将具有一些属性来保存复数的实部和虚部(状态)。复杂类将定义方法来执行这些数字的加法和乘法(行为)。

在JavaScript中,类使用基于原型的继承:如果两个对象从同一个原型继承属性(通常是函数值属性或方法),那么我们就说这些对象是同一个类的实例。简单地说,这就是JavaScript类的工作方式。在§6.2.3和§6.3.2中介绍了JavaScript原型和继承,要理解本章,您需要熟悉这些章节中的内容。本章涵盖了§9.1中的原型。

如果两个对象继承了相同的原型,这通常(但不一定)意味着它们是由相同的构造函数或工厂函数创建和初始化的。构造函数已经在§4.6、§6.2.2和§8.2.3中介绍过,本章在§9.2中有更多内容。

JavaScript一直允许定义类。ES6引入了全新的语法(包括class关键字),使创建类更加容易。这些新的JavaScript类的工作方式与旧式类相同,本章首先解释创建类的旧方法,因为这更清楚地展示了如何在幕后使类工作。一旦我们解释了这些基本原理,我们将改变并开始使用新的、简化的类定义语法。

如果您熟悉Java或c++等强类型的面向对象编程语言,您会注意到JavaScript类与这些语言中的类有很大的不同。它们在语法上有一些相似之处,而且您可以在JavaScript中模拟“经典”类的许多特性,但是最好预先了解JavaScript的类和基于原型的继承机制与Java和类似语言的类和基于类的继承机制有本质上的不同。

9.1 类和原型

在JavaScript中,类是一组从相同原型对象继承属性的对象。因此,原型对象是类的核心特性。第6章介绍了Object. create()函数,它返回一个新创建的对象,该对象继承自一个指定的原型对象。如果我们定义了一个原型对象,然后使用Object.create()创建从它继承的对象,那么我们就定义了一个JavaScript类。通常,类的实例需要进一步初始化,通常需要定义一个创建和初始化新对象的函数。示例9-1演示了这一点:它为表示一系列值的类定义了原型对象,还定义了创建和初始化类的新实例的工厂函数。

例子 9-1. 一个简单的JavaScript类

// 这是一个工厂函数,它返回一个新的range对象。
function range(from, to) {
    // 使用Object .create()创建一个继承下面定义的原型对象的对象。
    // 原型对象作为这个函数的属性存储,并为所有range对象定义共享方法(行为)。
    let r = Object.create(range.methods);
    // 存储这个新range对象的起始点和结束点(状态)。
	// 这些是该对象唯一的非继承属性。
    r.from = from;
    r.to = to;
    // 最后返回新对象
    return r;
}
// 这个原型对象定义了所有range对象继承的方法。
range.methods = {
    //如果x在范围内,返回true,否则返回false
	//此方法适用于文本和日期范围以及数字范围。
    includes(x) { return this.from <= x && x <= this.to; },
    //使类的实例可迭代的生成器函数。
	//注意,它只对数值范围有效。
    *[Symbol.iterator]() {
        for (let x = Math.ceil(this.from); x <= this.to; x++) yield x;
    },
    // 返回range的字符串表示形式
    toString() { return "(" + this.from + "..." + this.to + ")"; }
};
// 下面是使用range对象的示例。
let r = range(1, 3); // 创建一个range对象
r.includes(2) // => true: 2 在范围中
r.toString() // => "(1...3)"
[...r] // => [1, 2, 3]; 通过迭代器转换为数组
           

在示例9-1的代码中有几点值得注意:

  • 这段代码定义了工厂函数range(),用于创建新的range对象。
  • 它使用range()函数的methods属性作为存储定义类的原型对象的方便位置。将原型对象放在这里并没有什么特别或惯用的地方。
  • range()函数定义了每个range对象上的from和to属性。这些是未共享的、非继承的属性,它们定义每个单独范围对象的唯一状态。
  • range.methods对象使用ES6简化语法来定义方法,这就是为什么在任何地方都看不到function关键字的原因。(参见§6.10.5来回顾对象字面量方法简写语法。)
  • 原型中的一个方法具有计算名称(§6.10.2)Symbol.iterator,这意味着它为范围对象定义了一个迭代器。这个方法的名称以*作为前缀,这表明它是一个生成器函数而不是常规函数。迭代器和生成器将在第12章中详细介绍。现在,这个Range类的实例可以与For /of循环和…展开运算符一起使用。
  • range.methods内定义的共享、继承方法都使用range()工厂函数中初始化的from和to属性。为了引用它们,它们使用this关键字来引用调用它们的对象。这种用法是任何类方法的基本特征。

9.2 类和构造函数

示例9-1演示了一种定义JavaScript类的简单方法。但是,这不是惯用的方法,因为它没有定义构造函数。构造函数是为初始化新创建的对象而设计的函数。使用new关键字调用构造函数,如§8.2.3所述。构造函数调用使用new自动创建新对象,因此构造函数本身只需要初始化新对象的状态。构造函数调用的关键特性是,构造函数的prototype属性被用作新对象的原型。§6.2.3介绍了原型,强调了几乎所有对象都有原型,只有少数对象有原型属性。最后,我们要澄清一点:函数对象具有prototype属性。这意味着用相同的构造函数创建的所有对象都继承相同的对象,因此它们是相同类的成员。例9-2展示了如何改变例9-1的Range类,使其使用构造函数而不是工厂函数。例9-2演示了在不支持ES6 class 关键字的JavaScript版本中创建类的惯用方法。尽管class关键字现在都可以支持了,仍有大量的旧的JavaScript代码定义的类没有使用class关键字,你应该熟悉这些惯用写法,这样您就可以阅读旧代码, 而且当你使用class关键字的时候也能够明白“底层原理”。

示例 9-2. 使用构造函数的Range类

// 这是一个初始化新的范围对象的构造函数。
// 注意,它不创建或返回对象。它只是初始化this。
function Range(from, to) {
    //存储这个新范围对象的起始点和结束点(状态)。
	//这些是该对象唯一的非继承属性。
    this.from = from;
    this.to = to;
}

//所有范围对象都继承此对象。
//注意,属性名必须为“prototype”才能工作。
Range.prototype = {
    //如果x在范围内,返回true,否则返回false
	//此方法适用于文本和日期范围以及数字范围。
    includes: function(x) { return this.from <= x && x <= this.to; },

    //使类的实例可迭代的生成器函数。
	//注意,它只对数值范围有效。
    [Symbol.iterator]: function*() {
        for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
    },

    //返回范围的字符串表示形式
    toString: function() { return "(" + this.from + "..." + this.to + ")"; }
};

//下面是这个新的Range类的示例用法
let r = new Range(1,3); // 创建一个Range对象;注意new的用法
r.includes(2) // => true: 2在范围内
r.toString() // => "(1...3)"
[...r] // => [1, 2, 3]; 通过迭代器转换为数组
           

值得相当仔细地比较示例9-1和9-2,并注意这两种定义类的技术之间的差异。首先,请注意,在将range()工厂函数转换为构造函数时,我们将其重命名为Range()。这是一种非常常见的编码约定:在某种意义上,构造函数定义了类,而类的名称(按照约定)以大写字母开头。常规函数和方法的名称以小写字母开头。

接下来,请注意使用new关键字调用Range()构造函数(在示例的最后),而不使用new关键字调用Range()工厂函数。例9-1使用常规函数调用(§8.2.1)来创建新对象,例9-2使用构造函数调用(§8.2.3)。因为Range()构造函数是用new调用的,所以它不必调用Object. create()或采取任何操作来创建新对象。在调用构造函数之前自动创建新对象,并且可以将其作为this值访问。Range()构造函数只需对其进行初始化。构造函数甚至不需要返回新创建的对象。构造函数调用自动创建一个新对象,将构造函数作为该对象的方法调用,并返回新对象。构造函数调用与常规函数调用如此不同,这是我们为构造函数命名以大写字母开头的另一个原因。构造函数被编写为使用new关键字作为构造函数调用,如果作为常规函数调用,它们通常无法正常工作。将构造函数与常规函数区分开来的命名约定有助于程序员知道何时使用new。

构造函数和new.target

在函数体中,可以通过特殊表达式new.target判断函数是否被作为构造函数调用。如果定义了该表达式的值,那么您就知道该函数是作为一个构造函数调用的,带有new关键字。当我们在§9.5中讨论子类的时候,我们会看到new.target并不总是引用它所使用的构造函数:它也可能引用子类的构造函数。

如果new.target值是undefined,则包含函数是作为函数调用的,没有使用new关键字。JavaScript的各种错误的构造函数的调用都是因为没有使用new,如果你想在构造函数中模仿这个功能,你可以这样写:

function C() {
	if (!new.target) return new C();
	// 初始化代码在这里
}
           
这种技术只适用于以这种老式方式定义的构造函数。使用class关键字创建的类不允许在没有new的情况下调用其构造函数。

示例9-1和9-2之间的另一个关键区别是原型对象的命名方式。在第一个示例中,原型是range.methods。这是一个方便和描述性的名称,但是比较随意。在第二个例子中,原型是Range.prototype,这个名称是强制规定的。调用Range()构造函数会自动使用Range.prototype作为新Range对象的原型。

最后,还要注意示例9-1和9-2之间没有变化的事情:两个类都以相同的方式定义和调用range方法。因为示例9-2演示了在ES6之前的JavaScript版本中创建类的惯用方法,所以它没有在prototype对象中使用ES6方法简写语法,而是使用function关键字显式地说明方法。但是您可以看到,两个示例中方法的实现是相同的。

重要的是,请注意,这两个range示例在定义构造函数或方法时都没有使用箭头函数。回想一下§8.1.3中定义的箭头函数没有prototype属性,因此不能用作构造函数。此外,箭头函数从定义它们的上下文继承this关键字,而不是根据调用它们的对象来设置它,这使得它们对于方法毫无用处,因为方法的定义特征是它们使用this来引用它们被调用的实例。

幸运的是,新的ES6类语法不允许使用箭头函数定义方法,因此我们就不会不小心犯这种错误。我们将很快讨论ES6 class关键字,但是首先,有更多关于构造函数的细节。

9.2.1 构造函数,类的标识和instanceof

正如我们所看到的,原型对象是类标识的基础:两个对象是同一个类的实例,当且仅当它们从同一个原型对象继承时。初始化新对象状态的构造函数不是基本的:两个构造函数可能具有指向同一原型对象的原型属性。然后,两个构造函数都可以用来创建同一个类的实例。

尽管构造函数不像原型那么基本,但构造函数充当了类的公共形象。最明显的是,构造函数的名称通常被用作类的名称。例如,我们说Range()构造函数创建了Range对象。然而,更基本的是,当测试对象是否属于某个类时,构造函数被用作instanceof操作符的右操作数。如果我们有一个对象r,想知道它是否是一个Range对象,我们可以这样写:

instanceof操作符的描述见§4.9.4。左边的操作数应该是正在测试的对象,右边的操作数应该是命名类的构造函数。如果o继承了C.prototype,那么表达式o instanceof C的值为真。继承不需要是直接继承:如果o继承了一个对象,而这个对象又继承了一个从C.prototype继承的对象,那么表达式的值仍然为真。

从技术上讲,在前面的代码示例中,instanceof操作符没有检查r是否真的被Range构造函数初始化了。相反,它检查r是否继承了Range.prototype。如果我们定义一个函数Strange()并将其原型设置为与Range.prototype相同,那么用new Strange()创建的对象就instanceof而言将被视为Range对象(它们实际上不会作为Range对象工作,因为它们的from和to属性还没有初始化):

function Strange() {}
Strange.prototype = Range.prototype;
new Strange() instanceof Range // => true
           

即使instanceof不能实际验证构造函数的使用,它仍然使用构造函数作为它的右操作数,因为构造函数是类的公共标识。

如果您想要测试特定原型的对象的原型链,并且不想使用构造函数作为中介,那么可以使用isPrototypeOf()方法。例如,在例9-1中,我们定义了一个没有构造函数的类,因此无法对该类使用instanceof。相反,我们可以用下面的代码来测试对象r是否是无构造函数类的成员:

9.2.2 constructor属性

在例9-2中,我们设置了Range.prototype指向了一个包含类方法的新对象。虽然将这些方法表示为单个对象对象字面量的属性很方便,但实际上并不需要创建一个新对象。任何常规的JavaScript函数(不包括箭头函数、生成器函数和异步函数)都可以用作构造函数1,而构造函数调用需要一个prototype属性。因此,每个常规JavaScript函数自动拥有一个prototype属性。这个属性是一个对象,包含了一个具有不可枚举的constructor属性。constructor属性的值是函数对象本身:

let F = function() {}; // 一个函数对象.
let p = F.prototype; // 这是与F相关的原型对象.
let c = p.constructor; // 这是与原型相关联的函数.
c === F // => true: 对于任何F,F.prototype.constructor === F
           

这个预定义的原型对象及其constructor属性的存在意味着对象通常继承引用其构造函数的constructor属性。由于构造函数作为类的公共标识,这个constructor属性指出了一个对象引用的类:

let o = new F(); // 创建一个类F的对象o
o.constructor === F // => true: constructor属性指向类
           

图9-1说明了构造函数、它的原型对象、从原型到构造函数的反向引用以及用构造函数创建的实例之间的关系。

《JavaScript权威指南第7版》第9章 类9.1 类和原型9.2 类和构造函数9.3 class关键字创建类9.4 向现有类中添加方法9.5 子类9.6 总结

图 9-1 构造函数、它的原型和实例

注意,图9-1使用了Range()构造函数作为示例。然而,事实上,例9-2中定义的Range类会覆盖预定义的Range.prototype,使用自己的原型对象。它定义的新原型对象没有constructor属性。因此,Range类的实例如定义的那样没有constructor属性。我们可以通过显式地向原型添加一个constructor来解决这个问题:

Range.prototype = {
	constructor: Range, // 显式设置构造函数的反向引用
	
	/* 方法定义在这里 */
};
           

在旧的JavaScript代码中,你可能会看到的另一个常见技术是使用预定义的原型对象及其构造函数属性,并添加方法,每次一个,代码如下:

// 扩展预定义的Range.prototype对象,这样我们就不会重写
// 自动创建的Range.prototype.constructor属性。
Range.prototype.includes = function (x) {
    return this.from <= x && x <= this.to;
};
Range.prototype.toString = function () {
    return "(" + this.from + "..." + this.to + ")";
};
           

9.3 class关键字创建类

从JavaScript语言的第一个版本开始,类就一直是JavaScript的一部分,但是在ES6中,通过引入class关键字,它们最终获得了自己的语法。例9-3显示了使用这种新语法编写Range类时的情况。

例9-3. 使用class重写的Range类

class Range {
    constructor(from, to) {
        //存储此新范围对象的起点和终点(状态)。
		//这些是此对象特有的非继承属性。
        this.from = from;
        this.to = to;
    }
    //如果x在范围内,则返回true,否则返回false
	//此方法适用于文本和日期范围以及数字范围。
    includes(x) { return this.from <= x && x <= this.to; }
    //使类的实例变为可迭代的的生成器函数。
	//请注意,它只适用于数字范围。
    *[Symbol.iterator]() {
        for (let x = Math.ceil(this.from); x <= this.to; x++) yield x;
    }
    //返回字符串表示形式的范围
    toString() { return `(${this.from}...${this.to})`; }
}

// 下面是这个新范围类的示例用法
let r = new Range(1,3); // 创建范围对象
r.includes(2) // => true: 2在范围内
r.toString() // => "(1...3)"
[...r] // => [1, 2, 3]; convert to an array via iterator
           

理解示例9-2和9-3中定义的类的工作方式完全相同是很重要的。在语言中引入class关键字并不会改变JavaScript基于原型的类的基本性质。尽管示例9-3使用了class关键字,但结果Range对象是一个构造函数,就像示例9-2中定义的版本一样。新的类语法是干净和方便的,但是对于示例9-2所示的更基本的类定义机制,class语法最好将其视为“语法糖”。

注意示例9-3中关于class语法的以下内容:

  • 类是用class关键字声明的,关键字后跟类的名称和用大括号括起来的类主体。
  • 类主体包括使用对象字面量的简写方式定义方法(我们在示例9-1中也使用了这种方法),其中省略了function关键字。但是,与对象字面量不同,没有使用逗号来分隔方法。(尽管类主体在表面上与对象字面量相似,但它们不是同一回事。特别是,它们不支持使用名称/值对定义属性。)
  • 关键字constructor用于定义类的构造函数函数。但是,定义的函数实际上并没有命名为“constructor”。类声明语句定义一个新的变量Range,并将此特殊构造函数的值赋给该变量。
  • 如果您的类不需要进行任何初始化,则可以省略constructor关键字及其主体,并且将隐式为您创建一个空的构造函数函数。

如果要定义子类或从另一个类继承的类,可以将extends关键字与class关键字一起使用:

// Span类似于Range,但是我们不是用start和end来初始化它,
// 而是用start和length来初始化它
class Span extends Range {
    constructor(start, length) {
        if (length >= 0) {
            super(start, start + length);
        } else {
            super(start + length, start);
        }
    }
}
           

创建子类本身就是一个完整的主题。我们将在§9.5重新讲解这个主题,并解释这里显示的extends和super关键字。

与函数声明一样,类声明同时具有语句和表达式形式。正如我们可以写的:

let square = function(x) { return x * x; };
square(3) // => 9
           

我们也可以这样写:

let Square = class { constructor(x) { this.area = x * x; } };
new Square(3).area // => 9
           

与函数定义表达式一样,类定义表达式可以包含可选的类名。如果您提供了这样的名称,那么该名称只在类主体本身中定义。

虽然函数表达式很常见(特别是箭头函数的简写),但是在JavaScript编程中,除非您发现自己编写了一个以类为参数并返回子类的函数,否则您不太可能使用类定义表达式。

在结束对class关键字的介绍时,我们将提到一些在class语法中不明显的重要事项:

-类声明主体中的所有代码都隐式地处于严格模式(§5.6.3),即使没有出现“use strict”指令。例如,这意味着您不能在类主体中使用八进制整型字面值或with语句,并且如果在使用变量之前忘记声明变量,则更容易出现语法错误。

与函数声明不同,类声明不会提升。回想一下§8.1.1,函数定义的行为就像是移到了封闭文件或封闭函数的顶部,这意味着您可以在函数实际定义之前的代码中调用函数。虽然类声明在某些方面类似于函数声明,但它们不共享这种提升行为:在声明类之前不能实例化类。

9.3.1 静态方法

通过在方法声明的前面加上static关键字,可以在类主体中定义静态方法。静态方法被定义为构造函数的属性,而不是原型对象的属性。

例如,假设我们在例9-3中添加了以下代码:

static parse(s) {
    let matches = s.match(/^\((\d+)\.\.\.(\d+)\)$/);
    if (!matches) {
        throw new TypeError(`Cannot parse Range from "${s}".`)
    }
    return new Range(parseInt(matches[1]), parseInt(matches[2]));
}
           

此代码定义的方法是Range.parse(),不是Range.prototype.parse(),并且必须通过构造函数调用它,而不是通过实例:

let r = Range.parse('(1...10)'); // 返回一个新的Range对象
r.parse('(1...10)'); // TypeError: r.parse 不是一个函数
           

您有时会看到称为类方法的静态方法,因为它们是使用类/构造函数的名称调用的。当使用这个术语时,它是为了对比类方法和在类实例上调用的常规实例方法。因为静态方法是在构造函数上而不是在任何特定实例上调用的,所以在静态方法中使用this关键字几乎没有意义。

我们将在示例9-4中看到静态方法的示例。

9.3.2 Getter,Setter和其它方法形式

在类主体中,可以像在对象字面量中那样定义getter和setter方法(§6.10.6)。唯一的区别是在类主体中,在getter或setter之后不加逗号。示例9-4包括一个类中getter方法的实际示例。

一般来说,对象字面量中允许的所有简写方法定义语法也允许在类主体中使用。这包括生成器方法(用*标记)和名称为方括号中表达式值的方法。实际上,您已经看到(在示例9-3中)一个生成器方法,该方法的计算名称使Range类成为可迭代的:

*[Symbol.iterator]() {
	for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
}
           

9.3.3 公有,私有和静态字段

在这里讨论用class关键字定义的类时,我们只描述了类主体中方法的定义。ES6标准只允许创建方法(包括getter、setter和生成器)和静态方法;它不包括定义字段的语法。如果要在类实例上定义字段(它只是面向对象中“属性”的同义词),则必须在构造函数或其中一个方法中进行定义。如果你想为一个类定义一个静态字段,你必须在类体之外,且在类被定义之后。示例9-4包括这两种字段的示例。

然而,扩展类语法的标准化正在进行中,扩展类语法允许以公共和私有形式定义实例和静态字段。本节其余部分显示的代码到2020年初还不是标准JavaScript,但Chrome已经支持它,Firefox也部分支持(仅限于公共实例字段)。公共实例字段的语法通常由使用React框架和Babel翻译器的JavaScript程序员使用。

假设您正在编写这样一个类,其中包含初始化三个字段的构造函数:

class Buffer {
    constructor() {
        this.size = 0;
        this.capacity = 4096;
        this.buffer = new Uint8Array(this.capacity);
    }
}
           

使用可能标准化的新实例字段语法,您可以改为编写:

class Buffer {
    size = 0;
    capacity = 4096;
    buffer = new Uint8Array(this.capacity);
}
           

字段初始化代码已移出构造函数,现在直接出现在类主体中。(当然,该代码仍然作为构造函数的一部分运行。如果不定义构造函数,则字段初始化为隐式创建的构造函数的一部分。出现在赋值左侧的this.前缀已不存在,但请注意,您仍然必须使用此前缀this.引用这些字段,即使在初始值设定项赋值的右侧也是如此。以这种方式初始化实例字段的好处是,这种语法允许(但不要求)将初始值设定项放在类定义的顶部,使读者清楚地知道哪些字段将保存每个实例的状态。只需在字段名称后面加上分号,就可以声明没有初始值设定项的字段。如果这样做,字段的初始值将是undefined的。始终明确显示类字段是一个比较好的编码风格。

在添加此字段语法之前,类主体看起来很像使用方法简写语法的对象字面量,只是逗号已被删除。这种使用等号和分号而不是冒号和逗号的字段语法清楚地表明类主体与对象字面量根本不同。

标准化这些实例字段的建议中也定义了私有实例字段。如果使用上一个示例中显示的实例字段初始化语法来定义名称以#开头的字段(这在JavaScript标识符中通常不是合法字符),则该字段在类主体中可用(前缀为#),但对于外部的任何代码都不可见和不可访问(因此是不可变的)。对于前面假设的缓冲区类,如果希望确保该类的用户不会无意中修改实例的size字段,则可以改用私有的size字段,然后定义一个getter函数来提供对该值的只读访问:

class Buffer {
	#size = 0;
	get size() { return this.#size; }
}
           

请注意,在使用私有字段之前,必须使用此新字段语法声明它们。你不能只在类的构造函数中写this.#size=0;,除非你直接在类主体中包含这个字段的“声明”。

最后,一个相关的建议试图规范字段的静态关键字的使用。如果在公共或私有字段声明之前添加static,则这些字段将创建为构造函数函数的属性,而不是实例的属性。考虑我们定义的静态方法Range.parse()。它包含一个相当复杂的正则表达式,最好把它作为静态字段来使用。使用所提议的新静态字段语法,我们可以这样做:

static integerRangePattern = /^\((\d+)\.\.\.(\d+)\)$/;
static parse(s) {
    let matches = s.match(Range.integerRangePattern);
    if (!matches) {
        throw new TypeError(`Cannot parse Range from "${s}".`)
    }
    return new Range(parseInt(matches[1]), matches[2]);
}
           

如果我们希望这个静态字段只能在类中访问,我们可以使用类似于#pattern的名称将其设为私有。

9.3.4 示例:一个复数类

例9-4定义了一个表示复数的类。该类相对简单,但它包含实例方法(包括getter)、静态方法、实例字段和静态字段。它包含一些注释掉的代码,演示如何使用尚未标准的语法在类主体中定义实例字段和静态字段。

例9-4. Complex.js:复数类

/**
 * 这个复杂类的实例表示复数。
 * 回想一下复数是一个实数和一个虚数的和,虚数i是-1的平方根。
 */
class Complex {
    // 一旦类字段声明标准化,我们就可以声明
    // 私有字段来保存复数的实部和虚部,代码如下:
    //
    // #r = 0;
    // #i = 0;

    // 这个构造函数在它创建的每个实例上定义实例字段r和i。
    // 这些字段包含复数的实部和虚部:它们是对象的状态。
    constructor(real, imaginary) {
        this.r = real;       // 此字段保存数字的实部。
        this.i = imaginary;  // 这个区域包含虚部。
    }

    // 这里有两个复数加法和乘法的实例方法。如果c和d是这个类的实例,
    // 我们可以写c.plus(d)或d.times(c)
    plus(that) {
        return new Complex(this.r + that.r, this.i + that.i);
    }
    times(that) {
        return new Complex(this.r * that.r - this.i * that.i,
                           this.r * that.i + this.i * that.r);
    }

    //这里是复数算术方法的静态变体。
	//我们可以写Complex.sum(c,d)和Complex.product(c、d)
    static sum(c, d) { return c.plus(d); }
    static product(c, d) { return c.times(d); }

    // 这些是一些被定义为getter的实例方法,因此它们像字段一样使用。
    // 如果我们使用私有字段this.#r和this.#i,那么实数和虚数的getter方法会很有用
    get real() { return this.r; }
    get imaginary() { return this.i; }
    get magnitude() { return Math.hypot(this.r, this.i); }

    // 类几乎总是应该有一个toString()方法
    toString() { return `{${this.r},${this.i}}`; }

    //定义一个方法来测试类的两个实例是否表示相同的值,这通常很有用
    equals(that) {
        return that instanceof Complex &&
            this.r === that.r &&
            this.i === that.i;
    }

    //一旦类主体中支持了静态字段,我们就可以定义一个有用的像Complex.ZERO这样的常数:
    // static ZERO = new Complex(0,0);
}

// 下面是一些保存有用的预定义复数的类字段。
Complex.ZERO = new Complex(0,0);
Complex.ONE = new Complex(1,0);
Complex.I = new Complex(0,1);
           

在定义了例9-4的复数类后,我们可以使用如下代码使用构造函数、实例字段、实例方法、类字段和类方法:

let c = new Complex(2, 3); // 使用构造函数创建新对象
let d = new Complex(c.i, c.r); // 使用c的实例字段
c.plus(d).toString() // => "{5,5}"; 使用实例方法
c.magnitude // => Math.hypot(2,3); 使用getter函数
Complex.product(c, d) // => new Complex(0, 13); 静态方法
Complex.ZERO.toString() // => "{0,0}"; 静态属性
           

9.4 向现有类中添加方法

JavaScript基于原型的继承机制是动态的:即使在对象创建后原型的属性发生了变化,对象仍然可以从其原型继承新的属性。这意味着我们可以通过向JavaScript类的原型对象中添加新方法来扩充JavaScript类。

例如,这里的代码将计算复数共轭的方法添加到例9-4的复数类中:

// 返回一个复数,它是这个复数的复共轭。
Complex.prototype.conj = function() { return new Complex(this.r, -this.i); };
           

内置JavaScript类的原型对象也是这样开放的,这意味着我们可以向数字、字符串、数组、函数等添加方法。这对于在旧版本的语言中实现新的语言功能很有用:

// 如果新的字符串方法startsWith() 尚未定义...
if (!String.prototype.startsWith) {
    // ...然后使用旧的index()方法定义它。
    String.prototype.startsWith = function (s) {
        return this.indexOf(s) === 0;//译者注:别这么写,效率太低!!!
    };
}
           

下面是另一个例子:

// 多次调用函数f,传递迭代次数
// 例如,要打印“hello”三次:
// let n = 3;
// n.times(i => { console.log(`hello ${i}`); });
Number.prototype.times = function (f, context) {
    let n = this.valueOf();
    for (let i = 0; i < n; i++) f.call(context, i);
};
           

将方法添加到这样的内置类型的原型中通常被认为是一个坏主意,因为如果新版本的JavaScript定义了同名的方法,这将导致混乱和兼容性问题。甚至可以将方法添加到Object.prototype,使它们可用于所有对象。但这绝不是一件好事,因为属性添加到Object.prototype对for/in循环可见(但是可以通过使用Object.defineProerty() [§14.1]使新属性不可枚举)。

9.5 子类

在面向对象编程中,一个类B可以继承或子类化另一个类A。我们说A是超类,B是子类。B的实例继承A的方法。类B可以定义自己的方法,其中一些方法可以重写类A定义的同名方法。如果B的方法重写了A的方法,B中的重写方法通常需要调用A中被重写的方法。同样,子类构造函数B()通常必须调用超类构造函数A()以确保实例完全初始化。

本节首先展示了如何用旧的ES6之前的方式定义子类,然后快速介绍如何使用class和extends关键字来进行子类化,使用super关键字对超类构造函数进行调用。接下来小节讲的是关于避免创建子类和继承,依赖通过对象组合。本节以一个扩充现在的Set类的示例来做结尾,该示例定义了集合类的层次结构,并演示了如何使用抽象类将接口与实现分离。

9.5.1 子类和原型

假设我们想定义例9-2中Range类的一个Span子类。这个子类的工作方式与Range类似,但是我们初始化时不再指定一个开始和一个结束,而是指定一个开始和一个距离,或者跨度。这个Span类的实例也是Range超类的实例。span实例从Span.protype继承自定义的toString()方法,但为了成为Range的子类,它还必须从Range.prototype中继承方法(如includes()。

例9-5. Span.js:Range的简单子类

// 这是子类的构造函数
function Span(start, span) {
    if (span >= 0) {
        this.from = start;
        this.to = start + span;
    } else {
        this.to = start;
        this.from = start + span;
    }
}
// 确保Span原型继承自Range原型
Span.prototype = Object.create(Range.prototype);
// 我们不想继承Range.prototype.constructor,所以我们
// 定义我们自己的构造函数属性。
Span.prototype.constructor = Span;
// 定义自己的toString()方法,否则Span
// 将从Range继承的toString()方法。
Span.prototype.toString = function () {
    return `(${this.from}... +${this.to - this.from})`;
};
           

译者注:

也许有时候你真的需要使用instanceof来判断一个这个实例是哪个类。
let span = new Span();
console.log(span instanceof Span); // true
console.log(span instanceof Range); // true
           
那么instanceof到底是根据什么来判断呢,我觉得是根据原型,通过查询一个实例的原型是否等于某个类的原型,来判断这个实例是不是这个类的实例,有时还要判断原型的原型,所以上面的哪个instanceof判断,可以改写成如下判断形式:
console.log(Object.getPrototypeOf(span) == Span.prototype); // true
console.log(Object.getPrototypeOf(Object.getPrototypeOf(span)) == Range.prototype); // true
           

为了使Span成为Range的子类,我们需要让Span.prototype继承Range.prototype. 前面示例中的关键代码行是这一行,它将有有助于您将了解JavaScript中的子类是如何工作的:

使用Span()构造函数创建的对象将从Span.prototype对象进行继承,然后我们让这个原型又继承了Range.prototype的对象,因此Span对象将同时继承Span.prototype以及Range.prototype。

您可能会注意到,我们的Span()构造函数设置了与Range()构造函数相同的from和to属性,因此不需要调用Range()构造函数来初始化新对象。类似地,Span的toString()方法完全重新实现了字符串转换,而不需要调用Range的toString()版本。这使得Span成为一种特殊情况,我们只能真正摆脱这种子类化,因为我们知道超类的实现细节。一个健壮的子类化机制需要允许类调用超类的方法和构造函数,但是在ES6之前,JavaScript没有一种简单的方法来完成这些事情。

幸运的是,ES6使用super关键字作为类语法的一部分来解决这些问题。下一节将演示它是如何工作的。

9.5.2 使用extends和super来创建子类

在ES6及更高版本中,您可以通过向类声明中添加extends子句来创建子类,甚至对于内置类也可以这样做:

// 为第一个和最后一个元素添加getter的普通数组子类。
class EZArray extends Array {
    get first() { return this[0]; }
    get last() { return this[this.length - 1]; }
}
let a = new EZArray();
a instanceof EZArray // => true: a是子类实例
a instanceof Array // => true: a也是一个超类实例。
a.push(1, 2, 3, 4); // a.length == 4;我们可以使用继承的方法
a.pop() // => 4: 另一个继承的方法
a.first // => 1: 子类定义的 first getter
a.last // => 3: 子类定义的 last getter
a[1] // => 2: 常规数组访问语法仍然有效。
Array.isArray(a) // => true: 子类实例实际上是一个数组
EZArray.isArray(a) // => true: 子类也继承静态方法!
           

这个EZArray子类定义了两个简单的getter方法。EZArray实例的行为与普通数组类似,我们可以使用继承的方法和属性,如push()、pop()和length。但是我们也可以使用子类中定义的first和last getter。不仅像pop()这样的实例方法是继承的,静态方法像Array.isArray也是可以继承的的。这是ES6类语法启用的新功能:EZArray()是一个函数,但它继承自Array():

//EZArray继承实例方法是因为EZArray.prototype
//继承自Array.prototype
Array.prototype.isPrototypeOf(EZArray.prototype) // => true

// 而EZArray继承静态方法和属性,因为EZArray继承自Array。
// 这是extends关键字的一个特殊特性,在ES6之前是不可能的。
Array.isPrototypeOf(EZArray) // => true
           

我们的EZArray子类太简单,不太具有指导意义。例9-6是一个更加充实的例子。它定义了内置Map类的TypedMap子类,该子类添加了类型检查,以确保映射的键和值属于指定的类型(根据typeof)。重要的是,这个例子演示了如何使用super关键字来调用超类的构造函数和方法。

例9-6. TypedMap.js:Map的一个子类,用于检查键和值的类型

class TypedMap extends Map {
    constructor(keyType, valueType, entries) {
        // 如果指定了条目,请检查它们的类型
        if (entries) {
            for (let [k, v] of entries) {
                if (typeof k !== keyType || typeof v !== valueType) {
                    throw new TypeError(`Wrong type for entry [${k}, ${v}]`);
                }
            }
        }
        // 用(类型检查的)初始条目初始化超类
        super(entries);
        // 然后通过存储类型初始化这个子类
        this.keyType = keyType;
        this.valueType = valueType;
    }
    //现在重新定义set()方法,为添加到映射中的任何新条目添加类型检查。
    set(key, value) {
        // 如果键或值的类型错误,则引发错误
        if (this.keyType && typeof key !== this.keyType) {
            throw new TypeError(`${key} is not of type ${this.keyType}`);
        }
        if (this.valueType && typeof value !== this.valueType) {
            throw new TypeError(`${value} is not of type ${this.valueType}`);
        }
        // 如果类型是正确的,我们将调用超类的set()方法版本,
        // 真正地将条目添加到映射中。我们返回超类方法返回的值。
        return super.set(key, value);
    }
}
           

TypedMap()构造函数的前两个参数是所需的键和值类型。这些应该是typeof运算符返回的字符串,例如“number”和“boolean”。您还可以指定第三个参数:指定映射中初始项的[key,value]数组的数组(或任何可迭代对象)。如果您指定了任何初始条目,那么构造函数首先要做的就是验证它们的类型是否正确。接下来,构造函数调用超类构造函数,使用super关键字,就像它是函数名一样。Map()构造函数接受一个可选参数:一个[key,value]数组的可迭代对象。因此,TypedMap()构造函数的第三个可选参数是Map()构造函数的可选第一个参数,我们用super(entries)将其传递给超类构造函数。

在调用超类构造函数初始化超类状态之后,TypedMap()构造函数接下来通过设置this.keyType和this.valueType为指定的类型。它需要设置这些属性,以便可以在set()方法中再次使用它们。

关于在构造函数中使用super(),您需要了解一些重要规则:

  • 如果使用extends关键字定义类,则类的构造函数必须使用super()来调用超类构造函数。
  • 如果不在子类中定义构造函数,则会自动为您定义一个构造函数。这个隐式定义的构造函数只接受传递给它的任何值,并将这些值传递给super()。
  • 在用super()调用超类构造函数之前,不能在构造函数中使用this关键字。这强制了一个规则,即在子类初始化之前,超类必须先初始化自己。
  • 特殊表达new.target在不使用new关键字调用的函数中值是undefined。但是,在构造函数中,new.target是对调用的构造函数的引用。当调用子类构造函数并使用super()调用超类构造函数时,该超类构造函数将看到new.target等于子类的构造函数。 一个设计良好的超类不需要知道它是否已经被子类继承,但是如果在记录消息时,能够使用new.target.name还是挺有用的.

在构造函数之后,示例9-6的下一部分是名为set()的方法。Map超类定义了一个名为set()的方法来向映射添加一个新条目。我们说TypedMap中的set()方法重写了其超类的set()方法。这个简单的TypedMap子类不知道如何向映射添加新条目,但它知道如何检查类型,因此它首先要做的就是验证要添加到映射的键和值是否具有正确的类型,如果不正确,则抛出错误。这个set()方法无法将键和值添加到映射本身,但这正是超类set()方法的用途。所以我们再次使用super关键字来调用超类的方法版本。在这种情况下,super的工作方式与this关键字非常相似:它引用当前对象,但允许访问超类中定义的覆写方法。

在构造函数中,您需要先调用超类构造函数,然后才能访问this并自己初始化新对象。覆写方法时没有此类规则。覆写超类方法时,不需要调用超类方法。如果它确实使用super来调用超类中覆写的方法(或任何方法),那么它可以在覆写方法的开始、中间或结尾执行调用。

最后,在我们把TypedMap示例置之不理之前,值得注意的是,这个类是使用私有字段的理想候选。现在编写类时,用户可以更改keyType或valueType属性来破坏类型检查。一旦支持私有字段,我们就可以将这些属性更改为#keyType和#valueType,这样就不能从外部更改它们。

9.5.3 委托替代继承

extends关键字使创建子类变得容易。但这并不意味着您应该创建许多子类。如果你想写一个共享其他类行为的类,你可以尝试通过创建一个子类来继承这个行为。但是,通过让你的类创建另一个类的实例并根据需要委托给该实例,通常更容易、更灵活地将所需的行为引入到类中。创建一个新类,不是通过子类化,而是通过包装或“组合”其他类。这种委托方法通常被称为“组合”,这是面向对象编程的一条常被引用的格言,即应该“偏爱组合而不是继承”。

例如,假设我们想要一个类似JavaScript的Set类的Histogram类,除了不跟踪值是否已添加到Set之外,它还保留了值添加的次数的计数。因为这个Histogram类的API类似于Set,所以我们可以考虑将Set子类化并添加一个count()方法。另一方面,一旦我们开始考虑如何实现这个count()方法,我们可能会意识到Histogram类更像是一个映射而不是一个集合,因为它需要维护值和它们被添加的次数之间的映射。因此,我们可以创建一个类来定义一个类似于API的集合,但通过委托给一个内部映射对象来实现这些方法,而不是将Set子类化。例9-7展示了我们如何做到这一点。

例9-7. Histogram.js:用委托实现的类Set类

/**
* 一个类似集合的类,它跟踪一个值被添加了多少次。像对集合一样调用add()和remove(),
* 并调用count()来计算给定值的添加次数。默认迭代器生成至少已添加一次的值。
* 如果要迭代[value,count]对,请使用entries()。
*/
class Histogram {
    // 为了初始化,我们只需创建一个要委托给的映射对象
    constructor() { this.map = new Map(); }
    // 对于任何给定的键,计数是映射中的值,如果该键没有出现在映射中,则为零。
    count(key) { return this.map.get(key) || 0; }
    // 如果计数不为零,则类似Set的方法has()返回true
    has(key) { return this.count(key) > 0; }
    // 直方图的大小就是映射中的条目数。
    get size() { return this.map.size; }
    // 要添加一个键,只需在映射中增加它的计数。
    add(key) { this.map.set(key, this.count(key) + 1); }
    // 删除一个键有点困难,因为如果计数回到零,我们必须从映射中删除该键。
    delete(key) {
        let count = this.count(key);
        if (count === 1) {
            this.map.delete(key);
        } else if (count > 1) {
            this.map.set(key, count - 1);
        }
    }
    // 迭代直方图只返回存储在其中的键
    [Symbol.iterator]() { return this.map.keys(); }
    // 这些其他迭代器方法只委托给Map对象
    keys() { return this.map.keys(); }
    values() { return this.map.values(); }
    entries() { return this.map.entries(); }
}
           

在示例9-7中,Histogram()构造函数所做的就是创建一个Map对象。而且大多数方法都是一行程序,只委托给映射的一个方法,使得实现非常简单。因为我们使用的是委托而不是继承,所以直方图对象不是Set或Map的实例。但是Histogram实现了许多常用的Set方法,在JavaScript这样的非类型化语言中,这通常就足够了:正式继承关系有时很好,但通常是可选的。

9.5.4 类层次结构和抽象类

示例9-6演示了如何将Map子类化。示例9-7演示了我们可以在没有子类化的情况下委托给Map对象。使用JavaScript类来封装数据和模块化代码通常是一种很好的技术,您可能会发现自己经常使用class关键字。但您可能会发现,您更喜欢组合而不是继承,而且很少需要使用继承(除非您使用的库或框架需要您继承其基类)。

但是,在某些情况下,多层次的子类化是合适的,我们将在本章的结尾用一个继承的示例来演示表示不同类型集合的类的层次结构。(例9-8中定义的set类类似于JavaScript的内置set类,但并不完全兼容)

例9-8定义了许多子类,但它也演示了如何定义不包含完整实现的抽象类,作为一组相关子类的公共超类。抽象超类可以定义所有子类继承和共享的部分实现。因此,子类只需要通过实现超类定义但不实现的抽象方法来定义自己独特的行为。注意,JavaScript没有抽象方法或抽象类的任何形式定义;我只是在这里对未实现的方法和未完全实现的类使用该名称。

例9-8做了很多注释,并且是独立的类。我鼓励你把它作为这一章课程的一个最重要的例子来阅读。示例9-8中的最后一个类使用&、|和~运算符进行了大量位操作,您可以在§4.8.3中回顾这些运算符。

示例9-8. Sets.js:抽象和具体集合类的层次结构

/**
 * AbstractSet类定义了一个抽象方法has()。
 */
class AbstractSet {
    //在这里抛出一个错误,这样子类就会被迫定义这个方法的可运行版本。
    has(x) { throw new Error("Abstract method"); }
}

/**
* NotSet是AbstractSet的具体子类。此集合的成员都不是其他集合成员的值。
* 因为它是根据另一个集合定义的,所以它是不可写的,
* 而且因为它有无限个成员,所以它不是可枚举的。
* 我们所能做的就是测试成员资格并使用数学符号将其转换为字符串。
 */
class NotSet extends AbstractSet {
    constructor(set) {
        super();
        this.set = set;
    }

    // 我们继承的抽象方法的实现
    has(x) { return !this.set.has(x); }
    // 我们还重写了这个Object方法
    toString() { return `{ x| x ∉ ${this.set.toString()} }`; }
}

/**
 * RangeSet是AbstractSet的一个具体子类。
 * 其成员是介于“from”和“to”之间的所有值(包括边界)。
 * 由于其成员可以是浮点数字,因此它不可枚举,也没有有意义的大小。
 */
class RangeSet extends AbstractSet {
    constructor(from, to) {
        super();
        this.from = from;
        this.to = to;
    }

    has(x) { return x >= this.from && x <= this.to; }
    toString() { return `{ x| ${this.from} ≤ x ≤ ${this.to} }`; }
}

/*
 * AbstractEnumerableSet是AbstractSet的一个子类。它定义了一个抽象的getter,
 * 它返回集合的大小,还定义了一个抽象迭代器。
 * 然后它在它们之上实现具体的isEmpty()、toString()和equals()方法。
 * 实现迭代器、size getter和has()方法的子类可以免费获得这些具体方法。
 */
class AbstractEnumerableSet extends AbstractSet {
    get size() { throw new Error("Abstract method"); }
    [Symbol.iterator]() { throw new Error("Abstract method"); }

    isEmpty() { return this.size === 0; }
    toString() { return `{${Array.from(this).join(", ")}}`; }
    equals(set) {
        // 如果另一个集合不是可枚举的,它就不等于这个集合
        if (!(set instanceof AbstractEnumerableSet)) return false;

        // 如果它们的大小不一样,它们就不相等
        if (this.size !== set.size) return false;

        // 循环此集合的元素
        for(let element of this) {
            // 如果一个元素不在另一个集合中,它们就不相等
            if (!set.has(element)) return false;
        }

        // 元素匹配,所以集合是相等的
        return true;
    }
}

/*
 * SingletonSet是AbstractEnumerableSet的具体子类。
 * 单例集合是具有单个成员的只读集合。
 */
class SingletonSet extends AbstractEnumerableSet {
    constructor(member) {
        super();
        this.member = member;
    }

    // 我们实现了这三种方法,并基于这些方法继承
    // isEmpty()、equals() 和toString() 实现。
    has(x) { return x === this.member; }
    get size() { return 1; }
    *[Symbol.iterator]() { yield this.member; }
}

/*
 * AbstractWritableSet是AbstractEnumerableSet的抽象子类。
 * 它定义了insert()和remove()抽象方法,
 * 这些方法在集合中插入和删除单个元素,然后在这些方法的基础上实现具体的add()、
 * subtract()和intersect()方法。注意,我们的API在这里与标准
 * JavaScript Set类不同。
 */
class AbstractWritableSet extends  AbstractEnumerableSet {
    insert(x) { throw new Error("Abstract method"); }
    remove(x) { throw new Error("Abstract method"); }

    add(set) {
        for(let element of set) {
            this.insert(element);
        }
    }

    subtract(set) {
        for(let element of set) {
            this.remove(element);
        }
    }

    intersect(set) {
        for(let element of this) {
            if (!set.has(element)) {
                this.remove(element);
            }
        }
    }
}

/**
 * BitSet是AbstractWritableSet的一个具体子类,
 * 它为元素为小于某个最大大小的非负整数,实现提供了一个非常有效的固定大小。
 */
class BitSet extends AbstractWritableSet {
    constructor(max) {
        super();
        this.max = max;  // 我们可以存储的最大整数。
        this.n = 0;      // 集合中有多少个整数
        this.numBytes = Math.floor(max / 8) + 1;   // 我们需要多少字节
        this.data = new Uint8Array(this.numBytes); // 字节
    }

    // 检查值是否是此集的合法成员的内部方法
    _valid(x) { return Number.isInteger(x) && x >= 0 && x <= this.max; }

    // 测试是否设置了数据数组的指定字节的指定位。返回true或false。
    _has(byte, bit) { return (this.data[byte] & BitSet.bits[bit]) !== 0; }

    // 值x在这个位集中吗?
    has(x) {
        if (this._valid(x)) {
            let byte = Math.floor(x / 8);
            let bit = x % 8;
            return this._has(byte, bit);
        } else {
            return false;
        }
    }

    // 将值x插入位集
    insert(x) {
        if (this._valid(x)) {               // 如果值有效
            let byte = Math.floor(x / 8);   // 转换为字节和位
            let bit = x % 8;
            if (!this._has(byte, bit)) {    // 如果这个位还没有设置
                this.data[byte] |= BitSet.bits[bit]; // 那就设置一下
                this.n++;                            // 并增加集合大小
            }
        } else {
            throw new TypeError("Invalid set element: " + x );
        }
    }

    remove(x) {
        if (this._valid(x)) {              // 如果值有效
            let byte = Math.floor(x / 8);  // 转换为字节和位
            let bit = x % 8;
            if (this._has(byte, bit)) {    // 如果这个位已经设置
                this.data[byte] &= BitSet.masks[bit];  // 那么取消设置
                this.n--;                              // 并且减小集合大小
            }
        } else {
            throw new TypeError("Invalid set element: " + x );
        }
    }

    // 返回集合大小的getter
    get size() { return this.n; }

    // 通过依次检查每个位来迭代集合。
	//(我们可以更聪明,并对其进行大幅优化)
	// 译者注:应该根据n大小,对data数组进行遍历
    *[Symbol.iterator]() {
        for(let i = 0; i <= this.max; i++) {
            if (this.has(i)) {
                yield i;
            }
        }
    }
}

// has()、insert()和remove()方法使用的一些预计算值
BitSet.bits = new Uint8Array([1, 2, 4, 8, 16, 32, 64, 128]);
BitSet.masks = new Uint8Array([~1, ~2, ~4, ~8, ~16, ~32, ~64, ~128]);
           

9.6 总结

本章介绍了JavaScript类的主要功能:

  • 属于同一类的对象从同一原型对象继承属性。prototype对象是JavaScript类的关键特性,只需使用方法Object.create()就可以定义类。
  • 在ES6之前,类通常是通过首先定义构造函数函数来定义的。用function关键字创建的函数有一个prototype属性,这个属性的值是一个对象,当用new作为构造函数调用函数时,它被用作所有对象的原型。通过初始化这个原型对象,您可以定义类的共享方法。虽然原型对象是类的关键特性,但构造函数是类的公共标识。
  • ES6引入了一个class关键字,使得定义类更容易,但实际上构造函数和原型机制保持不变。
  • 子类是在类声明中使用extends关键字定义的。
  • 子类可以使用super关键字调用其超类的构造函数或覆写其超类的方法。
  1. 除了ES5 Function.bind()方法返回的函数之外。绑定函数没有自己的prototype属性,但是如果它们被作为构造函数调用,它们将使用底层函数的原型。 ↩︎