ES6开始正式支持类和继承。不过ES6的类仅是封装了ES5.1构造函数加原型继承的语法糖而已。
# 继承
很多面向对象语言都支持接口继承和实现继承。实现继承实际的方法,而接口继承只继承方法签名,但ECMAScript中的函数没有签名。实现继承是ECMAScript唯一支持的继承方式,而这主要是通过原型链实现的。
# 原型链
原型链是ECMAScript的主要继承方式,基本思想就是通过原型继承多个引用类型的属性和方法。默认情况下,所有引用类型都继承自Object,这也是为什么自定义类型能够继承包括toString()、valueOf()在内的所有默认方法的原因。
构造函数、原型和实例之间的关系:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。如果原型是另一个类型的实例,则意味着这个原型本身有一个内部指针指向另一个原型对象,相应地另一个原型也有一个指针指向另一个构造函数,这就在实例和原型之间构造了一条原型链
。
function AType() {
this.property = true; //这是一个实例属性
}
AType.prototype.getAValue = function() {
return this.property; //这是一个原型方法
};
function BType() {
this.bproperty = false;
}
// 继承 AType
BType.prototype = new AType();
// 继承后再添加方法
BType.prototype.getBValue = function () {
return this.bproperty;
};
let instance = new BType();
console.log(instance.getAValue()); // true
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
该例中BType通过创建AType的实例并将其赋值给自己的原型BType.prototype实现对AType的继承,该赋值重写了BType最初的原型。这意味着BType的实例不仅能从AType实例中继承属性和方法,还与AType的原型挂钩了。原型链搜索机制为:读取实例上的属性方法时,首先会在实例上搜索,如果没找到会继续搜索实例的原型,再继续搜索原型的原型,一直持续到原型链的末端(即Object.prototype)。
# 原型与实例的关系
可以通过两种方式来确定:
instanceof操作符:如果一个实例的原型链中出现过相应的构造函数,则instanceof返回true
console.log(instance instranceof AType);//true
1isPrototypeOf()
console.log(BType.prototype.isPrototypeOf(instance));//true
1
# 关于方法
子类有时候需要覆盖父类的方法,或增加父类没有的方法。这些方法必须在原型赋值之后再添加到原型上。且以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写了原型链(即覆盖)。
function AType() {
this.property = true; //这是一个实例属性
}
AType.prototype.getAValue = function() {
return this.property; //这是一个原型方法
};
function BType() {
this.bproperty = false;
}
// 继承 AType
BType.prototype = new AType();
// 通过对象字面量添加新方法,这会导致上一行无效,之前的原型链就断了,AType和BType之间就没有关系了
BType.prototype = {
getBValue() {
return this.bproperty;
},
someOtherMethod() {
return false;
}
};
let instance = new BType();
console.log(instance.getAValue()); // 出错
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 原型链的问题
原型中包含的引用值会在所有实例间共享
,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。但在使用原型实现继承时,原型实际上变成了另一个类型的实例,也就是说原先的实例属性摇身一变成了原型属性!子类型在实例化时不能给父类型的构造函数传参
,无法在不影响所有对象实例情况下把参数传进父类的构造函数。
# 盗用构造函数
为解决原型包含引用值导致的继承问题,出现了“盗用构造函数”这一技术,其基本思路是:在子类构造函数中调用父类构造函数,因为函数是特定上下文执行代码的简单对象,所以可以使用apply()
和call()
方法以新创建的对象为上下文执行构造函数。
function SuperType() {
this.colors = ["red", "blue", "green"];
}
function SubType() {
// 继承 SuperType
SuperType.call(this);
}
let instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"
let instance2 = new SubType();
console.log(instance2.colors); // "red,blue,green"
2
3
4
5
6
7
8
9
10
11
12
该例中,通过使用call()(或apply())方法,SuperType构造函数在为SubType的实例创建的新对象的上下文中执行了,这相当于新的SubType对象上运行了SuperType()函数中所有初始化代码,这样每个实例都会有自己的colors属性。
# 传递参数
盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参。
function SuperType(name){
this.name = name;
}
function SubType() {
// 继承 SuperType 并传参
SuperType.call(this, "Nicholas"); //实际上会在SubType的实例上定义name属性
// 实例属性
this.age = 29;
}
let instance = new SubType();
console.log(instance.name); // "Nicholas";
console.log(instance.age); // 29
2
3
4
5
6
7
8
9
10
11
12
# 盗用构造函数的问题
盗用构造函数的主要缺点是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用(即不同实例上的函数虽然同名却不相等);此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。
# 原型式继承
该方式的基本思想是即使不自定义类型也可以通过原型实现对象之间的信息共享。适用于以下情况:你有一个对象,想在它的基础上再创建一个新对象,你需要把这个对象先传给object(),然后再对返回的对象进行适当修改。
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
let person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = object(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");
let yetAnotherPerson = object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");
console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
该例中,person对象定义了另一个对象也应该共享的信息,把它传给object()之后返回一个新对象,这个新对象的原型是person,意味着它的原型上既有原始值属性又有引用值属性。这里实际上克隆了两个person,object()对传入的对象执行了一次浅复制。
ES5通过增加Object.create()
方法将原型式继承的概念规范化了,该方法接收两个参数:作为新对象原型的对象,给新对象定义额外属性的对象(可选)。Object.create()的第二个参数与Object.defineProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述,以这种方式添加的属性会遮蔽原型对象上的同名属性。
原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合,但要记住,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。
# 组合继承
该方式将原型链和盗用构造函数两者的优点集中了起来,基本思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性
,这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。这是JavaScript使用最多的继承模式。
function SuperType(name){
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
console.log(this.name);
};
function SubType(name, age){
// 继承属性
SuperType.call(this, name);
this.age = age;
}
// 继承方法
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function() {
console.log(this.age);
};
let instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"
instance1.sayName(); // "Nicholas";
instance1.sayAge(); // 29
let instance2 = new SubType("Greg", 27);
console.log(instance2.colors); // "red,blue,green"
instance2.sayName(); // "Greg";
instance2.sayAge(); // 27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
组合继承也存在效率问题,主要的效率问题就是父类构造函数始终会被调用两次:一次是在创建子类原型时调用,另一次是在子类构造函数中调用。本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了。
# 寄生式组合继承
该方式通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。
function inheritPrototype(subType, superType) {
let prototype = object(superType.prototype); // 创建父类原型的一个副本
prototype.constructor = subType; // 解决由于重写原型导致默认constructor丢失问题
subType.prototype = prototype; // 将新创建的对象赋值给子类型的原型
}
function SuperType(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
console.log(this.name);
};
function SubType(name, age) {
SuperType.call(this, name);
this.age = age;
}
inheritPrototype(SubType, SuperType);
SubType.prototype.sayAge = function() {
console.log(this.age);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
这里只调用了一次SuperType构造函数,避免了SubType.prototype上不必要也用不到的属性,且原型链仍然保持不变,因此instanceof操作符和isPrototypeOf()方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式。
# 类
前面说道:ES6开始正式支持类和继承。不过ES6的类仅是封装了ES5.1构造函数加原型继承的语法糖而已。
# 类定义
定义类有两种主要方式:类声明和类表达式。
- class Person {}
- const Animal = class {}
注:与函数表达式类似,类表达式在它们被求值之前也不能引用,且类声明不能提升。
类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必须的,空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行,多数编程风格都建议类名的首字母大写。
类表达式的名称是可选的,如果写了名称(如下例的PersonName),类表达式赋值给变量后,可以通过name属性取得类表达式的名称字符串,但不能在类表达式作用域外部访问这个名称字符串。
let Person = class PersonName {
identify() {
console.log(Person.name, PersonName.name);
}
}
let p = new Person();
p.identify(); // PersonName PersonName
console.log(Person.name); // PersonName
console.log(PersonName); // ReferenceError: PersonName is not defined
2
3
4
5
6
7
8
9
# 类构造函数
使用constructor
关键字在类定义块内部创建类的构造函数。JavaScript解释器知道使用new和类意味着应该使用constructor函数进行实例化。使用new调用类的构造函数和通过构造函数模式创建对象执行的操作一致。
默认情况下,类构造函数会在执行之后返回this对象,该对象会被用作实例化的对象,如果没有什么引用新创建的this对象,那么这个对象会被销毁。如果返回的不是this对象,而是其他对象,则这个对象不会通过instanceof操作符检测出跟类有关联,因为这个对象的原型指针并没有修改。
class Person {
constructor(override) {
//构造函数内部的this被赋值为这个新对象
this.foo = 'foo';
if (override) {
return {
bar: 'bar'
};
}
}
}
let p1 = new Person(),
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
注:
- ES中类就是一种特殊函数
- 类标识符有prototype属性,而这个原型也有一个constructor属性指向类自身
- 类可以像其他对象或函数引用一样作为参数传递
- 与立即调用函数表达式,类也可以立即实例化:
let p = new class Foo{..}('bar');
# 实例、原型和类成员
每个实例都对应一个唯一的成员对象,这意味着所有成员不会在原型上共享。为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。
class Person {
constructor() {
// 添加到 this 的所有内容都会存在于不同的实例上
this.locate = () => console.log('instance');
}
// 在类块中定义的所有内容都会定义在类的原型上
locate() {
console.log('prototype');
}
}
let p = new Person();
p.locate(); // instance
Person.prototype.locate(); // prototype
2
3
4
5
6
7
8
9
10
11
12
13
注:可以把方法定义在类构造函数中或类块中,但不能在类块中给原型添加原始值或对象作为成员数据,如class Person{ name: 'Jake' }
。
类定义也支持获取和设置访问器:
class Person {
set name(newName) {
this.name_ = newName;
}
get name() {
return this.name_;
}
}
let p = new Person();
p.name = 'Jake';
console.log(p.name); // Jake
2
3
4
5
6
7
8
9
10
11
可以在类上定义静态方法,这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。静态类成员在类定义中使用static关键字,在静态成员中,this引用类本身。
class Person {
constructor() {
// 添加到 this 的所有内容都会存在于不同的实例上
this.locate = () => console.log('instance', this);
}
// 定义在类的原型对象上
locate() {
console.log('prototype', this);
}
// 定义在类本身上
static locate() {
console.log('class', this);
}
}
let p = new Person();
p.locate(); // instance, Person {}
Person.prototype.locate(); // prototype, {constructor: ... }
Person.locate(); // class, class Person {}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 继承
ES6新增特性中最出色的一个就是原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链。
# 继承基础
ES6类支持单继承,使用extends
关键字,可以继承任何拥有[[Construct]]和原型的对象。这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容)。
- 派生类会通过原型链访问到类和原型上定义的方法,this值会反映调用相应方法的实例或类。
let Bar=class extends Foo{}
也是有效语法。- 派生类的方法可以通过
super
关键字引用它们的原型,这个关键字只能在派生类中使用,要么用于调用父类构造函数,要么用于引用父类的静态方法。 - 在类构造函数中,不能在调用super()之前引用this。
- ES6给类构造函数和静态方法添加了内部特性[[HomeObject]],这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在JavaScript引擎内部访问。super始终会定义为[[HomeObject]]的原型。
- 可能需要定义这样一个类:它可供其他类继承,但本身不会被实例化。虽然ES没有专门支持这种类的语法,但通过
new.target
也很容易实现。new.target保存通过new关键字调用的类或函数,在实例化时检测new.target是不是抽象基类,可以阻止对抽象基类的实例化。 - 通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在调用类构造函数之前就已经存在了,所以可以通过 this 关键字来检查相应的方法。
// 抽象基类
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() {}
}
// 派生类
class Van extends Vehicle {}
new Bus(); // success!
new Van(); // Error: Inheriting class must define foo()
new Vehicle(); // Error: Vehicle cannot be directly instantiated
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 类混入
通过Object.assign()方法可以混入多个对象的属性,如果需要混入类的行为时需要自己实现混入表达式,混入模式可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会解析为一个可以被继承的类。如果Person类需要组合A、B、C,则需要某种机制实现B继承A,C继承B,而Person再继承C,从而把A、B、C 组合到这个超类中。
class Vehicle {}
let FooMixin = (Superclass) => class extends Superclass {
foo() {
console.log('foo');
}
};
let BarMixin = (Superclass) => class extends Superclass {
bar() {
console.log('bar');
}
};
let BazMixin = (Superclass) => class extends Superclass {
baz() {
console.log('baz');
}
};
function mix(BaseClass, ...Mixins) {
return Mixins.reduce((accumulator, current) => current(accumulator), BaseClass);
}
class Bus extends mix(Vehicle, FooMixin, BarMixin, BazMixin) {}
let b = new Bus();
b.foo(); // foo
b.bar(); // bar
b.baz(); // baz
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
很多JavaScript框架(特别是 React)已经抛弃混入模式,转向了组合模式(把方法 提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。这反映了那个众 所周知的软件设计原则:“组合胜过继承。”这个设计原则被很多人遵循,在代码设计中能提供极大的灵活性。