跳到主要内容

在 JS 中, 使用基于原型的继承,与 Java 或 C++ 等强类型语言的非常不一样。

JS 一直允许定义类,ES6 新增了相关语法(比如 class 关键字)来方便创建类。新语法创建的类与老式的原理相同。

原型 & 构造函数

可以用 Object.create() 来根据一个原型创建对象。或者定义一个构造函数:

function Rectangle(width, height) {
this.width = width
this.height = height
}

Rectangle.prototype = {
perimeter() { return (this.width + this.height) * 2 },
area() { return this.width * this.height }
}

let r = new Rectangle(100, 50)
r.perimeter() // => 300
r.area() // => 5000

类名应当大写开头,属性方法均为小写开头。

在函数里可以使用 new.target 来判断函数是否以构造函数方式调用了(对于 class 创建的类,不允许不使用 new 来调用)。如果 new.targetundefined ,则说明函数是以普通形式调用,即非 new 调用。你可以在函数头添加这条代码来阻止非构造函数调用:

function C() {
if (!new.target) return new C()
/* 初始化代码 */
}

由于箭头函数没有 prototype 属性,且 this 值是从定义它们的上下文中继承的,所以不能作为构造函数去调用。

类标识 & instanceof

原型对象是类标识的基本,而构造函数不一定,因为两个不同的构造函数的原型可能指向同一个原型对象。在使用 instanceof 操作符时,实际是比较左操作数是否继承了右操作数的原型对象:

r instanceof Rectangle  // => true ,r 继承了 Rectangle.prototype 的原型
r instanceof Object // => true ,Object.prototype 也在 r 的原型链上

function Strange() {}
Strange.prototype = Rectangle.prototype
new Strange() instanceof Rectangle // => true

如果不想以(或者无法)构造函数作为媒介去识别类,可以用对象的 isPrototypeOf() 方法。

constructor 属性

除了箭头函数、生成器函数、异步函数,任何普通的 JS 函数都可以作为构造函数调用,构造函数调用需要一个 prototype 属性。每个普通定义的 JS 函数会自动拥有一个 prototype 属性,其中会有一个不可枚举的 constructor 属性,且该属性的值即为函数对象本身:

let F = function() {}
F === F.prototype.constructor // => true

let o = new F()
F === o.constructor // => true

除了箭头函数,ES6 及后的绑定函数也没有自己的 prototype 属性,但是作为构造函数时使用时,绑定函数会使用底层函数的 prototype 属性。

像上面的 Rectangle ,给其 prototype 重新指定了一个新对象,则原来的 constructor 属性也就不在了。我们需要手动添加该属性:

Rectangle.prototype = {
constructor: Rectangle,
/* 其他属性方法 */
}

也可以直接原地在原型对象上添加新的属性方法:

Rectangle.prototype.perimeter = function() { 
return (this.width + this.height) * 2
}
Rectangle.prototype.area = function() {
return this.width * this.height
}

使用 class 的类

JS 最早就支持类,但是 ES6 才有 class 关键字语法:

class Rectangle {
constructor(width, height) {
this.width = width
this.height = height
}
perimeter() { return (this.width + this.height) * 2 }
area() { return this.width * this.height }
}
  • 类使用 class 关键字声明;

  • 类体里使用对象字面量方法简写形式来定义方法,方法之间没有逗号;

  • constructor 来定义类的构造函数。如果该类不需要初始化属性,则可以省略 constructor ,JS 解释器会自动创建一个空构造函数。

如果要 继承 另一个类,可以用 extends 关键字:

class Square extends Rectangle {
constructor(length) {
super(length, length)
}
}

和函数一样,类也有 声明语句表达式 两种形式:

let Person = class { 
constructor(name) { this.name = name }
}
new Person("Talaxy").name // => "Talaxy"

与函数声明不同,类声明不会 "提升" 。且 class 声明内部默认处于严格模式。

静态方法

在方法声明前加上 static 关键字来表示静态方法,即类方法(而非对象方法):

class Line {
constructor(length) { this.length = length }
static compare(l1, l2) { return l1.length - l2.length }
}
let l1 = new Line(10), l2 = new Line(5)
Line.compare(l1, l2) // => 5

当然,也可以在类声明外追加定义:

class Line {
constructor(length) { this.length = length }
}
Line.compare = function(l1, l2) { return l1.length - l2.length }

获取方法 & 设置方法

和对象字面量中的一样。类声明里也可以用 get 和 set 定义获取方法和设置方法:

class Square {
constructor(length) { this.length = length }
get perimeter() { return this.length * 4 }
set perimeter(p) { this.length = p / 4 }
}
let s = new Square(10)
s.perimeter // => 40
s.perimeter = 12
s.length // => 3

公有、私有、静态字段

这里的 "字段" 与 "属性" 同义。

这些扩展类语法的标准化过程还在继续,( 2020 年初)Chrome 已经支持,Firefox 仅支持了公有实例字段。

公有实例字段对于使用 React 框架和 Babel 转译器的已经很常用了:

class Buffer {
size = 0
capacity = 4096
buffer = new Uint8Array(this.capacity)
}
let b = new Buffer()
b.capacity // => 4096

如果想定义为私有字段,即仅类内部可使用,可以在字段名前加上 # 前缀:

class Buffer {
#size = 0
get size() { return this.size }
}

而静态字段还在提案中,即在字段前使用 static 关键字。

通常,可以给方法名加个 _ 前缀表示内部方法。虽然 JS 规范没有定义这一语法。

扩展类的方法

JS 基于原型的继承机制是动态的,可以直接对原型对象添加方法:

class Rectangle {
constructor(width, height) {
this.width = width
this.height = height
}
area() { return this.width * this.height }
}

Rectangle.prototype.perimeter = function() {
return (this.width + this.height) * 2
}

给内置类型的原型添加方法通常被认为是不好的做法,因为未来的 JS 版本可能也会定义同名方法。同时,最好不要给 Object.prototype 添加方法,虽然这样所有的对象都会继承新方法,但在 for/in 循环中是可见的。

子类

在继承中,如果子类的方法覆盖了父类,子类的这个方法一般是要调用父类的。在子类的构造函数里通常必须调用父类的构造方法。

旧方式定义子类

在 ES6 之前,是通过设置子类的原型对象为父类来达到继承,比如这里子类 B 继承父类 A :

function A() {}
function B() {}

// 这里也可以赋值为 `new A()` ,区别在于是否需要 A 构造函数中定义的属性
B.prototype = Object.create(A.prototype)
// 上条语句把 B 原来的 constructor 属性冲掉了
B.prototype.constructor = B

// A 的原型对象是 B 的原型对象
A.prototype.isPrototypeOf(B.prototype) // => true

通过 extends 创建子类

class A {}
class B extends A {}

// B 原型继承了 A 原型,所以 B 的实例能调用 A 原型上的属性方法
A.prototype.isPrototypeOf(B.prototype) // => true
// B 类自身继承了 A 类的静态属性和方法
A.isPrototypeOf(B) // => true

在 class 类体里可以用 super 关键字来使用父类:

// 虽然 A 没有定义构造方法,但是 JS 解释器会自动创建一个空的构造方法
class A { sayHello() { return "Hello" } }

class B extends A {
// 在子类中的构造方法必须先调用父类构造方法,才能用 this 值
constructor(name) {
super()
this.name = name
}
sayHello() { return `${super.sayHello()}, ${this.name}!` }
}

new B("Talaxy").sayHello() // => "Hello, Talaxy!"

子类构造函数中必须调用父类构造函数。如果子类没有定义构造函数,解释器也会自动创建一个,然后把构造函数取得的值再传给 super()

class 类体里也可以用 new.target 获取被调用的构造函数。当子类使用 super() 调用父类构造函数时,父类构造函数可以通过 new.target 获取调用它的子类构造函数(但通常情况下不会用到)。

使用委托代替继承

如果一个类和另一个类有相似行为,可以用创建子类来继承行为。或者在这个类中创建另一个类的实例作为属性,并在需要时 委托 这个实例去做希望做的事,这通常会比继承更方便灵活。这种委托策略通常被称为 "组合" 。在面向对象编程中,开发者应当 "能组合就不继承" 。

抽象方法

虽然 JS 没有抽象类或抽象方法的相关语法及规范,但可以通过一些方法实现:

// 定义一个抽象的形状类
class Shape {
area() { throw new Error("Abstract method") }
}

class Square extends Shape {
constructor(side) {
super()
this.side = side
}
// 实现了抽象类中的 "抽象" 方法
area() { return this.side ** 2 }
}

new Square(4).area() // => 16

参考

new.target - MDN