类
几乎从JavaScript的最开始的那时候起,语法和开发模式都曾努力(读作:挣扎地)地戴上一个支持面向类的开发的假面具。伴随着new
和instanceof
和一个.constructor
属性,谁能不认为JS在它的原型系统的某个地方藏着类机制呢?
当然,JS的“类”与经典的类完全不同。其区别有很好的文档记录,所以在此我不会在这一点上花更多力气。
注意: 要学习更多关于在JS中假冒“类”的模式,以及另一种称为“委托”的原型的视角,参见本系列的 this与对象原型 的后半部分。
class
虽然JS的原型机制与传统的类的工作方式不同,但是这并不能阻挡一种强烈的潮流 —— 要求这门语言扩展它的语法糖以便将“类”表达得更像真正的类。让我们进入ES6class
关键字和它相关的机制。
这个特性是一个具有高度争议、旷日持久的争论的结果,而且代表了几种对关于如何处理JS类的强烈反对意见的妥协的一小部分。大多数希望JS拥有完整的类机制的开发者将会发现新语法的一些部分十分吸引人,但是也会发现一些重要的部分仍然缺失了。但不要担心,TC39已经致力于另外的特性,以求在后ES6时代中增强类机制。
新的ES6类机制的核心是class
关键字,它标识了一个 块,其内容定义了一个函数的原型的成员。考虑如下代码:
class Foo {
constructor(a,b) {
this.x = a;
this.y = b;
}
gimmeXY() {
return this.x * this.y;
}
}
一些要注意的事情:
class Foo
暗示着创建一个(特殊的)名为Foo
的函数,与你在前ES6中所做的非常相似。constructor(..)
表示了这个Foo(..)
函数的签名,和它的函数体内容。- 类方法同样使用对象字面量中可以使用的“简约方法”语法,正如在第二章中讨论过的。这也包括在本章早先讨论过的简约generator,以及ES5的getter/setter语法。但是,类方法是不可枚举的而对象方法默认是可枚举的。
- 与对象字面量不同的是,在一个
class
内容的部分没有逗号分隔各个成员!事实上,这甚至是不允许的。
前一个代码段的class
语法定义可以大致认为和这个前ES6等价物相同,对于那些以前做过原型风格代码的人来说可能十分熟悉它:
function Foo(a,b) {
this.x = a;
this.y = b;
}
Foo.prototype.gimmeXY = function() {
return this.x * this.y;
}
不管是前ES6形式还是新的ES6class
形式,这个“类”现在可以被实例化并如你所想地使用了:
var f = new Foo( 5, 15 );
f.x; // 5
f.y; // 15
f.gimmeXY(); // 75
注意!虽然class Foo
看起来很像function Foo()
,但是有一些重要的区别:
class Foo
的一个Foo(..)
调用 必须 与new
一起使用,因为前ES6的Foo.call( obj )
方式 不能 工作。- 虽然
function Foo
会被“提升”(参见本系列的 作用域与闭包),但是class Foo
不会;extends ..
指定的表达式不能被“提升”。所以,在你能够实例化一个class
之前必须先声明它。 - 在顶层全局作用域中的
class Foo
在这个作用域中创建了一个词法标识符Foo
,但与此不同的是function Foo
不会创建一个同名的全局对象属性。
已经建立的instanceof
操作仍然可以与ES6的类一起工作,因为class
只是创建了一个同名的构造器函数。然而,ES6引入了一个定制instanceof
如何工作的方法,使用Symbol.hasInstance
(参见第七章的“通用Symbol”)。
我发现另一种更方便地考虑class
的方法是,将它作为一个用来自动填充proptotype
对象的 宏。可选的是,如果使用extends
(参见下一节)的话它还能连接[[Prototype]]
关系。
其实一个ES6class
本身不是一个实体,而是一个元概念,它包裹在其他具体实体上,例如函数和属性,并将它们绑在一起。
提示: 除了这种声明的形式,一个class
还可以是一个表达式,就像:var x = class Y { .. }
。这主要用于将类的定义(技术上说,是构造器本身)作为函数参数值传递,或者将它赋值给一个对象属性。
extends
和 super
ES6的类还有一种语法糖,用于在两个函数原型之间建立[[Prototype]]
委托链 —— 通常被错误地标记为“继承”或者令人困惑地标记为“原型继承” —— 使用我们熟悉的面向类的术语extends
:
class Bar extends Foo {
constructor(a,b,c) {
super( a, b );
this.z = c;
}
gimmeXYZ() {
return super.gimmeXY() * this.z;
}
}
var b = new Bar( 5, 15, 25 );
b.x; // 5
b.y; // 15
b.z; // 25
b.gimmeXYZ(); // 1875
一个有重要意义的新增物是super
,它实际上在前ES6中不是直接可能的东西(不付出一些不幸的黑科技的代价的话)。在构造器中,super
自动指向“父构造器”,这在前一个例子中是Foo(..)
。在方法中,它指向“父对象”,如此你就可以访问它上面的属性/方法,比如super.gimmeXY()
。
Bar extends Foo
理所当然地意味着将Bar.prototype
的[[Prototype]]
链接到Foo.prototype
。所以,在gimmeXYZ()
这样的方法中的super
特被地意味着Foo.prototype
,而当super
用在Bar
构造器中时意味着Foo
。
注意: super
不仅限于class
声明。它也可以在对象字面量中工作,其方式在很大程度上与我们在此讨论的相同。更多信息参见第二章中的“对象super
”。
super
的坑
注意到super
的行为根据它出现的位置不同而不同是很重要的。公平地说,大多数时候这不是一个问题。但是如果你背离一个狭窄的规范,令人诧异的事情就会等着你。
可能会有这样的情况,你想在构造器中引用Foo.prototype
,比如直接访问它的属性/方法之一。然而,在构造器中的super
不能这样被使用;super.prototype
将不会工作。super(..)
大致上意味着调用new Foo(..)
,但它实际上不是一个可用的对Foo
本身的引用。
与此对称的是,你可能想要在一个非构造器方法中引用Foo(..)
函数。super.constructor
将会指向Foo(..)
函数,但是要小心这个函数 只能 与new
一起被调用。new super.constructor(..)
将是合法的,但是在大多数情况下它都不是很有用, 因为你不能使这个调用使用或引用当前的this
对象环境,而这很可能是你想要的。
另外,super
看起来可能就像this
一样是被函数的环境所驱动的 —— 也就是说,它们都是被动态绑定的。但是,super
不像this
那样是动态的。当声明时一个构造器或者方法在它内部使用一个super
引用时(在class
的内容部分),这个super
是被静态地绑定到这个指定的类阶层中的,而且不能被覆盖(至少是在ES6中)。
这意味着什么?这意味着如果你习惯于从一个“类”中拿来一个方法并通过覆盖它的this
,比如使用call(..)
或者apply(..)
,来为另一个类而“借用”它的话,那么当你借用的方法中有一个super
时,将很有可能发生令你诧异的事情。考虑这个类阶层:
class ParentA {
constructor() { this.id = "a"; }
foo() { console.log( "ParentA:", this.id ); }
}
class ParentB {
constructor() { this.id = "b"; }
foo() { console.log( "ParentB:", this.id ); }
}
class ChildA extends ParentA {
foo() {
super.foo();
console.log( "ChildA:", this.id );
}
}
class ChildB extends ParentB {
foo() {
super.foo();
console.log( "ChildB:", this.id );
}
}
var a = new ChildA();
a.foo(); // ParentA: a
// ChildA: a
var b = new ChildB(); // ParentB: b
b.foo(); // ChildB: b
在前面这个代码段中一切看起来都相当自然和在意料之中。但是,如果你试着借来b.foo()
并在a
的上下文中使用它的话 —— 通过动态this
绑定的力量,这样的借用十分常见而且以许多不同的方式被使用,包括最明显的mixin —— 你可能会发现这个结果出奇地难看:
// 在`a`的上下文环境中借用`b.foo()`
b.foo.call( a ); // ParentB: a
// ChildB: a
如你所见,引用this.id
被动态地重绑定所以在两种情况下都报告: a
而不是: b
。但是b.foo()
的super.foo()
引用没有被动态重绑定,所以它依然报告ParentB
而不是期望的ParentA
。
因为b.foo()
引用super
,所以它被静态地绑定到了ChildB
/ParentB
阶层而不能被用于ChildA
/ParentA
阶层。在ES6中没有办法解决这个限制。
如果你有一个不带移花接木的静态类阶层,那么super
的工作方式看起来很直观。但公平地说,实施带有this
的编码的一个主要好处正是这种灵活性。简单地说,class
+ super
要求你避免使用这样的技术。
你能在对象设计上作出的选择归结为两个:使用这些静态的阶层 —— class
,extends
,和super
将十分不错 —— 要么放弃所有“山寨”类的企图,而接受动态且灵活的,没有类的对象和[[Prototype]]
委托(参见本系列的 this与对象原型)。
子类构造器
对类或子类来说构造器不是必需的;如果构造器被省略,这两种情况下都会有一个默认构造器顶替上来。但是,对于一个直接的类和一个被扩展的类来说,顶替上来的默认构造器是不同的。
特别地,默认的子类构造器自动地调用父构造器,并且传递所有参数值。换句话说,你可以认为默认的子类构造器有些像这样:
constructor(...args) {
super(...args);
}
这是一个需要注意的重要细节。不是所有支持类的语言的子类构造器都会自动地调用父构造器。C++会,但Java不会。更重要的是,在前ES6的类中,这样的自动“父构造器”调用不会发生。如果你曾经依赖于这样的调用 不会 发生,按么当你将代码转换为ES6class
时就要小心。
ES6子类构造器的另一个也许令人吃惊的偏差/限制是:在一个子类的构造器中,在super(..)
被调用之前你不能访问this
。其中的原因十分微妙和复杂,但是可以归结为是父构造器在实际上创建/初始化你的实例的this
。前ES6中,它相反地工作;this
对象被“子类构造器”创建,然后你使用这个“子类”的this
上下文环境调用“父构造器”。
让我们展示一下。这是前ES6版本:
function Foo() {
this.a = 1;
}
function Bar() {
this.b = 2;
Foo.call( this );
}
// `Bar` “扩展” `Foo`
Bar.prototype = Object.create( Foo.prototype );
但是这个ES6等价物不允许:
class Foo {
constructor() { this.a = 1; }
}
class Bar extends Foo {
constructor() {
this.b = 2; // 在`super()`之前不允许
super(); // 可以通过调换这两个语句修正
}
}
在这种情况下,修改很简单。只要在子类Bar
的构造器中调换两个语句的位置就行了。但是,如果你曾经依赖于前ES6可以跳过“父构造器”调用的话,就要小心这不再被允许了。
extend
原生类型
新的class
和extend
设计中最值得被欢呼的好处之一,就是(终于!)能够为内建原生类型,比如Array
,创建子类。考虑如下代码:
class MyCoolArray extends Array {
first() { return this[0]; }
last() { return this[this.length - 1]; }
}
var a = new MyCoolArray( 1, 2, 3 );
a.length; // 3
a; // [1,2,3]
a.first(); // 1
a.last(); // 3
在ES6之前,可以使用手动的对象创建并将它链接到Array.prototype
来制造一个Array
的“子类”的山寨版,但它仅能部分地工作。它缺失了一个真正数组的特殊行为,比如自动地更新length
属性。ES6子类应该可以如我们盼望的那样使用“继承”与增强的行为来完整地工作!
另一个常见的前ES6“子类”的限制与Error
对象有关,在创建自定义的错误“子类”时。当纯粹的Error
被创建时,它们自动地捕获特殊的stack
信息,包括错误被创建的行号和文件。前ES6的自定义错误“子类”没有这样的特殊行为,这严重地限制了它们的用处。
ES6前来拯救:
class Oops extends Error {
constructor(reason) {
super(reason);
this.oops = reason;
}
}
// 稍后:
var ouch = new Oops( "I messed up!" );
throw ouch;
前面代码段的ouch
自定义错误对象将会向任何其他的纯粹错误对象那样动作,包括捕获stack
。这是一个巨大的改进!
new.target
ES6引入了一个称为 元属性 的新概念(见第七章),用new.target
的形式表示。
如果这看起来很奇怪,是的;将一个带有.
的关键字与一个属性名配成一对,对JS来说绝对是不同寻常的模式。
new.target
是一个在所有函数中可用的“魔法”值,虽然在普通的函数中它总是undefined
。在任意的构造器中,new.target
总是指向new
实际直接调用的构造器,即便这个构造器是在一个父类中,而且是通过一个在子构造器中的super(..)
调用被委托的。
class Foo {
constructor() {
console.log( "Foo: ", new.target.name );
}
}
class Bar extends Foo {
constructor() {
super();
console.log( "Bar: ", new.target.name );
}
baz() {
console.log( "baz: ", new.target );
}
}
var a = new Foo();
// Foo: Foo
var b = new Bar();
// Foo: Bar <-- 遵照`new`的调用点
// Bar: Bar
b.baz();
// baz: undefined
new.target
元属性在类构造器中没有太多作用,除了访问一个静态属性/方法(见下一节)。
如果new.target
是undefined
,那么你就知道这个函数不是用new
调用的。然后你就可以强制一个new
调用,如果有必要的话。
static
当一个子类Bar
扩展一个父类Foo
时,我们已经观察到Bar.prototype
被[[Prototype]]
链接到Foo.prototype
。但是额外地,Bar()
被[[Prototype]]
链接到Foo()
。这部分可能就没有那么明显了。
但是,在你为一个类声明static
方法(不只是属性)时它就十分有用,因为这些静态方法被直接添加到这个类的函数对象上,不是函数对象的prototype
对象上。考虑如下代码:
class Foo {
static cool() { console.log( "cool" ); }
wow() { console.log( "wow" ); }
}
class Bar extends Foo {
static awesome() {
super.cool();
console.log( "awesome" );
}
neat() {
super.wow();
console.log( "neat" );
}
}
Foo.cool(); // "cool"
Bar.cool(); // "cool"
Bar.awesome(); // "cool"
// "awesome"
var b = new Bar();
b.neat(); // "wow"
// "neat"
b.awesome; // undefined
b.cool; // undefined
小心不要被搞糊涂,认为static
成员是在类的原型链上的。它们实际上存在与函数构造器中间的一个双重/平行链条上。
Symbol.species
构造器Getter
一个static
可以十分有用的地方是为一个衍生(子)类设置Symbol.species
getter(在语言规范内部称为@@species
)。这种能力允许一个子类通知一个父类应当使用什么样的构造器 —— 当不打算使用子类的构造器本身时 —— 如果有任何父类方法需要产生新的实例的话。
举个例子,在Array
上的许多方法都创建并返回一个新的Array
实例。如果你从Array
定义一个衍生的类,但你想让这些方法实际上继续产生Array
实例,而非从你的衍生类中产生实例,那么这就可以工作:
class MyCoolArray extends Array {
// 强制`species`为父类构造器
static get [Symbol.species]() { return Array; }
}
var a = new MyCoolArray( 1, 2, 3 ),
b = a.map( function(v){ return v * 2; } );
b instanceof MyCoolArray; // false
b instanceof Array; // true
为了展示一个父类方法如何可以有些像Array#map(..)
所做的那样,使用一个子类型声明,考虑如下代码:
class Foo {
// 将`species`推迟到衍生的构造器中
static get [Symbol.species]() { return this; }
spawn() {
return new this.constructor[Symbol.species]();
}
}
class Bar extends Foo {
// 强制`species`为父类构造器
static get [Symbol.species]() { return Foo; }
}
var a = new Foo();
var b = a.spawn();
b instanceof Foo; // true
var x = new Bar();
var y = x.spawn();
y instanceof Bar; // false
y instanceof Foo; // true
父类的Symbol.species
使用return this
来推迟到任意的衍生类,就像你通常期望的那样。然后Bar
手动地声明Foo
被用于这样的实例创建。当然,一个衍生的类依然可以使用new this.constructor(..)
生成它本身的实例。