几乎从JavaScript的最开始的那时候起,语法和开发模式都曾努力(读作:挣扎地)地戴上一个支持面向类的开发的假面具。伴随着newinstanceof和一个.constructor属性,谁能不认为JS在它的原型系统的某个地方藏着类机制呢?

当然,JS的“类”与经典的类完全不同。其区别有很好的文档记录,所以在此我不会在这一点上花更多力气。

注意: 要学习更多关于在JS中假冒“类”的模式,以及另一种称为“委托”的原型的视角,参见本系列的 this与对象原型 的后半部分。

class

虽然JS的原型机制与传统的类的工作方式不同,但是这并不能阻挡一种强烈的潮流 —— 要求这门语言扩展它的语法糖以便将“类”表达得更像真正的类。让我们进入ES6class关键字和它相关的机制。

这个特性是一个具有高度争议、旷日持久的争论的结果,而且代表了几种对关于如何处理JS类的强烈反对意见的妥协的一小部分。大多数希望JS拥有完整的类机制的开发者将会发现新语法的一些部分十分吸引人,但是也会发现一些重要的部分仍然缺失了。但不要担心,TC39已经致力于另外的特性,以求在后ES6时代中增强类机制。

新的ES6类机制的核心是class关键字,它标识了一个 ,其内容定义了一个函数的原型的成员。考虑如下代码:

  1. class Foo {
  2. constructor(a,b) {
  3. this.x = a;
  4. this.y = b;
  5. }
  6. gimmeXY() {
  7. return this.x * this.y;
  8. }
  9. }

一些要注意的事情:

  • class Foo 暗示着创建一个(特殊的)名为Foo的函数,与你在前ES6中所做的非常相似。
  • constructor(..)表示了这个Foo(..)函数的签名,和它的函数体内容。
  • 类方法同样使用对象字面量中可以使用的“简约方法”语法,正如在第二章中讨论过的。这也包括在本章早先讨论过的简约generator,以及ES5的getter/setter语法。但是,类方法是不可枚举的而对象方法默认是可枚举的。
  • 与对象字面量不同的是,在一个class内容的部分没有逗号分隔各个成员!事实上,这甚至是不允许的。

前一个代码段的class语法定义可以大致认为和这个前ES6等价物相同,对于那些以前做过原型风格代码的人来说可能十分熟悉它:

  1. function Foo(a,b) {
  2. this.x = a;
  3. this.y = b;
  4. }
  5. Foo.prototype.gimmeXY = function() {
  6. return this.x * this.y;
  7. }

不管是前ES6形式还是新的ES6class形式,这个“类”现在可以被实例化并如你所想地使用了:

  1. var f = new Foo( 5, 15 );
  2. f.x; // 5
  3. f.y; // 15
  4. 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 { .. }。这主要用于将类的定义(技术上说,是构造器本身)作为函数参数值传递,或者将它赋值给一个对象属性。

extendssuper

ES6的类还有一种语法糖,用于在两个函数原型之间建立[[Prototype]]委托链 —— 通常被错误地标记为“继承”或者令人困惑地标记为“原型继承” —— 使用我们熟悉的面向类的术语extends

  1. class Bar extends Foo {
  2. constructor(a,b,c) {
  3. super( a, b );
  4. this.z = c;
  5. }
  6. gimmeXYZ() {
  7. return super.gimmeXY() * this.z;
  8. }
  9. }
  10. var b = new Bar( 5, 15, 25 );
  11. b.x; // 5
  12. b.y; // 15
  13. b.z; // 25
  14. 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时,将很有可能发生令你诧异的事情。考虑这个类阶层:

  1. class ParentA {
  2. constructor() { this.id = "a"; }
  3. foo() { console.log( "ParentA:", this.id ); }
  4. }
  5. class ParentB {
  6. constructor() { this.id = "b"; }
  7. foo() { console.log( "ParentB:", this.id ); }
  8. }
  9. class ChildA extends ParentA {
  10. foo() {
  11. super.foo();
  12. console.log( "ChildA:", this.id );
  13. }
  14. }
  15. class ChildB extends ParentB {
  16. foo() {
  17. super.foo();
  18. console.log( "ChildB:", this.id );
  19. }
  20. }
  21. var a = new ChildA();
  22. a.foo(); // ParentA: a
  23. // ChildA: a
  24. var b = new ChildB(); // ParentB: b
  25. b.foo(); // ChildB: b

在前面这个代码段中一切看起来都相当自然和在意料之中。但是,如果你试着借来b.foo()并在a的上下文中使用它的话 —— 通过动态this绑定的力量,这样的借用十分常见而且以许多不同的方式被使用,包括最明显的mixin —— 你可能会发现这个结果出奇地难看:

  1. // 在`a`的上下文环境中借用`b.foo()`
  2. b.foo.call( a ); // ParentB: a
  3. // ChildB: a

如你所见,引用this.id被动态地重绑定所以在两种情况下都报告: a而不是: b。但是b.foo()super.foo()引用没有被动态重绑定,所以它依然报告ParentB而不是期望的ParentA

因为b.foo()引用super,所以它被静态地绑定到了ChildB/ParentB阶层而不能被用于ChildA/ParentA阶层。在ES6中没有办法解决这个限制。

如果你有一个不带移花接木的静态类阶层,那么super的工作方式看起来很直观。但公平地说,实施带有this的编码的一个主要好处正是这种灵活性。简单地说,class + super要求你避免使用这样的技术。

你能在对象设计上作出的选择归结为两个:使用这些静态的阶层 —— classextends,和super将十分不错 —— 要么放弃所有“山寨”类的企图,而接受动态且灵活的,没有类的对象和[[Prototype]]委托(参见本系列的 this与对象原型)。

子类构造器

对类或子类来说构造器不是必需的;如果构造器被省略,这两种情况下都会有一个默认构造器顶替上来。但是,对于一个直接的类和一个被扩展的类来说,顶替上来的默认构造器是不同的。

特别地,默认的子类构造器自动地调用父构造器,并且传递所有参数值。换句话说,你可以认为默认的子类构造器有些像这样:

  1. constructor(...args) {
  2. super(...args);
  3. }

这是一个需要注意的重要细节。不是所有支持类的语言的子类构造器都会自动地调用父构造器。C++会,但Java不会。更重要的是,在前ES6的类中,这样的自动“父构造器”调用不会发生。如果你曾经依赖于这样的调用 不会 发生,按么当你将代码转换为ES6class时就要小心。

ES6子类构造器的另一个也许令人吃惊的偏差/限制是:在一个子类的构造器中,在super(..)被调用之前你不能访问this。其中的原因十分微妙和复杂,但是可以归结为是父构造器在实际上创建/初始化你的实例的this。前ES6中,它相反地工作;this对象被“子类构造器”创建,然后你使用这个“子类”的this上下文环境调用“父构造器”。

让我们展示一下。这是前ES6版本:

  1. function Foo() {
  2. this.a = 1;
  3. }
  4. function Bar() {
  5. this.b = 2;
  6. Foo.call( this );
  7. }
  8. // `Bar` “扩展” `Foo`
  9. Bar.prototype = Object.create( Foo.prototype );

但是这个ES6等价物不允许:

  1. class Foo {
  2. constructor() { this.a = 1; }
  3. }
  4. class Bar extends Foo {
  5. constructor() {
  6. this.b = 2; // 在`super()`之前不允许
  7. super(); // 可以通过调换这两个语句修正
  8. }
  9. }

在这种情况下,修改很简单。只要在子类Bar的构造器中调换两个语句的位置就行了。但是,如果你曾经依赖于前ES6可以跳过“父构造器”调用的话,就要小心这不再被允许了。

extend原生类型

新的classextend设计中最值得被欢呼的好处之一,就是(终于!)能够为内建原生类型,比如Array,创建子类。考虑如下代码:

  1. class MyCoolArray extends Array {
  2. first() { return this[0]; }
  3. last() { return this[this.length - 1]; }
  4. }
  5. var a = new MyCoolArray( 1, 2, 3 );
  6. a.length; // 3
  7. a; // [1,2,3]
  8. a.first(); // 1
  9. a.last(); // 3

在ES6之前,可以使用手动的对象创建并将它链接到Array.prototype来制造一个Array的“子类”的山寨版,但它仅能部分地工作。它缺失了一个真正数组的特殊行为,比如自动地更新length属性。ES6子类应该可以如我们盼望的那样使用“继承”与增强的行为来完整地工作!

另一个常见的前ES6“子类”的限制与Error对象有关,在创建自定义的错误“子类”时。当纯粹的Error被创建时,它们自动地捕获特殊的stack信息,包括错误被创建的行号和文件。前ES6的自定义错误“子类”没有这样的特殊行为,这严重地限制了它们的用处。

ES6前来拯救:

  1. class Oops extends Error {
  2. constructor(reason) {
  3. super(reason);
  4. this.oops = reason;
  5. }
  6. }
  7. // 稍后:
  8. var ouch = new Oops( "I messed up!" );
  9. throw ouch;

前面代码段的ouch自定义错误对象将会向任何其他的纯粹错误对象那样动作,包括捕获stack。这是一个巨大的改进!

new.target

ES6引入了一个称为 元属性 的新概念(见第七章),用new.target的形式表示。

如果这看起来很奇怪,是的;将一个带有.的关键字与一个属性名配成一对,对JS来说绝对是不同寻常的模式。

new.target是一个在所有函数中可用的“魔法”值,虽然在普通的函数中它总是undefined。在任意的构造器中,new.target总是指向new实际直接调用的构造器,即便这个构造器是在一个父类中,而且是通过一个在子构造器中的super(..)调用被委托的。

  1. class Foo {
  2. constructor() {
  3. console.log( "Foo: ", new.target.name );
  4. }
  5. }
  6. class Bar extends Foo {
  7. constructor() {
  8. super();
  9. console.log( "Bar: ", new.target.name );
  10. }
  11. baz() {
  12. console.log( "baz: ", new.target );
  13. }
  14. }
  15. var a = new Foo();
  16. // Foo: Foo
  17. var b = new Bar();
  18. // Foo: Bar <-- 遵照`new`的调用点
  19. // Bar: Bar
  20. b.baz();
  21. // baz: undefined

new.target元属性在类构造器中没有太多作用,除了访问一个静态属性/方法(见下一节)。

如果new.targetundefined,那么你就知道这个函数不是用new调用的。然后你就可以强制一个new调用,如果有必要的话。

static

当一个子类Bar扩展一个父类Foo时,我们已经观察到Bar.prototype[[Prototype]]链接到Foo.prototype。但是额外地,Bar()[[Prototype]]链接到Foo()。这部分可能就没有那么明显了。

但是,在你为一个类声明static方法(不只是属性)时它就十分有用,因为这些静态方法被直接添加到这个类的函数对象上,不是函数对象的prototype对象上。考虑如下代码:

  1. class Foo {
  2. static cool() { console.log( "cool" ); }
  3. wow() { console.log( "wow" ); }
  4. }
  5. class Bar extends Foo {
  6. static awesome() {
  7. super.cool();
  8. console.log( "awesome" );
  9. }
  10. neat() {
  11. super.wow();
  12. console.log( "neat" );
  13. }
  14. }
  15. Foo.cool(); // "cool"
  16. Bar.cool(); // "cool"
  17. Bar.awesome(); // "cool"
  18. // "awesome"
  19. var b = new Bar();
  20. b.neat(); // "wow"
  21. // "neat"
  22. b.awesome; // undefined
  23. b.cool; // undefined

小心不要被搞糊涂,认为static成员是在类的原型链上的。它们实际上存在与函数构造器中间的一个双重/平行链条上。

Symbol.species构造器Getter

一个static可以十分有用的地方是为一个衍生(子)类设置Symbol.speciesgetter(在语言规范内部称为@@species)。这种能力允许一个子类通知一个父类应当使用什么样的构造器 —— 当不打算使用子类的构造器本身时 —— 如果有任何父类方法需要产生新的实例的话。

举个例子,在Array上的许多方法都创建并返回一个新的Array实例。如果你从Array定义一个衍生的类,但你想让这些方法实际上继续产生Array实例,而非从你的衍生类中产生实例,那么这就可以工作:

  1. class MyCoolArray extends Array {
  2. // 强制`species`为父类构造器
  3. static get [Symbol.species]() { return Array; }
  4. }
  5. var a = new MyCoolArray( 1, 2, 3 ),
  6. b = a.map( function(v){ return v * 2; } );
  7. b instanceof MyCoolArray; // false
  8. b instanceof Array; // true

为了展示一个父类方法如何可以有些像Array#map(..)所做的那样,使用一个子类型声明,考虑如下代码:

  1. class Foo {
  2. // 将`species`推迟到衍生的构造器中
  3. static get [Symbol.species]() { return this; }
  4. spawn() {
  5. return new this.constructor[Symbol.species]();
  6. }
  7. }
  8. class Bar extends Foo {
  9. // 强制`species`为父类构造器
  10. static get [Symbol.species]() { return Foo; }
  11. }
  12. var a = new Foo();
  13. var b = a.spawn();
  14. b instanceof Foo; // true
  15. var x = new Bar();
  16. var y = x.spawn();
  17. y instanceof Bar; // false
  18. y instanceof Foo; // true

父类的Symbol.species使用return this来推迟到任意的衍生类,就像你通常期望的那样。然后Bar手动地声明Foo被用于这样的实例创建。当然,一个衍生的类依然可以使用new this.constructor(..)生成它本身的实例。