class 类
难度:⭐️⭐️⭐️⭐️
前几节深入讲解了如何只使用 ECMAScript 5 的特性来模拟类似于类(class-like)的行为。不难看出,各种策略都有自己的问题,也有相应的妥协。正因为如此,实现继承的代码也显得非常冗长和混乱。为解决这些问题,ECMAScript 6 新引入的 class 关键字具有正式定义类的能力。
💌 虽然 ECMAScript 6 类表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念(语法糖 🍭)。
类定义
与函数类型相似,定义类也有两种主要方式:类声明和类表达式。这两种方式都使用 class
关键字加大括号:
// 类声明
class Person {}
// 类表达式
const Animal = class {};
- 与函数表达式类似,类表达式在它们被求值前也不能引用。不过,与函数定义不同的是,虽然函数声明可以提升,但类定义不能:
console.log(FunctionExpression); // undefined
var FunctionExpression = function() {};
console.log(FunctionExpression); // function() {}
console.log(FunctionDeclaration); // FunctionDeclaration() {}
function FunctionDeclaration() {}
console.log(FunctionDeclaration); // FunctionDeclaration() {}
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 {}
- 另一个跟函数声明不同的地方是,函数受函数作用域限制,而类受块作用域限制:
{
function FunctionDeclaration() {}
class ClassDeclaration {}
}
console.log(FunctionDeclaration); // FunctionDeclaration() {}
console.log(ClassDeclaration); // ReferenceError: ClassDeclaration is not defined
类的构成
类可以包含构造函数方法、实例方法、获取&设置 函数和静态类方法。但这些都不是必需的,空的类定义照样有效。
// 空类定义,有效
class Foo {}
// 有构造函数的类,有效
class Bar {
constructor() {}
}
// 有获取函数的类,有效
class Baz {
get myBaz() {}
}
// 有静态方法的类,有效
class Qux {
static myQux() {}
}
类构造函数
constructor
关键字用于在类定义块内部创建类的构造函数。方法名 constructor
会告诉解释器在使用 new 操作符创建类的新实例时,应该调用这个函数。
构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。
实例化
使用 new
调用类的构造函数会执行如下操作(和构造函数一致):
- 在内存中创建一个新对象。
- 这个新对象内部的
__proto__
指针被赋值为构造函数的prototype
属性(接上原型链)。 - 构造函数内部的
this
被赋值为这个新对象(即this
指向新对象)。 - 执行构造函数内部的代码(给新对象添加属性)。
- 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
🟢 默认情况下,类构造函数会在执行之后返回 this
对象。
🟡 不过,如果返回的不是 this
对象,而是其他对象(构造函数内部返回的非空对象),那么这个对象不会通过 instanceof
操作符检测出跟类有关联,因为这个对象的原型指针并没有被修改。
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
) 作为内部对象。调用类构造函数时如果忘了使用 new
则会抛出错误:
function Person() {}
class Animal {}
// 把 window 作为 this 来构建实例
let p = Person();
let a = Animal();
// TypeError: class constructor Animal cannot be invoked without 'new'
把类当成特殊函数
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
实例、原型和类成员
类的语法可以非常方便地定义应该存在于实例上的成员、应该存在于原型上的成员,以及应该存在于类本身的成员。
实例成员
每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享:
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
原型方法与访问器
为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法:
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: 'Jake'
}
// Uncaught SyntaxError: Unexpected token
类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键:
const symbolKey = Symbol('symbolKey');
class Person {
stringKey() {
console.log('invoked stringKey');
}
[symbolKey]() {
console.log('invoked symbolKey');
}
['computed' + 'Key']() {
console.log('invoked computedKey');
}
}
let p = new Person();
p.stringKey(); // invoked stringKey
p[symbolKey](); // invoked symbolKey
p.computedKey(); // invoked computedKey
类定义也支持获取和设置访问器。语法与行为跟普通对象一样:
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
静态类方法
可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。
静态类成员在类定义中使用 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 {
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
继承
✨ ECMAScript 6 新增特性中最出色的一个就是原生支持了类继承机制。
虽然类继承使用的是新语法,但背后依旧使用的是原型链。
继承基础 extends
使用 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
构造函数 和 super()
派生类的方法可以通过 super
关键字引用它们的原型。这个关键字只能在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部。
在类构造函数中使用 super
可以调用父类构造函数。
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
时要注意几个问题:
super
只能在 派生类 构造函数和静态方法中使用:
class Vehicle {
constructor() {
super()
// SyntaxError: 'super' keyword unexpected
}
}
- 不能单独引用
super
关键字,要么用它调用构造函数,要么用它引用静态方法:
class Vehicle {}
class Bus extends Vehicle {
constructor() {
console.log(super);
// SyntaxError: 'super' keyword unexpected here
}
}
- 调用
super()
会调用父类构造函数,并将返回的实例赋值给this
:
class Vehicle {}
class Bus extends Vehicle {
constructor() {
super()
console.log(this instanceof Vehicle)
}
}
new Bus() // true
super()
的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入:
class Vehicle {
constructor(licensePlate) {
this.licensePlate = licensePlate
}
}
class Bus extends Vehicle {
constructor(licensePlate) {
super(licensePlate)
}
}
console.log(new Bus('1337H4X')) // Bus { licensePlate: '1337H4X' }
- 如果没有定义类构造函数,在实例化派生类时会调用
super()
,而且会传入所有传给派生类的参数:
class Vehicle {
constructor(licensePlate) {
this.licensePlate = licensePlate
}
}
class Bus extends Vehicle {}
console.log(new Bus('1337H4X')) // Bus { licensePlate: '1337H4X' }
- 在类构造函数中,不能在调用
super()
之前引用this
:
class Vehicle {}
class Bus extends Vehicle {
constructor() {
console.log(this)
}
}
new Bus()
// ReferenceError: Must call super constructor in derived class
// before accessing 'this' or returning from derived constructor
- 如果在派生类中显式定义了构造函数,则要么必须在其中调用
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
保存通过 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()
继承内置类型
ES6 类为继承内置引用类型提供了顺畅的机制,开发者可以方便地扩展内置类型:
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]
🎉 class 类 这部分的内容还是蛮多的,也比较重要。我现在复习 class 就看这篇文章,绝大多数(书中)知识点都囊括了,希望可以帮助到你~ ❤