类
几乎从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.speciesgetter(在语言规范内部称为@@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(..)生成它本身的实例。