JavaScript权威指南 第9章 类
第9章 类
第6章介绍了JavaScript对象。当时把对象当成一种独特的属性集合,每个对象都不一样。然而,多个对象经常需要共享一些属性,此时可以为这些对象定义一个类。这个类的成员或实例,各自拥有属性来保存或定义自己的状态,但也有方法定义它们的行为。这些方法是由类定义且由所有实例共享的。想象有一个Complex类,表示和执行复数的计算。Complex的实例会有属性保存复数的实数和虚数部分(状态)。同时Complex类也会定义对这些数执行加法和乘法操作(行为)的方法。
在JavaScript中,类使用基于原型的继承。如果说两个对象从同一个原型继承属性(通常是以函数作为值的属性,或者方法),那我们说这些对象是同一个类的实例。简言之,这就是JavaScript类原理。
如果两个对象继承同一个原型,通常(但不必定)意味着它们是通过同一个构造函数或工厂函数创建和初始化的。
JavaScript一直允许定义类。ES6新增了相关语法(包括class关键字)让创建类更容易。通过新语法创建的JavaScript类与老式的类原理相同。
JavaScript的类和基于原型的继承机制与Java等语言中类和基于类的继承机制有着本质区别。
9.1 类和原型
在JavaScript中,类意味着一组对象从同一个原型对象继承属性。因此,原型对象是类的核心特征。第6章介绍的Object.create()函数用于创建一个新对象,这个新对象继承指定的原型对象。如果我们定义了一个原型对象,然后使用Object.create()创建一个继承它的对象,那我们就定义了一个JavaScript类。通常,一个类的实例需要进一步初始化,因此,常见的作法是定义一个函数来创建和初始化新对象。
示例 9-1:一个简单的JavaScript类
//这个工厂函数返回一个新范围对象
function range(from,to){
//使用Object.create()创建一个对象,继承下面定义的
//原型对象。这个原型对象保存为这个函数的一个属性,为
//所有范围对象定义共享方法(行为)
let r=Object.create(range.methods);
//保存新范围对象的起点和终点(状态)
//这些属性不是继承的,是当前对象独有的
r.from=from;
r.to=to;
//最后返回新对象
return r;
}
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;
},
//要返回范围的字符串表示
toString(){
return "("+this.from+"..."+this.to+")";}
};
let r=range(1,3) //创建一个新范围对象
r.includes(2)
=>true
r.toString()
=>"(1...3)"
[...r]
=>(2) [1, 2] //通过迭代器转换为数组
实例 9-1 中有几个方面需要注意:
- 这段代码定义了一个工厂函数range(),用于创建新的Range()对象。
- 它使用range()函数的methods属性保存这个类的原型对象。把原型对象放在这里没有什么特别的,也不是习惯写法。
- 这个range()函数为每个Range()对象定义from和to属性。这两个属性是非共享、非继承属性,定义每个范围对象独有的在状态。
- range.methods对象使用了ES6定义方法的简写语法,所以没有出现function关键字。
- 原型的方法中有一个是计算的名字Symbol.iterator,这意味着它为Range对象定义了一个迭代器。这个方法的名字前面有一个星号*,表示它是一个生成器函数,而非普通函数。
- 定义在range.methods中的共享方法都会用到在range()工厂函数中初始化的from和to属性,它们通过this关键字引用调用它们的对象。使用this是所有类方法的基本特征。
9.2 类和构造函数
例9-1展示了一种定义JavaScript类的简单方式。不过,这种方式并非习惯写法,因为它没有定义构造函数。构造函数是一种专门用于初始化新对象的函数。使用new调用构造函数会自动创建新对象,因此构造函数本身只需要初始化新对象的状态。构造函数调用的关键在于构造函数的propotype属性将被用作新对象的原型。6.2.3节介绍了原型并强调并非所有对象都有原型,但只有少数对象有propotype属性。现在终于可以明确了:只有函数对象才有propotype属性,这意味着使用同一个构造函数创建的所有对象都继承同一个对象,因而是同一个类的成员。
示例9-2演示了在不支持ES6 class关键字的JavaScript版本中创建类的习惯做法。虽然class目前已经得到全面支持,但仍有很多老JavaScript代码是以这种方式来定义类的。因此我们应该首席这种写法,以便理解老代码,同时也理解class关键字时“底层”都发生了什么。
function Range(from,to){
//保存新范围对象的起点和终点
//这些属性不是继承的,是当前对象独有的
this.from=from;
this.to=to;
}
undefined
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对象
r.includes(2)
=>true
r.toString()
=>"(1...3)"
[...r]
=>(2) [1, 2]
实例9-1和实例9-2区别:首先,注意range()工厂函数在转换为构造函数时被重命名为了Range()。这是一个非常常见的编码约定。因为构造函数在某种意义上是定义类的。而类名(按照惯例)应以大写字母开头。普通函数和方法的名字则以小写字母开头。
其次,注意Range()构造函数是以new关键字调用的(在实例末尾),而range()工厂函数被调用时没有这个关键字。示例9-1使用普通函数调用常见新对象,而示例9-2使用构造函数调用创建新对象。因为Range()构造函数是通过new调用的,所以它没有调用Object.create(),也没有执行任何创建对象的操作。新对象是在调用构造函数之前自动创建的,可以通过this来访问。Range()构造函数仅仅需要初始化this。构造函数甚至不需要返回新创建的对象,它调用会自动创建新对象,并将构造函数作为该对象的方法来调用,然后返回新对象。构造函数调用与普通函数调用的这个重要区别也是我们用首字母大写的名字命名构造函数的一个重要原因。构造函数在编写时就会考虑它会作为构造函数以new关键字来调用,因此把它们当成普通函数来调用通常会有问题。这个命名约定让构造函数有别于普通函数,方便程序员知道什么时候使用new。

示例9-1和示例9-2的另一个关键区别是命名原型对象的方式。在第一个示例中,原型是range.methods。这是一个方便且好懂的名字,但太随意。在第二个示例中,原型是Range.prototype,这个名字是强制性的。对构造函数Range()的调用会自动把Range.propotype作为新Range对象的原型。
最后,要注意示例9-1和示例9-2相比没有变化的部分,两个类的范围相关方法是以相同方式定义和调用的。因为示例9-2演示了 JavaScript在ES6之前创建类的习惯做法,所以原型对象中没有使用ES6简写语法定义方法,而是明确使用了 function关键字。 但那些方法的实现在两个示例中都是一样的。
还有一点很重要,就是这两个范围类的实例都没使用箭头函数定义构造函数或方法。8.1.3节讲过,用箭头函数方式定义的函数没有propotype属性,因此不能作为构造函数使用。而且,箭头函数中的this是从定义它们的上下文继承的,不会根据调用它们的对象来动态设置。这样定义的方法就不能使用了,因为方法的典型特点就是使用this引用调用它们的实例。
好在ES6新增的类语法不允许使用箭头函数定义方法,因此在使用该语法时不必担心自己会意外犯这种错误。稍后我们会讲解ES6的class关键字,但首先要把关于构造函数的内容介绍完。
9.2.1 构造函数、类标识和instanceof
如前所见,原型对象是类标识的基本:当且仅当两个对象继承同一个原型对象时,它们才是同一个类的实例。初始化新对象状态的构造函数不是基本标识,因为两个构造函数的propotype属性可能指向同一个原型对象,此时两个构造函数都可以用于创建同一个类的实例。
虽然构造函数不像原型那么基本,但构造函数充当类的外在表现。最明显的,构造函数的名字通常都用作类名。例如,我们说Range()构造函数可以创建Range对象。但更根本的问题在于,在使用instanceof操作符测试类的成员关系时,构造函数是其右操作数。如果想测试对象r是不是Range对象,可以这样编码:
r instanceof Range //=>true:r继承了Range.propotype
对于表达式o instanceof C,如果o继承了C.propotype,则表达式求值为true。这里的继承不一定是直接继承,如果o继承的对象继承了C.propotype,这个表达式仍然求值为true。
严格来说,对于前面的表达式,instanceof操作符并非检查r是否通过Range构造函数初始化,而是检查r是否继承Range.propotype。如果我们定义了一个函数Strange(),并将其propotype属性设置为等于Range.propotype,那么instanceof操作符也会将new Strange()创建的对象判定为Range()对象(尽管它们不能像真正的Range对象一样工作,因为它们的from和to属性都没有初始化):
function Strange(){}
Strange.prototype=Range.prototype
new Strange() instanceof Range
=>true
虽然instanceof不能验证使用的是哪个构造函数,但它仍然以构造函数作为其右操作数,因为构造函数是类的公共标识。
如果不想以构造函数作为媒介,直接测试某个对象原型链中是否包含指定原型,可以使用isPropotypeOf方法。例如在示例9-1中,我们定义类时没有使用构造函数,因而无法对这个类使用instanceof操作符。此时,可以通过如下代码检测对象r是不是这个无构造函数类的成员:
range.methods.isPropotypeOf(r); //range.methods是r的原型对象
9.2.2 constructor 属性
在示例9-2中,我们把Range.propotype设置为一个新对象,其中包含我们的类的方法。尽管把方法定义为对象字面量的属性很便捷,但实际上没有必要创建一个新对象。任何普通JavaScript函数(不包括箭头函数、生成器函数和异步函数)都可以用作构造函数,而构造函数调用需要一个propotype属性。为此,每个普通JavaScript函数自动拥有一个propotype属性。这个属性的值是一个对象,有一个不可枚举的constructor属性。而这个constructor属性的值就是该函数对象:
let F=function(){} //这是一个函数对象
let p=F.prototype; //这是一个与F关联的原型对象
let c=p.constructor; //这是与原型关联的函数
c===F
=>true //对任何F,F.propotype.constuctor===f
这个预定义对象及其constuctor属性的存在,意味着对象也会继承一个引用其构造函数的construtor属性。因为构造函数充当类的公共标识,所以这个constructor属性返回对象的类:
let o=new F(); //创建类F的对象o
o.constructor===F //constructor指定类
true
图9-1直观地展示了构造函数、其原型对象、原型对构造函数的反向引用,以及通过该构造函数创建的实例之间的关系。

注意,图9-1使用了Range(0构造函数作为示例。但实际上,例9-2定义的Range类用自己的对象重写了预定义的Range.propotype对象。而它定义的这个新的原型对象并没有constructor属性。所以按照定义,Range类的实例都没有constructor属性。这个问题可以通过显式地为原型添加一个constructor属性类解决:
Range.prototype={
constructor:Range, //显式设置反向引用constructor
/* 以下是方法定义 */
};

另一个在老代码中常见的技术是使用预定义的原型对象及其constructor属性,然后像下面这样每次给它添加一个方法:
//扩展预定义的Range.propotype对象,不重写
//自动创建的Range.propotype.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早在它最初的版本就支持类,只不过自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})`;
}
}
//下面是使用这个新Range类的示例
let r=new Range(1,3); //创建一个Range对象
r.includes(2) //true:2在范围内
=>true
r.toString()
示例9-2和示例9-3中定义的类本质完全一样,理解这一点非常重要。新增class关键字并未改变JavaScript类基于原型的本质。虽然示例9-3使用了class关键字,但得到的Range对象是一个构造函数,与示例9-2定义的版本一样。新的class语法虽然明确、方便,但最好把它看成示例9-2中更基础的类定义机制的“语法糖”。
对于示例9-3展示的类语法,需要注意以下几点:
- 类是以class关键字声明的,后面跟着类名和花括号中的类体。
- 类体包含使用对象字面量方法简写形式(示例9-1中使用过)定义的方法,因此省略了function关键字。但与对象字面量不同的是方法之间没有逗号(尽管类体与对象字面量表面上相似,但它们不是一回事。特别地,类体中不支持名/值对形式的属性定义)。
- 关键字constructor用于定义类的构造函数。但实际上定义的函数并不叫“constructor”。class声明语句会定义一个新变量Range,并将这个特殊构造函数的值赋给该变量。
- 如果类不需要初始化,可以省略constructor关键字及其方法体,解释器为隐式为你创建一个空构造函数。
如果想定义一个继承另一个类(或作为另一个类子类)的类,可以使用extends关键字和class关键字:
//Span和Range相似,但初始化使用的
//不是起点和终点,而是起点和长度
class Span extends Range{
constructor(state,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语法并不显而易见的情形:
- 即使没有出现“use strict”指令,class声明体中的所有代码默认也处于严格模式。这意味着不能在类体中使用八进制整数字面量或with语句,而且忘记在声明之前使用变量也会导致语法错误。
- 与函数声明不同,类声明不会“提升”。8.1.1节介绍过,函数定义就像是会被提升到包含文件或包含函数顶部一样,因此函数调用语句可以出现在函数定义之前。尽管类声明与函数声明有几分相似,但类声明不会被提升。换句话说,不能在声明类之前初始化它。
9.3.2 静态方法
在class体中,把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.propotype.parse(),必须通过构造函数而非实例调用它:
let r=Range.parse('(1...10)') //返回一个新Range对象
undefined
r.parse(('1...10')) //TypeError:r.parse不是一个函数
有人也把静态方法成为类方法,因为它们要通过类(构造函数)名调用。这么叫是为了区分类方法和在类实例上调用的普通实例方法。由于静态方法所在构造函数而非实例上调用的,所以在静态方法中使用this关键字没什么意义。
9.3.2 获取方法、设置方法及其他形式的方法
在class体内,可以像在对象字面量中一样定义获取方法和设置方法。唯一的区别是类体内的获取方法和设置方法后面不加逗号。示例9-4展示了如何在类中定义获取方法。
一般来说,对象字面量支持的所有简写的方法定义语法都可以在类体中使用。这包括生成器方法(带*)和名字为方括号中表达式值的方法。事实上,前面我们已经看到了一个通过计算的名字定义的生成器方法,该方法让Range类变得可迭代:
*[Symbol.iterator](){
for(let x=Math.ceil(this.from);x<=this.to;x++)
yield x;
}
9.3.3 共有、私有和静态字段
在关于使用class关键字定义类的讨论中,我们只介绍了类体中方法的定义。ES6标准只允许创建方法(包括获取方法、设置方法和生成器)和静态方法,还没有定义字段的语法。如果想在类实例上定义字段(这只是面向对象的“属性”的同义词),必须在构造函数或某个方法中定义。如果想定义类的静态字段,必须在类体之外,在定义类之后定义。示例9-4中也包含这两种字段的示例。
不过,扩展类语法以支持定义示例和静态字段的标准话过程还在继续。本节后面展示的代码在2020年初还不是标准JavaScript写法,但Chrome已经支持,Firefox已经部分支持了(仅公有实例字段)。其中定义公有实例字段的语法在使用React框架和Babel转译器的JavaScript程序员中已经很常用了。
假设你写了一个类似下面的类,使用构造函数初始了3个字段:
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标识符字符),则该字段就只能在类体中(带着#前缀)使用,对类体外部的任何代码都不可见、不可访问(因而无法修改)。杜宇前面假想的Buffer类,如果你想确保用户不会意外修改实例的size字段,可以使用私有的#size字段,然后定义一个获取函数,只允许读取该字段的值:
class Buffer{
#size=0;
get size(){
return this.#size;
}
}
要注意的是,私有字段必须先使用这种语法声明才能使用。换句话说,如果没有直接在类体中“声明”#size字段,就不能在类的构造函数中写this.#size=0;
最后,还有一个相关提案希望将在字段前使用static关键字标准化。提供这份提案,如果在公有或私有字段声明前加上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]),parseInt(matches[2]));
}
9.3.4 实例:复数类
示例9-4定义了一个表示复数的类。这个类相对比较简单,但包含了实例方法(包括获取方法)、静态方法、实例字段和静态字段。代码中的注释解释了应该在类体中使用尚未成为标准的定义实例字段和静态字段的语法。
class Complex{
//在这种类声明标注啊之后,我们可以
//像下面这样,声明私有字段来保存复数的
//实数和虚数部分
//
//#r=0;
//#i=0;
//这个构造函数定义了它需要在每个实例上
//创建的实例属性r和i。这两个字段保存
//可以写c.plus(d)或d.times(c)
constructor(real,imaginary){
this.r=real; //这字段保存这个数的实数部分
this.i=imaginary; //这个字段保存这个数的虚数部分
}
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);
}
//这些也是实例方法,但是使用获取函数定义的,
//因此可以像使用字段一样使用它们。如果我们
//使用的是私有片段this.#r和this.#i,那这里获取方法就有用了
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中的Complex类,就可以像下面这样使用构造函数、实例字段、实例方法、类字段和类方法:
let c=new Complex(2,3); //通过构造含创建一个新对象
let d=new Complex(c.i,c.r) //使用c的实例字段
c.plus(d).toString() //使用实例方法
=>"{5,5}"
c.magnitude //使用实例方法
=>3.6055512754639896
Complex.product(c,d) //使用静态方法
=>Complex {r: 0, i: 13}
Complex.ZERO.toString() //使用静态属性
=>"{0,0}"
9.4 为已有类添加方法
JavaScript基于原型的继承机制是动态的。换句话说,对象从它的原型继承属性,如果在创建对象之后修改了原型的属性,如果在创建对象修改了原型的属性,则对象继承修改后的属性。这意味着只要给原型添加方法,就可以增强JavaScript类。
例如,下面的代码为实例9-4定义的Complex类添加了一个计算共轭复数的方法:
//返回当前复数的共轭复数
Complex.propotype.conj=function(){
return new Complex(this.r,-this.i);
};
内置JavaScript类的原型对象也跟这里一样是开放的,因此我们可以为数值、字符串、数组、函数等添加方法。如果想在旧版本JavaScript中添加新语言特性,就可以怎么做:
//如果字符串上没有定义startWith()方法
if(!String.prototype.startsWith){
//......则使用已有的indexOf()方法实现一个
String.prototype.startsWith=function(s){
return this.indexOf(s)===0;
};
}
下面是另一个示例:
//多次调用函数f,传给它迭代数值
//如,要打印3次“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.propotype添加方法也是可以的,这样所有对象都会继承新方法。但最好不要这样做,因为添加带Object.propotype上的属性在for/in循环中是可见的(尽管使用14.1介绍的Object.defineProperty()方法把新属性设置为不可枚举能够避免这个问题)。
9.5 子类
在面向对象编程中,类B可以扩展或子类化A。此时我们说A是父类,B是子类。B的实例继承A的方法。类B也可以定义自己的方法,其中有些方法可能覆盖类A的同名方法。如果B的方法覆盖了A的方法,B中的覆盖方法经常需要调用A中被覆盖的方法。类似地,子类构造函数B()通常必须是调用父类构造函数才能将实例完全初始化。
9.5.1 子类与原型
假设我们想定义示例9-2中Range类的一个子类Span。这个子类与Range相似,但不是初始化起点和终点,而是初始化起点和距离或跨度。Span类的实例也是父亲Range的实例。跨度的实例从Span.propotype继承了自定义的toString()方法,但为了成为Range的子类,它也必须从Range.propotype继承方法(如includes())。
示例 9-5:Range的简单的子类(Span.js)
//这里是子类构造函数
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
//因此需要定义自己的constructor属性
Span.prototype.constructor=Span;
//通过定义自己的toString()方法,Span
//覆盖了toString(),否则就要从Range继承
Span.prototype.toString=function(){
return `(${this.from}...+${this.to-this.from})`;
};
为了让Span成为Range的子类,需要让Span.prototype继承Range.prototype。前面示例中最关键的一行代码就是这一行,如果你能明白,那就理解了JavaScript中子类的工作机制:
Span.prototype=Object.create(Range.prototype)
通过Span()构造函数创建的对象会继承Span.propotype对象。但我们在创建该对象时让它继承了Range.prototype,因此Span对象既会继承Span.prototype,也会继承Range.prototype。
注意,Span()构造函数像Range()构造函数一样,也设置了from和to属性,因此不需要调用Range()构造函数来初始化新对象。类似地,Span的toString()方法完全重新实现了字符串转换逻辑,不需要调用Range的toString()。这让Span成为一个特例,只有在知道父类实现细节的前提下才可能这样定义子类。健壮的子类化机制应该允许类调用父类的方法和构造函数,但在ES6之前,JavaScript中没有简单的方法做这些。
好在ES6通过super关键字作为class语法的一部分解决了这个问题。下面将演示其工作原理。
9.5.2 通过extends和super创建子类
在ES6及以后,要继承父类,可以简单在类声明中加上一个extends子句,甚至对内置的类也可以这样:
class ESArray extends Array{
get first(){
return this[0];
}
get last(){
return this[this.length-1];
}
}
let a=new ESArray();
a instanceof ESArray
=>true //a是子类的实例
a instanceof Array
=>true //a是父类的实例
a.push(1,2,3,4)
=>4 //可以使用继承的方法
a.pop()
=>4 //使用另一个继承的方法
a.first
=>1 //子类定义的first获取方法
a.last
=>3 //普通数组访问语法仍然有效
a[1]
=>2 //普通数组访问语法仍然有效
Array.isArray(a)
=>true //子类实例确实是数组
ESArray.isArray(a)
=>true //子类也继承了静态方法
这个ESArray子类定义了两个简单的获取方法。ESArray的实例就像普通数组一样,拥有继承的方法和属性,如push()、pop()和length。但是它也有子类定义的first和last获取方法。另外,子类实例不仅继承了pop()等实例方法,子类本身也继承了Array.isArray这种静态方法。这是ES6类语法带来的新特性:ESArray()是个函数,但它继承Array():
//ESArray的实例之所以能继承实例方法,是因为
//ESArray.prototype继承Array.prototype
Array.prototype.isPrototypeOf(ESArray.prototype)
=>true
//ESArray之所以能继承静态方法和属性,是因为
//ESArray继承Array。这是extends关键字独有
//的特性,在ES6之前是不可能做到的
Array.isPrototypeOf(ESArray);
=>true
ESArray子类太简单,很难成分说明问题。示例9-2是一个相对更完善的示例,该实例为内置Map类定义了一个TypedMap子类,添加了类型检查以确保映射的键和值都是指定的类型(根据typeof)。重点是,该示例展示了使用super关键字调用父类构造函数和方法。
示例9-6:Map检查键和值类型的子类(TypedMap.js)
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);
}
}
TypeMap()构造函数的前两个参数的期望的键和值类型,应该是字符串,例如“number”“boolean”等typeof操作符返回的值。还可以指定第三个参数:一个[key,value]数组的数组(或可迭代对象),用于指定映射的初始条目。如果指定了初始条目,则构造函数的第一件事就是检查它们的类型是否正确。然后,再通过super调用父类构造函数,就像它是一个函数名一样。Map()构造函数接收一个可选的参数:一个[key,value]数组的可迭代对象。因此,TypedMap()构造函数可选的第三个参数就是Map()构造函数可选的第一个参数,我们通过super(entries)把它传给父类构造函数。
在调用父类构造函数初始化父类状态后,TypedMap()构造函数接着通过把this.keyType和this.valueType设置为指定类型初始化了自己这个子类的状态。之所以要保存这两个值,是因为后面的set()方法要使用。
关于在构造函数中使用super(),有几个重要的规则需要知道:
- 如果使用extends关键字定义了一个类,那么这个类的构造函数必须使用super()调用父类构造函数。
- 如果没有在子类中定义构造函数之前,解释器会自动为你创建一个。这个隐式定义的构造函数会取得传给它的值,然后把这些值再传给super()。
- 在通过super()调用父类构造函数之前,不能在构造函数中使用this关键字。这条强制规则是为了确保父类先于子类得到初始化。
- 在没有使用new关键字调用的函数中,特殊表达式new.target的值是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关键字可以轻松地创建子类。但这并不意味就应该创建很多子类。如果你写了一个类,这个类与另一类有相同的行为,可以通过创建子类来继承该行为。但是,在你的类中创建另一个类的实例,并在需要时委托该实例去做你希望的事反而更方便,也更灵活。这时候,不需要创建一个类的子类,只要包装或组合其他类型即可。这种委托策略常常被称为“组合”,也是面向对象编程领域奉行的一个准则,即开发者应该“能组合就不继承”。
例如,假设我们想写一个Histogram类,这个类有些像JavaScript的Set类,但除了记录一个值是否被添加到集合,它还要维护值被添加的次数。因为这个Histogram类的API类似于Set,可以考虑扩展Set并添加一个count()方法。但从另一个角度来看,在思考如何实现这个count()方法时,又会发现这个Histogram类更像是Map而不是Set。因为它需要维护值与添加次数的映射。所以与其创建Set的子类,不如创建一个类,为它定义类似Set的API,但通过相应操作委托给一个内部Map对象来实现那些方法。
示例 9-7:通过委托实现的类似Set的类

本文深入探讨JavaScript中的类,从基于原型的继承机制到ES6的class关键字。讲解了构造函数、实例与类标识、静态方法、获取和设置方法,以及如何为已有类添加方法。示例中通过Range类和其子类Span展示了类的定义和继承,强调了使用new和super关键字的重要性。此外,还讨论了动态添加方法到内置类以及委托而非继承的策略。
1505

被折叠的 条评论
为什么被折叠?



