使用派生类进行继承

ES6 之前,实现自定义类型的继承是个繁琐的过程。严格的继承要求有多个步骤。例如,研究以下范例:

function Rectangle(length, width) {
    this.length = length;
    this.width = width;
}

Rectangle.prototype.getArea = function() {
    return this.length * this.width;
};

function Square(length) {
    Rectangle.call(this, length, length);
}

Square.prototype = Object.create(Rectangle.prototype, {
    constructor: {
        value:Square,
        enumerable: false,
        writable: true,
        configurable: true
    }
});

var square = new Square(3);

console.log(square.getArea());              // 9
console.log(square instanceof Square);      // true
console.log(square instanceof Rectangle);   // true

Square 继承了 Rectangle,为此它必须使用 Rectangle.prototype 所创建的一个新对象来重写 Square.prototype,并且还要调用 Rectangle.call() 方法。这些步骤常常会搞晕 JS 的新手,并会成为有经验开发者出错的根源之一。

类让继承工作变得更轻易,使用熟悉的 extends 关键字来指定当前类所需要继承的函数,即可。生成的类的原型会被自动调整,而你还能调用 super() 方法来访问基类的构造器。此处是与上个例子等价的 ES6 代码:

class Rectangle {
    constructor(length, width) {
        this.length = length;
        this.width = width;
    }

    getArea() {
        return this.length * this.width;
    }
}

class Square extends Rectangle {
    constructor(length) {

        // same as Rectangle.call(this, length, length)
        super(length, length);
    }
}

var square = new Square(3);

console.log(square.getArea());              // 9
console.log(square instanceof Square);      // true
console.log(square instanceof Rectangle);   // true

此次 Square 类使用了 extends 关键字继承了 RectangleSquare 构造器使用了 super() 配合指定参数调用了 Rectangle 的构造器。注意与 ES5 版本的代码不同,Rectangle 标识符仅在类定义时被使用了(在 extends 之后) 。

继承了其他类的类被称为派生类(derived classes)。如果派生类指定了构造器,就需要使用 super(),否则会造成错误。若你选择不使用构造器,super() 方法会被自动调用,并会使用创建新实例时提供的所有参数。例如,下列两个类是完全相同的:

class Square extends Rectangle {
    // no constructor
}

// Is equivalent to

class Square extends Rectangle {
    constructor(...args) {
        super(...args);
    }
}

此例中的第二个类展示了与所有派生类默认构造器等价的写法,所有的参数都按顺序传递给了基类的构造器。在当前需求下,这种做法并不完全准确,因为 Square 构造器只需要单个参数,因此最好手动定义构造器。

使用 super() 时需牢记以下几点:

  1. 你只能在派生类中使用 super()。若尝试在非派生的类(即:没有使用 extends 关键字的类)或函数中使用它,就会抛出错误。

  2. 在构造器中,你必须在访问 this 之前调用 super()。由于 super() 负责初始化 this,因此试图先访问 this 自然就会造成错误。

  3. 唯一能避免调用 super() 的办法,是从类构造器中返回一个对象。

屏蔽类方法

派生类中的方法总是会屏蔽基类的同名方法。例如,你可以将 getArea() 方法添加到 Square 类,以便重定义它的功能:

class Square extends Rectangle {
    constructor(length) {
        super(length, length);
    }

    // override and shadow Rectangle.prototype.getArea()
    getArea() {
        return this.length * this.length;
    }
}

由于 getArea() 已经被定义为 Square 的一部分,Rectangle.prototype.getArea() 方法就不能在 Square 的任何实例上被调用。当然,你总是可以使用 super.getArea() 方法来调用基类中的同名方法,就像这样:

class Square extends Rectangle {
    constructor(length) {
        super(length, length);
    }

    // override, shadow, and call Rectangle.prototype.getArea()
    getArea() {
        return super.getArea();
    }
}

用这种方式使用 super,其效果等同于第四章讨论过的 super 引用(详见 “使用 super 引用的简单原型访问”)。this 值会被自动设置为正确的值,因此你就能进行简单的调用。

继承静态成员

如果基类包含静态成员,那么这些静态成员在派生类中也是可用的。继承的工作方式类似于其他语言,但对于 JS 而言则是新概念。此处有个范例:

class Rectangle {
    constructor(length, width) {
        this.length = length;
        this.width = width;
    }

    getArea() {
        return this.length * this.width;
    }

    static create(length, width) {
        return new Rectangle(length, width);
    }
}

class Square extends Rectangle {
    constructor(length) {

        // same as Rectangle.call(this, length, length)
        super(length, length);
    }
}

var rect = Square.create(3, 4);

console.log(rect instanceof Rectangle);     // true
console.log(rect.getArea());                // 12
console.log(rect instanceof Square);        // false

在此代码中,一个新的静态方法 create() 被添加到 Rectangle 类中。通过继承,该方法会以 Square.create() 的形式存在,并且其行为方式与 Rectangle.create() 一样。

从表达式中派生类

ES6 中派生类的最强大能力,或许就是能够从表达式中派生类。只要一个表达式能够返回一个具有 [[Construct]] 属性以及原型的函数,你就可以对其使用 extends。例如:

function Rectangle(length, width) {
    this.length = length;
    this.width = width;
}

Rectangle.prototype.getArea = function() {
    return this.length * this.width;
};

class Square extends Rectangle {
    constructor(length) {
        super(length, length);
    }
}

var x = new Square(3);
console.log(x.getArea());               // 9
console.log(x instanceof Rectangle);    // true

Rectangle 被定义为 ES5 风格的构造器,而 Square 则是一个类。由于 Rectangle 具有 [[Construct]] 以及原型,Square 类就能直接继承它。

extends 后面能接受任意类型的表达式,这带来了巨大可能性,例如动态地决定所要继承的类:

function Rectangle(length, width) {
    this.length = length;
    this.width = width;
}

Rectangle.prototype.getArea = function() {
    return this.length * this.width;
};

function getBase() {
    return Rectangle;
}

class Square extends getBase() {
    constructor(length) {
        super(length, length);
    }
}

var x = new Square(3);
console.log(x.getArea());               // 9
console.log(x instanceof Rectangle);    // true

getBase() 函数作为类声明的一部分被直接调用,它返回了 Rectangle,使得此例的功能等价于前一个例子。并且由于可以动态地决定基类,那也就能创建不同的继承方式。例如,你可以有效地创建混入:

let SerializableMixin = {
    serialize() {
        return JSON.stringify(this);
    }
};

let AreaMixin = {
    getArea() {
        return this.length * this.width;
    }
};

function mixin(...mixins) {
    var base = function() {};
    Object.assign(base.prototype, ...mixins);
    return base;
}

class Square extends mixin(AreaMixin, SerializableMixin) {
    constructor(length) {
        super();
        this.length = length;
        this.width = length;
    }
}

var x = new Square(3);
console.log(x.getArea());               // 9
console.log(x.serialize());             // "{"length":3,"width":3}"

此例使用了混入(mixin)而不是传统继承。mixin() 函数接受代表混入对象的任意数量的参数,它创建了一个名为 base 的函数,并将每个混入对象的属性都赋值到新函数的原型上。此函数随后被返回,于是 Square 就能够对其使用 extends 关键字了。注意由于仍然使用了 extends,你就必须在构造器内调用 super()

Square 的实例既有来自 AreaMixingetArea() 方法,又有来自 SerializableMixinserialize() 方法,这是通过原型继承实现的。mixin() 函数使用了混入对象的所有自有属性,动态地填充了新函数的原型(记住:若多个混入对象拥有相同的属性,则只有最后添加的属性会被保留)。

任意表达式都能在 extends 关键字后使用,但并非所有表达式的结果都是一个有效的 类。特别的,下列表达式类型会导致错误:

  • null

  • 生成器函数( 详见第八章) 。

试图使用结果为上述值的表达式来创建一个新的类实例,都会抛出错误,因为不存在 [[Construct]] 可供调用。

继承内置对象

几乎从 JS 数组出现那天开始,开发者就想通过继承机制来创建他们自己的特殊数组类型。在 ES5 及早期版本中,这是不可能做到的。试图使用传统继承并不能产生功能正确的代码,例如:

// built-in array behavior
var colors = [];
colors[0] = "red";
console.log(colors.length);         // 1

colors.length = 0;
console.log(colors[0]);             // undefined

// trying to inherit from array in ES5

function MyArray() {
    Array.apply(this, arguments);
}

MyArray.prototype = Object.create(Array.prototype, {
    constructor: {
        value: MyArray,
        writable: true,
        configurable: true,
        enumerable: true
    }
});

var colors = new MyArray();
colors[0] = "red";
console.log(colors.length);         // 0

colors.length = 0;
console.log(colors[0]);             // "red"

console.log() 在此代码尾部的输出说明了:对数组使用传统形式的 JS 继承, 产生了预期外的行为。MyArray 实例上的 length 属性以及数值属性,其行为与内置数组并不一致,因为这些功能并未被涵盖在 Array.apply() 或数组原型中。

ES6 中的类,其设计目的之一就是允许从内置对象上进行继承。为了达成这个目的,类的继承模型与 ES5 或更早版本的传统继承模型有轻微差异:

ES5 的传统继承中,this 的值会先被派生类(例如 MyArray)创建,随后基类构造器(例如 Array.apply() 方法)才被调用。这意味着 this 一开始就是 MyArray 的实例,之后才使用了 Array 的附加属性对其进行了装饰。

ES6 基于类的继承中,this 的值会先被基类(Array)创建,随后才被派生类的构造器(MyArray)所修改。结果是 this 初始就拥有作为基类的内置对象的所有功能,并能正确接收与之关联的所有功能。

以下范例实际展示了基于类的特殊数组:

class MyArray extends Array {
    // empty
}

var colors = new MyArray();
colors[0] = "red";
console.log(colors.length);         // 1

colors.length = 0;
console.log(colors[0]);             // undefined

MyArray 直接继承了 Array,因此工作方式与正规数组一致。与数值索引属性的互动更新了 length 属性,而操纵 length 属性也能更新索引属性。这意味着你既能适当地继承 Array 来创建你自己的派生数组类,也同样能继承其他的内置对象。伴随着这些附加功能,ES6 与派生类型有效解决了从内置类型进行派生这最后的特殊情况,不过这种情况仍然值得继续探索。

Symbol.species 属性

继承内置对象一个有趣的方面是:任意能返回内置对象实例的方法,在派生类上却会自动返回派生类的实例。因此,若你拥有一个继承了 Array 的派生类 MyArray,诸如 slice() 之类的方法都会返回 MyArray 的实例。例如:

class MyArray extends Array {
    // empty
}

let items = new MyArray(1, 2, 3, 4),
    subitems = items.slice(1, 3);

console.log(items instanceof MyArray);      // true
console.log(subitems instanceof MyArray);   // true

在此代码中,slice() 方法返回了 MyArray 的一个实例。slice() 方法是从 Array 上继承的,原本应当返回 Array 的一个实例。而 Symbol.species 属性在后台造成了这种变化。

Symbol.species 知名符号被用于定义一个能返回函数的静态访问器属性。每当类实例的方法(构造器除外)必须创建一个实例时,前面返回的函数就被用为新实例的构造器。下列内置类型都定义了 Symbol.species

  • Array

  • ArrayBuffer ( 详见第十章)

  • Map

  • Promise

  • RegExp

  • Set

  • 类型化数组( 详见第十章)

以上每个类型都拥有默认的 Symbol.species 属性,其返回值为 this,意味着该属性总是会返回自身的构造器函数。若你准备在一个自定义类上实现此功能,代码就像这样:

// several builtin types use species similar to this
class MyClass {
    static get [Symbol.species]() {
        return this;
    }

    constructor(value) {
        this.value = value;
    }

    clone() {
        return new this.constructor[Symbol.species](this.value);
    }
}

在此例中,Symbol.species 知名符号被用于定义 MyClass 的一个静态访问器属性。注意此处只有 getter 而没有 setter,这是因为修改类的 species 是不允许的。任何对 this.constructor[Symbol.species] 的调用都会返回 MyClassclone() 方法使用了该定义来返回一个新的实例,而没有直接使用 MyClass,这就允许派生类重写这个值。例如:

class MyClass {
    static get [Symbol.species]() {
        return this;
    }

    constructor(value) {
        this.value = value;
    }

    clone() {
        return new this.constructor[Symbol.species](this.value);
    }
}

class MyDerivedClass1 extends MyClass {
    // empty
}

class MyDerivedClass2 extends MyClass {
    static get [Symbol.species]() {
        return MyClass;
    }
}

let instance1 = new MyDerivedClass1("foo"),
    clone1 = instance1.clone(),
    instance2 = new MyDerivedClass2("bar"),
    clone2 = instance2.clone();

console.log(clone1 instanceof MyClass);             // true
console.log(clone1 instanceof MyDerivedClass1);     // true
console.log(clone2 instanceof MyClass);             // true
console.log(clone2 instanceof MyDerivedClass2);     // false

此处, MyDerivedClass1 继承了 MyClass,并且未修改 Symbol.species 属性。由于 this.constructor[Symbol.species] 会返回 MyDerivedClass1,当 clone() 被调用时,它就返回了 MyDerivedClass1 的一个实例。MyDerivedClass2 类也继承了 MyClass,但重写了 Symbol.species,让其返回 MyClass。当 clone()MyDerivedClass2 的一个实例上被调用时,返回值就变成 MyClass 的一个实例。使用 Symbol.species,任意派生类在调用应当返回实例的方法时,都可以判断出需要返回什么类型的值。

例如,Array 使用了 Symbol.species 来指定方法所使用的类,让其返回值为一个数组。在 Array 派生出的类中,你可以决定这些继承的方法应返回何种类型的对象,正如:

class MyArray extends Array {
    static get [Symbol.species]() {
        return Array;
    }
}

let items = new MyArray(1, 2, 3, 4),
    subitems = items.slice(1, 3);

console.log(items instanceof MyArray);      // true
console.log(subitems instanceof Array);     // true
console.log(subitems instanceof MyArray);   // false

此代码重写了从 Array 派生的 MyArray 类上的 Symbol.species。所有返回数组的继承方法现在都会使用 Array 的实例,而不是 MyArray 的实例。

一般而言,每当想在类方法中使用 this.constructor 时,你就应当设置类的 Symbol.species 属性。这么做允许派生类轻易地重写方法的返回类型。此外,若你从一个拥有 Symbol.species 定义的类创建了派生类,要保证使用此属性,而不是直接使用构造器。