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

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

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

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

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

    一些要注意的事情:

    • 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 { .. }。这主要用于将类的定义(技术上说,是构造器本身)作为函数参数值传递,或者将它赋值给一个对象属性。

    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的坑

    注意到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中)。

    这意味着什么?这意味着如果你习惯于从一个“类”中拿来一个方法并通过覆盖它的,比如使用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. super.foo();
    17. console.log( "ChildB:", this.id );
    18. }
    19. }
    20. var a = new ChildA();
    21. a.foo(); // ParentA: a
    22. // ChildA: a
    23. var b = new ChildB(); // ParentB: b
    24. b.foo(); // ChildB: b

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

    如你所见,引用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. }

    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前来拯救:

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

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

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

    new.target是一个在所有函数中可用的“魔法”值,虽然在普通的函数中它总是undefined。在任意的构造器中,总是指向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. var b = new Bar();
    17. // Foo: Bar <-- 遵照`new`的调用点
    18. // Bar: Bar
    19. b.baz();
    20. // baz: undefined

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

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

    当一个子类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(在语言规范内部称为@)。这种能力允许一个子类通知一个父类应当使用什么样的构造器 —— 当不打算使用子类的构造器本身时 —— 如果有任何父类方法需要产生新的实例的话。

    举个例子,在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 Foo; // true