通过上文的几种继承不难看出,各种策略都有自己的问题,也有相应的妥协。
正因为如此,实现继承的代码也显得非常冗长和混乱。
为解决这些问题,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);
有些内置类型的方法会返回新实例。默认情况下,返回实例的类型与原始实例的类型是一致的。如果想覆盖这个默认行为,可以重写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)已经抛弃混入模式,转向了组合模式——把方法提取到独立的类和辅助对象中,然后把他们组合起来,但不适用继承。这反映了哪个众所周知的软件设计原则:组合胜过继承