第八章(二)
内容
- 继承
- 类
8.3 继承
8.3.1 原型链
ECMA-262 把原型链定义为ECMAScript 的主要继承方式。其基本思想就是通过原型继承多个引用类型的属性和方法。
构造函数、原型和实例的关系:
每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型
当一个原型对象是另一个类型的实例时,意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。这就是原型链的基本构想。
function FatherType() {
this.property = true;
}
FatherType.prototype.getFatherValue = function() {
return this.property;
};
function SonType() {
this.sonproperty = false;
}
// 继承SuperType
SonType.prototype = new FatherType();
let instance = new SonType();
console.log(instance.getFatherValue()); // true
如上所示,继承了父类的方法和属性。
因为定义父类时将property属性没有定义在原型对象中,所以父类的实例会拥有这个属性,所以这个属性会出现在子类的原型对象上;但由于getFatherValue()
这个方法定义在父类的原型对象上,所以父类实例中并不包含这个方法,所以子类原型对象上没有这个方法;
当在子类实例上调用上调用这个方法时,会先在子类实例instance上找,没找到后去子类的原型对象也就是父类实例上找,也没找到后,会去父类的原型对象上,最终调用成功。
关系图:
原型链扩展了前面描述的原型搜索机制。我们知道,在读取实例上的属性时,首先会在实例上搜索这个属性。如果没找到,则会继承搜索实例的原型。在通过原型链实现继承之后,搜索就可以继承向上,搜索原型的原型。
1. 默认原型
默认情况下,所有引用类型都继承自Object,这也是通过原型链实现的。任何函数的默认原型都是一个Object 的实例,这意味着这个实例有一个内部指针指向Object.prototype
。这也是为什么自定义类型能够继承包括toString()、valueOf()在内的所有默认方法的原因。
即所有函数的原型对象其实都是通过Object构造函数生成的,因此,这些原型对象都会有一个指针[[Prototype]]
指向Object构造函数的原型对象Object.prototype
,因此可以调用默认方法。
2. 原型与继承的关系
原型与实例的关系可以通过两种方式来确定。第一种方式是使用instanceof 操作符,如果一个实例的原型链中出现过相应的构造函数,则instanceof 返回true。
console.log(instance instanceof Object); // true
console.log(instance instanceof SuperType); // true
console.log(instance instanceof SubType); // true
确定这种关系的第二种方式是使用isPrototypeOf()方法。原型链中的每个原型都可以调用这个方法,如下例所示,只要原型链中包含这个原型,这个方法就返回true:
console.log(Object.prototype.isPrototypeOf(instance)); // true
console.log(SuperType.prototype.isPrototypeOf(instance)); // true
console.log(SubType.prototype.isPrototypeOf(instance)); // true
左边的原型对象出现在了括号中对象的原型链中时就返回true.
3. 关于方法
子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上。
即要在将父类的实例赋值给子类的原型对象后,子类原型对象添加的方法或覆盖的方法才能生效。
function GrandSon(){
};
// 先定义的同名方法
GrandSon.prototype.getFatherValue = function(){
return false;
}
// 再继承
GrandSon.prototype = new FatherType();
let g1 = new GrandSon();
console.log(g1.getFatherValue()); //true
如上所示,先定义的原型对象会随着继承变成另外一个完全不同的父类对象,之前定义的属性和方法都会丢失。所以需要先继承然后进行添加或者覆盖操作。
// 接着上面的代码
GrandSon.prototype.getFatherValue = function(){
return false;
}
// 成功覆盖
console.log(g1.getFatherValue()); //false
要注意这里是为子类的原型对象定义一个getFatherValue()
方法,原先因为子类并没有这个方法,调用时是通过原型链一层层找最终从父类原型对象处调用;现在子类有这个方法了就会直接找到这个方法,不会再随着原型链进行寻找了。
这个覆盖并不是将父类方法修改变成一个新的方法,而是从调用的优先级层面实现一个类似于屏蔽的效果。
还有一个要注意不能使用对象字面量来添加新方法,例如:
// 通过对象字面量添加新方法,这会导致继承的原型对象无效
SonType.prototype = {
getFatherValue() {
return this.property;
}
}
这种大括号方式代表一个对象,这个操作会将原型对象覆盖掉。继承关系和原型链就不复存在了。
4. 原型链的问题
问题1
原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型的实例。这意味着原先的实例属性摇身一变成为了原型属性。
通过引用值继承的属性,在修改时会传递影响到原本的原型对象以及所有继承的对象。
问题2
子类型在实例化时不能给父类型的构造函数传参(使用原型链的情况下)。
事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。再加上之前提到的原型中包含引用值的问题,就导致原型链基本不会被单独使用。
8.3.2 盗用构造函数
基本思路:在子类构造函数中调用父类构造函数。
因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用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"
会新建一个对象,生成的对象是独立的,修改并不会传递。
1. 传递参数
相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参。
function SuperType(name){
this.name = name;
}
function SubType() {
// 继承SuperType 并传参
SuperType.call(this, "Nicholas");
// 实例属性
this.age = 29;
}
let instance = new SubType();
console.log(instance.name); // "Nicholas";
console.log(instance.age); // 29
如上所示,先通过调用父类构造函数生成需要继承的属性,再添加自己独有的属性(上例为age属性)。
这里相当于将父类构造函数中包含的方法和属性传递给子类对象,生成的子类对象并不属于父类。
// 属于子类
console.log(instance instanceof SubType) // true
// 不属于父类
console.log(instance instanceof SuperType) // false
2. 盗用构造函数的问题
盗用构造函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用。
(应该是子类只会获得父类构造函数中设置的属性和方法。如果后续给父类补充方法,由于这个方法不在父类的构造函数中,因此这种方式生成的子类也就不会获得这个方法)
function SuperType(name){
this.name = name;
}
SuperType.prototype.getValue = function(){
return 123;
}
同时在构造函数外部想要为对象添加方法,只能通过原型对象添加,或者就是对每个实例单独进行添加。
此外,子类也不能访问父类原型上定义的方法(没有设置原型链),因此所有类型只能使用构造函数模式。
8.3.3 组合继承
组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。
既在子类构造函数中调用父类的构造函数,又将父类的一个实例赋值给子类的原型对象,这样生成的子类对象间属性值相互独立而且还可以通过原型链调用父类中的方法。
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);
};
8.3.4 原型式继承
出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返回这个临时类型的一个实例。
本质上,object()
是对传入的对象执行了一次浅复制,因此对引用值的修改会进行保留传递。
ECMAScript 5 通过增加Object.create()
方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。在只有一个参数时,Object.create()
与这里的object()
方法效果相同:
let person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = Object.create(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");
let yetAnotherPerson = Object.create(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");
console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"
Object.create()的第二个参数与Object.defineProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。比如:
let person = {
name: "Nicholas",
friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = Object.create(person, {
name: {
value: "Greg"
}
});
console.log(anotherPerson.name); // "Greg"
原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但要记住,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。
8.3.5 寄生式继承
类似于原型式继承。
寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,将指定对象作为返回对象的原型对象,为返回对象添加属性方法来增强。基本的寄生继承模式如下:
function createAnother(original){
let clone = object(original); // 通过调用函数创建一个新对象
clone.sayHi = function() { // 以某种方式增强这个对象
console.log("hi");
};
return clone; // 返回这个对象
}
返回的对象会拥有original的属性方法以及在函数中新添加的一个sayHi()
方法。
寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。object()函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用。
8.3.6 寄生式组合继承
组合继承存在效率问题。
注意:构造函数中的属性方法不会存在于对应的原型对象中,这也是这个方法的优点、
最主要的效率问题就是父类构造函数始终会被调用两次:
- 在是创建子类原型时调用,
- 在子类构造函数中调用。
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); // 第二次调用SuperType()
this.age = age;
}
SubType.prototype = new SuperType(); // 第一次调用SuperType()
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
console.log(this.age);
};
原型对象和实例在生成的时候都会有默认的属性,造成了浪费
寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。寄生式组合继承的基本模式如下所示:
function inheritPrototype(subType, superType) {
let prototype = object(superType.prototype); // 创建对象
prototype.constructor = subType; // 增强对象
subType.prototype = prototype; // 赋值对象
}
这个inheritPrototype()
函数实现了寄生式组合继承的核心逻辑。
使用父类的原型对象的副本赋值给子类的原型对象, 而不是将父类的一个实例赋值给子类原型对象,这样子类原型对象上就没有父类构造函数中的属性,这样在子类构造函数中调用父类构造函数生成的实例上的属性就不会与子类原型对象上的属性重复。
这样就使得生成的子类对象既有父类构造函数中的属性,又可以通过原型链获取父类原型对象上的属性。
如果是之前的写法的话,子类原型对象通过父类构造函数生成,本身会具有父类构造函数的属性也可以通过原型链访问父类原型对象上的属性。
并且子类构造函数中调用父类构造函数生成的子类实例,也会具有父类构造函数中的属性,与子类原型对象上的默认属性重复了,造成浪费。
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);
};
这里只调用了一次SuperType
构造函数,避免了SubType.prototype
上不必要也用不到的属性,因此可以说这个例子的效率更高。而且,原型链仍然保持不变,因此instanceof
操作符和isPrototypeOf()
方法正常有效。
继承总结:
- 原型链是将父类实例赋值给子类原型对象,因此可以访问父类构造函数中的属性和父类原型对象上的属性。但父类属性中的引用值会被子类所有对象共享。
- 构造函数式继承在子类构造函数中调用父类构造函数生成子类实例,因此无法访问父类原型对象上的属性,只能访问父类构造函数中的属性。
- 组合式继承调用父类构造函数生成子类实例,同时子类原型对象被赋值为父类实例。因此既可以访问父类构造函数中的属性,也可以访问父类原型对象上的属性。但子类原型对象和子类实例都会拥有父类构造函数中的属性,造成浪费。
- 原型式继承在构造函数中是将指定对象赋值给子类原型对象,因此可以访问指定对象的属性和其原型对象上的属性。引用值会被继承的对象共享,因为是通过浅复制生成的新对象。
- 寄生式继承是在构造函数中是将指定对象赋值给子类原型对象然后添加自己需要的方法来增强它最后返回这个对象,可以访问指定对象的属性和其原型对象上的属性。同样是浅复制,修改指定对象的引用值会传递到子类对象的原型对象上。
// 寄生式继承
function object(o) {
function F() {}
F.prototype = o;
return new F();
}
function createAnother(original){
let clone = object(original); // 通过调用函数创建一个新对象
clone.sayHi = function() { // 以某种方式增强这个对象
console.log("hi");
};
return clone; // 返回这个对象
}
person1 = {name: "Nicholas",
friends: ["Shelby", "Court", "Van"]}
let person2 = createAnother(person1);
person1.friends.pop();
console.log(person2.friends) // ["Shelby", "Court"]
- 寄生式组合继承的子类实例可以访问父类构造函数中的属性,也可以通过原型链访问父类原型对象上的属性。相比组合继承,子类原型对象不会都拥有父类构造函数中的属性,节约了空间。
8.4 类
8.4.1 类定义
定义类有两种主要方式:类声明和类表达式。这两种方式都使用class 关键字加大括号:
// 类声明
class Person {}
// 类表达式
const Animal = class {};
类声明不能提升,必须要声明赋值后才能引用。函数声明可以提升,但赋值无法提升。
console.log(ClassExpression); // undefined
var ClassExpression = class {};
console.log(ClassExpression); // class {}
console.log(ClassDeclaration); // ReferenceError: ClassDeclaration is not defined
class ClassDeclaration {}
console.log(ClassDeclaration); // class ClassDeclaration {}
上面第一部分不报错第二部分报错可以看出,是var声明的提升作用。第二部分报错可以看出类定义没有提升功能。
下面这个则表示了函数定义本身就具有提升作用。第一部分的结果是由于这是var的声明操作,所以只提升了声明没有提升赋值。
console.log(FunctionExpression); // undefined
var FunctionExpression = function() {};
console.log(FunctionExpression); // function() {}
console.log(FunctionDeclaration); // FunctionDeclaration() {}
function FunctionDeclaration() {}
console.log(FunctionDeclaration); // FunctionDeclaration() {}
类受块作用域限制:
{
function FunctionDeclaration() {}
class ClassDeclaration {}
}
console.log(FunctionDeclaration); // FunctionDeclaration() {}
console.log(ClassDeclaration); // ReferenceError: ClassDeclaration is not defined
类的构成
类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行。
与函数构造函数一样,多数编程风格都建议类名的首字母要大写,以区别于通过它创建的实例(比
如,通过class Foo {}创建实例foo):
// 空类定义,有效
class Foo {}
// 有构造函数的类,有效
class Bar {
constructor() {}
}
类表达式的名称是可选的。在把类表达式赋值给变量后,可以通过name 属性取得类表达式的名称字符串。但不能在类表达式作用域外部访问这个标识符。
不讲类表达式赋值给变量时,可以通过类表达式的名称来访问:
class PersonName {
identify() {
console.log(Person.name, PersonName.name);
}
}
console.log(PersonName)
/* class PersonName {
identify() {
console.log(Person.name, PersonName.name);
}
} */
若将类表达式赋值给变量后,就无法通过类名来访问:
let Person3 = class PersonName2 {
identify() {
console.log(Person3.name, PersonName2.name);
}
}
console.log(PersonName2)
/* Uncaught ReferenceError: PersonName2 is not defined
at <anonymous>:1:13 */
console.log(Person3)
/* class PersonName2 {
identify() {
console.log(Person3.name, PersonName2.name);
}
} */
相当于将类赋值给指定的变量了,而不赋值的情况下会将类默认赋值给类名代表的变量下。
8.4.2 类构造函数
constructor
关键字用于在类定义块内部创建类的构造函数。方法名constructor
会告诉解释器在使用new 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。
1. 实例化
使用new新建类的实例,就会调用constructor函数进行实例化,类的实例属于object类型,类的声明属于function类型。
class Animal {}
let a = new Animal();
console.log(typeof a) // object
console.log(typeof Animal) // function
class Person {
constructor() {
console.log('person ctor');
}
}
let p1 = new Person(); // person ctor
使用new 调用类的构造函数会执行如下操作。
- 在内存中创建一个新对象。
- 这个新对象内部的
[[Prototype]]
指针被赋值为构造函数的prototype
属性。 - 构造函数内部的
this
被赋值为这个新对象(即this 指向新对象)。 - 执行构造函数内部的代码(给新对象添加属性)。
- 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的:
class Person {
constructor(name) {
console.log(arguments.length);
this.name = name || null;
}
}
let p1 = new Person; // 0
console.log(p1.name); // null
let p2 = new Person(); // 0
console.log(p2.name); // null
let p3 = new Person('Jake'); // 1
console.log(p3.name); // Jake
即如果实例化时没有要传递的参数时,可以new类名后面不加括号。
如果返回的不是this 对象,而是其他对象,那么这个对象不会通过instanceof 操作符检测出跟类有关联,因为这个对象的原型指针并没有被修改。
即构造函数返回的如果不是this对象,则返回的对象不属于这个类。
class Person {
constructor(override) {
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
类构造函数与构造函数的主要区别是,调用类构造函数必须使用new 操作符。而普通构造函数如果不使用new 调用,那么就会以全局的this(通常是window)作为内部对象。
function test() {
this.ln = 'window';
}
let a1 = test();
console.log(window.ln) // window
console.log(typeof a1) // undefined
在上面的例子中,因为构造函数test没有返回值,所以a1被赋了空值undefined。this对象默认为window对象,因此会给window对象添加属性。
如果返回this对象的话,就会返回默认的this对象,也就是window对象。
function test() {
this.ln = 'window_new';
return this;
}
a1 = test();
console.log(a1===window) // true
类构造函数没有什么特殊之处,实例化之后,它会成为普通的实例方法(但作为类构造函数,仍然要使用new 调用)。因此,实例化之后可以在实例上引用它:
class Person {}
// 使用类创建一个新实例
let p1 = new Person();
p1.constructor();
// TypeError: Class constructor Person cannot be invoked without 'new'
// 使用对类构造函数的引用创建一个新实例
let p2 = new p1.constructor();
console.log(p2 instanceof Person) // true
并且调用实例上的构造函数创建的新的实例还是属于这个类的。
2. 把类当成特殊函数
ECMAScript 中没有正式的类这个类型。从各方面来看,ECMAScript 类就是一种特殊函数。声明一个类之后,通过typeof 操作符检测类标识符,表明它是一个函数:
class Person {}
console.log(Person); // class Person {}
console.log(typeof Person); // function
类标识符有prototype
属性(普通的构造函数也有这个属性指向原型对象),而这个原型也有一个constructor 属性指向类自身:
class Person{}
console.log(Person.prototype); // { constructor: f() }
console.log(Person === Person.prototype.constructor); // true
可以使用instanceof
操作符检查一个对象与类构造函数,以确定这个对象是不是类的实例。此时的类构造函数要使用类标识符。
console.log(p instanceof Person); // true
如前所述,类本身具有与普通构造函数一样的行为。在类的上下文中,类本身在使用new 调用时就会被当成构造函数。重点在于,类中定义的constructor 方法不会被当成构造函数,在对它使用instanceof 操作符时会返回false。但是,如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么instanceof 操作符的返回值会反转(根据你的调用方式会改变,就是看你怎么看待这个构造函数,是作为类中的一部分,还是单独拿出来作为一个普通的构造函数看待):
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.constructor();
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 {}
8.4.3 实例,原型和类成员
1. 实例成员
每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享:
class Person {
constructor() {
// 这个例子先使用对象包装类型定义一个字符串
// 为的是在下面测试两个对象的相等性
this.name = new String('Jack');
this.sayName = () => console.log(this.name);
this.nicknames = ['Jake', 'J-Dog']
}
}
let p1 = new Person(),
p2 = new Person();
p1.sayName(); // Jack
p2.sayName(); // Jack
console.log(p1.name === p2.name); // false
console.log(p1.sayName === p2.sayName); // false
console.log(p1.nicknames === p2.nicknames); // false
p1.name = p1.nicknames[0];
p2.name = p2.nicknames[1];
p1.sayName(); // Jake
p2.sayName(); // J-Dog
p1.nicknames.pop();
console.log(p2.nicknames) // ["Jake", "J-Dog"]
可以看出修改一个对象上的原始值或者引用值都不会导致另外一个对象的值发生变化,它们之间是独立的。
2. 原型方法与访问器
为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。即在类中定义的方法都会同时定义在这个类的原型对象上。
class Person {
constructor() {
// 添加到this 的所有内容都会存在于不同的实例上
this.locate = () => console.log('instance');
this.testfunc = function() {
console.log('You have this');
}
}
// 在类块中定义的所有内容都会定义在类的原型上
locate() {
console.log('prototype');
}
}
let p = new Person();
p.locate(); // instance
Person.prototype.locate(); // prototype
Person.prototype.testfunc(); // VM2431:1 Uncaught TypeError
即定义在类块中的方法会同时定义在类的原型对象上,但定义在类的构造函数中的方法不会定义在类的原型对象上(与之前继承那里类似)。
可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据:
class Person {
name: 'Jake'
}
// Uncaught SyntaxError: Unexpected token
类定义也支持获取和设置访问器。语法与行为跟普通对象一样:
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
3. 静态类方法
可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。即这些静态类方法只能通过类标识符(类名或者赋值的变量)来进行调用。
静态类成员在类定义中使用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 {}
如上所示,根据定义,三个调用的是是同名但内容不同的函数。
静态类方法非常适合作为实例工厂,如下所示,直接生成一个实例:
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_: ... }
4. 非函数类型和类成员
虽然类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加:
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
console.log(p1.greeting) // undefined
console.log(p1.name) // Jake
这种方式在类上添加的数据成员(greeting),实例上无法进行访问。
定义在原型对象上的数据成员,实例可以通过原型链进行访问,同样对引用值的修改会传递给其他类实例。
注意 类定义中之所以没有显式支持添加数据成员,是因为在共享目标(原型和类)上添加可变(可修改)数据成员是一种反模式。一般来说,对象实例应该独自拥有通过this 引用的数据
8.4.4 继承
ECMAScript 6 新增特性中最出色的一个就是原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链。
1. 继承基础
ES6 类支持单继承。使用extends
关键字,就可以继承任何拥有[[Construct]]
和原型的对象。很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容):
class Vehicle {}
// 继承类
class Bus extends Vehicle {}
let b = new Bus();
console.log(b instanceof Bus); // true
console.log(b instanceof Vehicle); // true
function Person() {}
// 继承普通构造函数
class Engineer extends Person {}
let e = new Engineer();
console.log(e instanceof Engineer); // true
console.log(e instanceof Person); // true
派生类都会通过原型链访问到类和原型上定义的方法。this 的值会反映调用相应方法的实例或者类:
class Vehicle {
identifyPrototype(id) {
console.log(id, this);
}
static identifyClass(id) {
console.log(id, this);
}
}
class Bus extends Vehicle {}
let v = new Vehicle();
let b = new Bus();
b.identifyPrototype('bus'); // bus, Bus {}
v.identifyPrototype('vehicle'); // vehicle, Vehicle {}
Bus.identifyClass('bus'); // bus, class Bus {}
Vehicle.identifyClass('vehicle'); // vehicle, class Vehicle {}
如上所示,函数内容是相同的,this会调用自己所在的这个实例或者类。
2. 构造函数、HomeObject
和super()
派生类的方法可以通过super
关键字引用它们的原型。这个关键字只能在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部。在类构造函数中使用super
可以调用父类构造函数。
调用super()会调用父类构造函数,并将返回的实例赋值给this。
class Vehicle {
constructor() {
this.hasEngine = true;
}
}
class Bus extends Vehicle {
constructor() {
// 不要在调用super()之前引用this,否则会抛出ReferenceError
super(); // 相当于super.constructor()
console.log(this instanceof Vehicle); // true
console.log(this); // Bus { hasEngine: true }
}
}
new Bus();
在静态方法中可以通过super 调用继承的类上定义的静态方法:
class Vehicle {
static identify() {
console.log('vehicle');
}
}
class Bus extends Vehicle {
static identify() {
super.identify();
}
}
Bus.identify(); // vehicle
使用super时注意的几个问题:P260
- 调用super()会调用父类构造函数,并将返回的实例赋值给this。
- super()调用时可以传递参数给父类构造函数。
- 子类没有定义构造函数时,在实例化派生类时会默认调用super(),并传入所有传给派生类的父类上的属性和方法(不仅仅是构造函数中的,类块中的都会传递给派生类)如下所示。
class Vehicle {
constructor(licensePlate) {
this.licensePlate = licensePlate;
}
test() {
console.log('OK')
}
}
class Bus extends Vehicle {}
let b = new Bus('aaaa');
console.log(b.test()) // OK
console.log(b.licensePlate) // aaaa
- 如果在派生类中显式定义了构造函数,则要么必须在其中调用super(),要么必须在其中返回一个对象。
3. 抽象基类
有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。
虽然ECMAScript 没有专门支持这种类的语法 ,但通过new.target 也很容易实现。new.target 保存通过new 关键字调用的类或函数。通过在实例化时检测new.target 是不是抽象基类,可以阻止对抽象基类的实例化:
// 抽象基类
class Vehicle {
constructor() {
console.log(new.target);
if (new.target === Vehicle) {
throw new Error('Vehicle cannot be directly instantiated');
}
}
}
// 派生类
class Bus extends Vehicle {}
new Bus(); // class Bus {}
new Vehicle(); // class Vehicle {}
// Error: Vehicle cannot be directly instantiated
另外,通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在调用类构造函数之前就已经存在了,所以可以通过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()
4. 继承内置类型
即可以继承语言内置的引用类型
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, 5);
console.log(a instanceof Array); // true
console.log(a instanceof SuperArray); // true
console.log(a); // [1, 2, 3, 4, 5]
a.shuffle();
console.log(a); // [3, 1, 4, 5, 2]
let a2 = a.filter(x => !!(x%2))
console.log(a2 instanceof SuperArray); // true
继承的内置类型的方法返回的实例也是属于新的类型的。
5. 类混入
看不懂
8.5 小结
对象在代码执行过程中的任何时候都可以被创建和增强,具有极大的动态性,并不是严格定义的实体。下面的模式适用于创建对象。
- 工厂模式就是一个简单的函数,这个函数可以创建对象,为它添加属性和方法,然后返回这个 对象。这个模式在构造函数模式出现后就很少用了。
- 使用构造函数模式可以自定义引用类型,可以使用new 关键字像创建内置类型实例一样创建自定义类型的实例。不过,构造函数模式也有不足,主要是其成员无法重用,包括函数。考虑到函数本身是松散的、弱类型的,没有理由让函数不能在多个对象实例间共享。
- 原型模式解决了成员共享的问题,只要是添加到构造函数prototype 上的属性和方法就可以共享。而组合构造函数和原型模式通过构造函数定义实例属性,通过原型定义共享的属性和方法。
JavaScript 的继承主要通过原型链来实现。原型链涉及把构造函数的原型赋值为另一个类型的实例。这样一来,子类就可以访问父类的所有属性和方法,就像基于类的继承那样。原型链的问题是所有继承的属性和方法都会在对象实例间共享,无法做到实例私有。盗用构造函数模式通过在子类构造函数中调用父类构造函数,可以避免这个问题。这样可以让每个实例继承的属性都是私有的,但要求类型只能通过构造函数模式来定义(因为子类不能访问父类原型上的方法)。目前最流行的继承模式是组合继承,
即通过原型链继承共享的属性和方法,通过盗用构造函数继承实例属性。除上述模式之外,还有以下几种继承模式。
- 原型式继承可以无须明确定义构造函数而实现继承,本质上是对给定对象执行浅复制。这种操 作的结果之后还可以再进一步增强。
- 与原型式继承紧密相关的是寄生式继承,即先基于一个对象创建一个新对象,然后再增强这个新对象,最后返回新对象。这个模式也被用在组合继承中,用于避免重复调用父类构造函数导 致的浪费。
- 寄生组合继承被认为是实现基于类型继承的最有效方式。
ECMAScript 6 新增的类很大程度上是基于既有原型机制的语法糖。类的语法让开发者可以优雅地定义向后兼容的类,既可以继承内置类型,也可以继承自定义类型。类有效地跨越了对象实例、对象原型和对象类之间的鸿沟。