几乎从JavaScript的最开始的那时候起,语法和开发模式都曾努力(读作:挣扎地)地戴上一个支持面向类的开发的假面具。伴随着newinstanceof和一个.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 { .. }。这主要用于将类的定义(技术上说,是构造器本身)作为函数参数值传递,或者将它赋值给一个对象属性。

extendssuper

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要求你避免使用这样的技术。

你能在对象设计上作出的选择归结为两个:使用这些静态的阶层 —— classextends,和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原生类型

新的classextend设计中最值得被欢呼的好处之一,就是(终于!)能够为内建原生类型,比如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.targetundefined,那么你就知道这个函数不是用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(..)生成它本身的实例。

results matching ""

    No results matching ""