259 阅读9分钟

通过上文的几种继承不难看出,各种策略都有自己的问题,也有相应的妥协。

正因为如此,实现继承的代码也显得非常冗长和混乱。

为解决这些问题,ECMAScript6新引入的class关键字具有正式定义类的能力。

类(class)是ECMAScript中新的基础性语法糖结构(原型+构造函数),虽然表面上看起来可以支持正式的面向对象编程

类定义

  • 类声明 class Person {}

  • 类表达式 const person = class {};

    与函数表达式类似,类表达式在它们被求值前也不能引用

    且与函数定义不同,类定义不能提升,且类受块作用域限制(函数受函数作用域限制)

类可以包含 构造函数方法、实例方法、获取函数、设置函数和静态类方法。

class Person() {
    // 构造函数
    constructor() {}
    // 获取函数
    get name() {
        return this.name;
    }
    // 设置函数
    set name(val) {
        this.name = val;
    }
    // 静态方法
    static test() {
        console.log('calss', this);
    }
}

默认情况下,类中的代码都在严格模式下执行。首字母大写

类构造函数

方法名constructor会告诉解释器在使用new操作符创建类的新实例时,应该调用这个函数。

不定义构造函数相当于构造函数定义为空函数

使用new操作符实例化Person的操作,等于使用new调用其构造函数(JS解释器知道使用new和类,意味着应该使用constructor函数进行实例化)。

使用new调用类的构造函数会执行如下操作:

1、在内存中创建一个新对象

2、这个新对象内部的[[Prototype]]指针被赋值为构造函数的prototype属性

3、构造函数内部的this被赋值为这个新对象(即this指向这个新对象)

4、执行构造函数内部的代码

5、如果构造函数返回非空对象,则返回该对象;否则,返回刚刚创建的新对象

构造函数返回的对象会被用作实例化的对象,如果没有什么引用新创建的this对象,那么这个对象会被销毁

如果返回的不是this对象,而是其他对象,那么这个对象不会通过instanceof操作符检测出跟类有关联。因为这个对象的原型指针并没有被修改

class Person {
    constructor(override) {
        this.foo = 'foo';
        if(override) {
            return {
                bar: 'bar'
            }
        }
    }
}

let p1 = new Person();
let p2 = new Person(true);

console.log(p1);                    // Person{foo: 'foo'}
console.log(p1 instanceof Person);  // true

console.log(p2);                    // {bar: 'bar'}
console.log(p2 instanceof Person);  // false

可以使用instanceof操作符检查一个对象与类构造函数,以确定这个对象是不是类的实例。只不过此时的类构造函数要使用类标识符(类标识符有prototype属性)

类中定义的constructor方法不会被当成构造函数,在对它使用instanceof操作符时会返回false

但如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么结果会反转

class Person {}
let p1 = new Person();
console.log(p1.constructor === Person); // true
console.log(p1 instanceof Person);      // true
console.log(p1 instanceof Person.constructor); // false

let p2 = new Person.construcor();
console.log(p2.constructor === Person); // false
console.log(p2 instanceof Person);      // false
console.log(p2 instanceof Person.constructor); // true

与立即调用函数表达式相似,类也可以立即实例化

let p = new class Foo {
    constructor(x) {
        console.log(x);
    }
}('bar'); // bar
console.log(p); // Foo{}

实例、原型和类成员

每次通过new调用类标识符时,都会执行类构造函数。

在这个函数内部,可以为新创建的实例(this)添加“自有”属性。每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享

为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法

class Person {
    constructor() {
        // 添加到this的所有内容都会存在于不同的实例上
        this.locate = () => console.log('instance');
    }
    // 在类块中定义的所有内容都会定义在类的原型上
    locate() {
        console.log('prototype');
    }
}
let p = new Person();
p.locate();                // instance
Person.prototype.locate(); // prototype

可以把方法定义在类构造函数中或类块中,但不能在类块中给原型添加原始值或对象作为成员数据

class Person{
    name: 'echo'
}
// SyntaxError

静态类方法

这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例

使用static关键字作为前缀。在静态成员中,this引用类自身

静态类方法非常适合作为实例工厂

class Person {
    constructor(age) {
        this.age_ = age;
    }
    sayAge() {
        console.log(this.age_);
    }
    static create() {
        // 使用随机年龄创建并返回一个Person实例
        return new Person(Math.floor(Math.random() * 100));
    }
}
console.log(Person.create()); // Person {age_: ...}

在类上定义数据成员

class Person {
    sayName() {
        console.log(`${Person.greeting} ${this.name}`);
    }
}
// 在类上定义数据成员
Person.greeting = 'my name is ';
// 在原型上定义数据成员
Person.prototype.name = 'Jake';
let p = new Person();
p.sayName(); // my name is Jake

之所以没有显示支持添加数据成员,是因为在共享目标(原型和类)上添加可变(可修改)数据成员是一种反模式。

一般来说,对象实例应该独自拥有通过this引用的数据

迭代器与生成器方法

类定义语法支持在原型和类本身上定义生成器方法。因为支持生成器,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象

这里不对迭代器和生成器做过多解释,会在后续文章中做出详解。

继承

extends

ECMAScript6新增特性中最出色的一个就是原生支持了类继承机制。(虽然类继承使用的是新语法,但背后依旧使用的是原型链)

使用extends关键字,就可以继承任何拥有[[constructor]]和原型的对象。

很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容)

// 继承类
class Vehicle {}
class Bus extends Vehicle {}
let b = new Bus();
console.log(b instanceof Bus); // true
console.log(b instanceof Vehicle); // true

// 继承普通构造函数
class Engineer extends Person {}
let e = new Engineer();
console.log(e instanceof Engineer); // true
console.log(e instanceif Person);   // true

extends关键字也可以再类表达式中使用,比如let Bar = class extends Foo {}

super

派生类的方法可以通过super关键字引用他们的原型。

这个关键字只能再派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部

在类构造函数中使用super可以调用父类构造函数

class Vehicle {
    constructor() {
        this.hasEngine = true;
    }
    static identify() {
        console.log('vehicle');
    }
}
class Bus extends Vehicle {
    constructor() {
        // 不要在调用super()之前引用this,否则会抛出ReferenceError
        super(); // 相当于super.constructor();
        
        console.log(this instanceof Vehicle); // true
        console.log(this); // Bus {hasEngine: true}
    }
    
    // 在静态方法中可以通过super调用继承的类上定义的静态方法
    static identify() {
        super.identify();
    }
}
Bus.identify(); // vehicle

ES6给类构造函数和静态方法添加了内部特性[[HomeObject]],这个特性是一个指针,指向定义该方法的对象。

这个指针是自动赋值的,而且只能再JS引擎内部访问

super始终会定义为[[HomeObject]]的原型

使用super时要注意几个问题

  • super只能在派生类构造函数和静态方法中使用

    class Vehicle{
        constructor() {
            super(); // SyntaxError
        }
    }
    
  • 不能单独引用super关键字,要么用它调用构造函数,要么用它引用静态方法

  • 调用uper()会调用父类构造函数,并将返回的实例赋值给this

  • super()的行为如同调用构造函数,如果需要给父类构造函数传参,则需手动传入,例如super(params)

  • 如果没有定义类构造函数,在实例化派生类时会调用super(),而且会传入所有传给派生类的参数

    class Vehicle{
        constructor(params) {
            this.a = params;
        }
    }
    class Bus extends Vehicle {}
    console.log(new Bus(123)); // Bus {a: 123}
    
  • 在类构造函数中,不能在调用super()之前引用this

  • 如果在派生类中显示定义了构造函数,则要么必须在其中调用super(),要么必须在其中返回一个对象

    class Vehicle {}
    class Car extends Vehicle {}
    class Bus extends Vehicle {
        constructor() {
            super();
        }
    }
    class Van extends Vehicle {
        constructor() {
            return {};
        }
    }
    console.log(new Car); // Car {}
    console.log(new Bus()); // Bus {}
    console.log(new Van()); // {}
    

抽象基类

有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。

虽然ECMAScript,没有专门支持这种类的语法,但通过new.target也很容易实现

通过在实例化时检测new.target是不是抽象基类,可以阻止对抽象基类的实例化

class Vehicle {
    constructor() {
        console.log(new.target);
        if(new.target === Vehicle) {
            throw new Error('Vehicle cannot be directly instantiated');
        }
    }
}

通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法

class Vehicle {
    constructor() {
        if(new.target === Vehicle) {
            throw new Error('Vehicle cannot be directly instantiated');
        }
        if(!this.foo) {
            throw new Error('Inheriting class must define foo()');
        }
        console.log('success!');
    }
}
// 派生类
class Bus extends Vehicle {
    // 必须定义的foo,否则抛出错误
   foo() {}
}

继承内置类型

class SuperArray extends Array {
    // 洗牌算法
    shuffle() {
        for(let i = this.length - 1; i>0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            [this[i], this[j]] = [this[j], this[i]];
        }
    }
}
let a = new SuperArray(1, 2, 3, 4);
console.log(a);
a.shuffle();
console.log(a);

截图.PNG

有些内置类型的方法会返回新实例。默认情况下,返回实例的类型与原始实例的类型是一致的。如果想覆盖这个默认行为,可以重写Symbol.species访问器,这个访问器决定在创建返回的实例时使用的类(注意下面代码中的最后一行

class SuperArray extends Array {
    static get [Symbol.species] () {
        return Array;
    }
}
let a1 = new SuperArray(1, 2, 3, 4);
let a2 = a1.filter(x => !!(x % 2));
console.log(a1); // [1, 2, 3, 4]
console.log(a2); // [1, 3]
console.log(a1 instanceof SuperArray); // true
console.log(a2 instanceof SuperArray); // false

类混入

虽然ES6没有显式支持多类继承,但通过现有特性可以轻松地模拟这种行为

Object.assign()方法是为了混入对象行为而设计的。

只有在需要混入类的行为时才有必要自己实现混入表达式。

如果只是需要混入多个对象的属性,那么使用Object.assign()就可以了

任何可以解析为一个类或者一个构造函数的表达式都可以出现在extends关键字后面

class Vehicle {}

function getParentClass() {
    console.log('test');
    return Vehicle;
}

class Bus extends getParentClass() {}

混入模式可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会解析为一个可以被继承的类。实现这种模式有不同的策略:

  • 定义一组“可嵌套”的函数,每个函数分别接收一个超类作为参数,而将混入类定义为这个参数的子类,并返回这个类。这些组合函数可以连缀调用,最终组合成超类表达式
    class Vehicle {}
    let FooMixin = (Superclass) => class extends Superclass {
        foo() {
            console.log('foo');
        }
    }
    let BarMixin = (Superclass) => class extends Superclass {
        bar() {
            console.log('bar');
        }
    }
    class Bus extends FooMixin(BarMixin(vehicle)) {}
    let b = new Bus();
    b.foo(); // foo
    b.bar(); // bar
    
    // 通过辅助函数,把嵌套调用展开
    function mix(BaseClass, ...Mixins) {
        return Mixins.reduce((accumulator, current) => current(accumulator), baseClass);
    }
    
    class Bus extends mix(Vehicle, FooMixin, BarMixin) {}
    

很多JS框架(特别是React)已经抛弃混入模式,转向了组合模式——把方法提取到独立的类和辅助对象中,然后把他们组合起来,但不适用继承。

这反映了哪个众所周知的软件设计原则:组合胜过继承