0%

JavaScript 权威指南 09:类

可以将对象看成一种独特的属性集合,每个对象都不一样。但是多个对象经常需要共享一些属性,此时可以为这些对象定义一个类。这个类的实例,各自拥有属性来保存或定义自己的状态,也有方法定义它们的行为。这些方法是由类定义且由所有实例共享的。

在 JavaScript 中,类使用基于原型的继承。如果两个对象从同一个原型继承属性,那我们说这些对象是同一个类的实例。如果两个对象继承同一个原型,通常(但不必定)意味着它们是通过同一个构造函数或工厂函数创建和初始化的。

JavaScript 的类以及基于原型的继承机制与 Java 等语言中类和基于类的继承机制有着本质区别。

类和原型

在 JavaScript 中,类意味着一组对象从同一个原型对象继承属性。因此,原型对象是类的核心特征

  • Object.create 创建新对象时,这个新对象就继承了指定的原型对象
  • 如果我们定义了一个原型对象,然后使用 Object.create() 创建一个继承它的对象。那我们就定义了一个 JavaScript 类,并创建了它的实例

如下是一个实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function range(from, to) {
let r = Object.create(range.methods);

r.from = from;
r.to = to;

return r;
}

range.methods = {
includes(x) { return this.from <= x && x <= this.to; },

*[Symbol.iterator]() {
for (let x = Math.ceil(this.from); x <= this.to; x++) {
yield x;
}
},

toString() { return "(" + this.from + "..." + this.to + ")"; },
}

let r = range(1, 3);
console.log(r.includes(2)); // => 2;
console.log(r.toString()); // => "(1...3)"
console.log([...r]); // => [1, 2, 3]
  • 这段代码定义了一个工厂函数 range(),用于创建新的 Range 对象
  • 利用 range() 函数的 methods 属性保存定义这个类的原型对象。把原型对象放在这里没有什么特别的,也不是习惯写法
  • range() 函数为每个 Range 对象定义 from 和 to 属性。这两个属性是非共享、非继承属性,定义每个范围对象独有的状态
  • range.methods 对象使用了ES6定义方法的简写语法,所以没有出现 function 关键字
  • 原型的方法中有一个是计算的名字 Symbol.iterator ,这意味着它为 Range 对象定义了一个迭代器。这个方法的名字前面有一个星号 *,表示它是一个生成器函数,而非普通函数
  • 定义在 range.methods 中的共享方法,它们通过 this 关键字引用调用它们的对象。使用 this 是所有类方法的基本特征

类和构造函数

上面的示例展示了定义 JavaScript 类的简单方法。但这种方式并非习惯写法,因为它没有定义构造函数。构造函数是一种专门用于初始化新对象的函数。

  • 构造函数要使用 new 关键字调用
  • 使用 new 调用构造函数会自动创建新对象,因此构造函数本身只需要初始化新对象的状态
  • 构造函数调用的关键在于构造函数的 prototype 属性将被用作新对象的原型
  • 之前说过:几乎所有对象都有原型,但只有少数对象有 prototype 属性。现在可以明确,只有函数对象才有 prototype 属性
  • 因此同一个构造函数创建的所有对象都继承同一个对象,因而是同一个类的实例

如下修改上面的示例,它使用构造函数来创建 Range 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Range(from, to) {
this.from = from;
this.to = to;
}

Range.prototype = {
includes(x) { return this.from <= x && x <= this.to; },

*[Symbol.iterator]() {
for (let x = Math.ceil(this.from); x <= this.to; x++) {
yield x;
}
},

toString() { return "(" + this.from + "..." + this.to + ")"; },
}

let r = new Range(1, 3);
console.log(r.includes(2)); // => 2;
console.log(r.toString()); // => "(1...3)"
console.log([...r]); // => [1, 2, 3]

虽然 class 目前已经得到全面支持,但仍有很多老 JavaScript 代码是以这种方式来定义类的。因此我们应该熟悉这种习惯写法,以便理解老代码:

  • 因为构造函数在某种意义上是定义类的,而类名(按照惯例)应以大写字母开头。普通函数和方法的名字则以小写字母开头
  • 注意 Range() 构造函数是以 new 关键字调用的,
  • 新对象是在调用构造函数之前自动创建的,构造函数内部没有执行任何对象创建操作。
  • 构造函数内部通过 this 来初始化新对象中的独有属性(或者说类实例的独有属性)
  • 构造函数甚至不需要返回新创建的对象,它调用会自动创建新对象,并将构造函数作为该对象的方法来调用,然后返回新对象
  • 原型对象被保存在构造函数的 prototype 属性中,这个名字是强制性的。对构造函数的调用,会自动将其 prototype 属性作为新创建对象的原型

构造函数在编写时就会考虑它会作为构造函数以 new 关键字来调用,因此把它们当成普通函数来调用通常会有问题。在函数体内,可以通过一个特殊表达式 new.target 判断函数是否作为构造函数被调用了:

  • 如果该表达式的值有定义,就说明函数是作为构造函数、通过 new 关键字调用的
  • 如果 new.target 是 undefined,那么包含函数就是作为普通函数被调用的,没有使用 new 关键字

JavaScript 的各种错误构造函数可以不使用 new 调用,如果想在自己的构造函数中模拟这个特性,可以像下面这样编码:

1
2
3
4
function C() {
if (!new.target) return new C();
// 初始化代码
}

这个技术只适用于以这种老方式定义的构造函数。使用 class 关键字创建的类不允许不使用 new 调用它们的构造函数。

另外,需要注意,不要用箭头函数来定义构造函数或者方法。用箭头函数方式定义的函数没有 prototype 属性,因此不能作为构造函数使用。而且,箭头函数中的 this 是从定义它们的上下文继承的,不会根据调用它们的对象来动态设置。这样定义的方法就不能用了,因为方法的典型特点就是使用 this 引用调用它们的实例。ES6 新增的类语法直接不允许使用箭头函数定义方法。

构造函数、类标识和 instanceof

原型对象是类标识的基本:当且仅当两个对象继承同一个原型对象时,它们才是同一个类的实例。初始化新对象状态的构造函数不是基本标识,因为两个构造函数的 prototype 属性可能指向同一个原型对象,此时两个构造函数都可以用于创建同一个类的实例。

虽然构造函数不像原型那么基本,但构造函数充当类的外在表现。最明显的,构造函数的名字通常都用作类名。。在使用 instanceof 操作符测试类的成员关系时,构造函数是其右操作数。如果想测试对象 r 是不是Range对象,可以使用 r instanceof Range。而这背后的原理是:

  • 对于表达式 o instanceof C,如果 o 继承 C.prototype,则表达式求值为 true
  • 这里的继承不一定是直接继承,如果 o 继承的对象继承了 C.prototype,这个表达式仍然求值为 true。

因此 r instanceof Range 操作符并非检查 r 是否通过 Range 构造函数初始化,而是检查 r 是否继承 Range.prototype。例如:

1
2
3
4
5
6
7
function SRange() {
// void
}

SRange.prototype = Range.prototype;
let r = new Range(1, 3);
console.log(r instanceof SRange);

虽然 instanceof 不能验证使用的是哪个构造函数,但它仍然以构造函数作为其右操作数,因为构造函数是类的公共标识。

如果不想以构造函数作为媒介,直接测试某个对象原型链中是否包含指定原型,可以使用 isPrototypeOf() 方法。

constructor 属性

任何普通 JavaScript 函数(不包括箭头函数、生成器函数和异步函数)都可以用作构造函数,而构造函数调用需要一个 prototype 属性。为此,每个普通 JavaScript 函数自动拥有一个 prototype 属性。这个属性的值是一个对象,有一个不可枚举的 constructor 属性,而该属性的值就是该函数对象本身。

1
2
3
4
5
> let F = function() {};
> let p = F.prototype
> let c = p.constructor
> c === F
true

构造函数存在 prototype 对象,而这个 prototype 对象又存在一个 constructor 属性,这意味着通过构造函数创建的类实例也会继承该 constructor 属性。

1
2
3
> let oo = new F()
> oo.constructor === F
true

在我们的 Range 实现中,Range 类用自己的对象重写了预定义的 Range.prototype 对象。而它定义的这个新的原型对象并没有 constructor 属性。所以按照定义,Range 类的实例都没有 constructor 属性。为了解决这个问题,可以通过显式为原型添加一个 constructor 属性来解决:

1
2
3
4
5
Range.prototype = {
constructor: Range, // 显式设置反向引用 constructor

// 以下则是方法定义
}

另一个在老代码中常见的技术是直接使用预定义的原型对象及其 constructor 属性,然后为预定义的 prototype 对象添加方法:

1
2
3
// 扩展预定义的 Range.prototype 对象,不重写
Range.prototype.includes = function(x) { ... };
Range.prototype.toString = function() { ... };

使用 class 关键字的类

ES6 新增了 class 关键字,可以使用新的语法重写上述示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Range {
constructor(from, to) {
this.from = from;
this.to = to;
}

includes(x) { return this.from <= x && x <= this.to; }

*[Symbol.iterator]() {
for (let x = Math.ceil(this.from); x <= this.to; x++) {
yield x;
}
}

toString() { return "(" + this.from + "..." + this.to + ")"; }
}


let r = new Range(1, 3);
console.log(r.includes(2)); // => 2;
console.log(r.toString()); // => "(1...3)"
console.log([...r]); // => [1, 2, 3]

新增 class 关键字并未改变 JavaScript 类基于原型的本质,但得到的 Range 对象是一个构造函数。新的 class 语法虽然明确、方便,但最好把它看成之前的更基础的类定义机制的 语法糖”

1
console.log(typeof Range) // => "function"
  • 类是以 class 关键字声明的,后面跟着类名和花括号中的类体
  • 类体包含使用对象字面量方法简写形式定义的方法,因此省略了 function 关键字
  • 方法之间没有逗号(毕竟不是对象字面量语法)。特别地,类体中不支持名/值对形式的属性定义
  • 关键字 constructor 用于定义类的构造函数。但实际定义的函数并不叫 constructor​。class 声明语句会定义一个新变量 Range,并将这个特殊构造函数的值赋给该变量
  • 如果类不需要任何初始化,可以省略 constructor 关键字及其方法体,解释器隐式为你创建一个空构造函数

如果想定义一个继承另一个类的类,可以使用 extends 关键字和 class 关键字:

1
2
3
4
5
6
7
8
9
class Span extends Range {
constructor(start, length) {
if (length >= 0) {
super(start, start + length);
} else {
super(start + length, start);
}
}
}

与函数定义类似,类声明也有语句和表达式两种形式:

1
2
3
> let Square = class { constructor(x) { this.area = x * x; } };
> new Square(3).area
9
  • 与函数定义表达式一样,类定义表达式也可以包含可选的类名。如果提供了名字,则该名字只能在类体内部访问到
  • 在 JavaScript 编程中,除非需要写一个以类作为参数且返回其子类的函数,否则类定义表达式并不常用

另外需要注意:

  • 即使没有出现 “use strict” 指令,class 声明体中的所有代码默认也处于严格模式
  • 与函数声明不同,类声明不会 提升。不能在声明类之前实例化类的对象

静态方法

在 class 体中,把static关键字放在方法声明前面可以定义静态方法。静态方法是作为构造函数而非原型对象的属性定义

1
2
3
4
5
6
7
8
9
10
> class Test{ test1() {}; static test2() {} }

> Test.prototype.test1
[Function: test1]
> Test.prototype.test2
undefined
> Test.test1
undefined
> Test.test2
[Function: test2]

可以看到,静态函数 test2 是 Test 构造函数的的属性(即 Test.test2),而实例方法 test1 是 Test.prototype 的属性(即 Test.prototype.test1)。

因此对于静态函数,必须通过构造函数而非实例来调用它:

1
Test.test2();

有人也把静态方法称为类方法,因为它们要通过类(构造函数)名调用。这么叫是为了区分类方法和在类实例上调用的普通实例方法。由于静态方法是在构造函数而非实例上调用的,所以在静态方法中使用 this 关键字没什么意义。

获取方法、设置方法及其他形式的方法

在 class 体内,可以像在对象字面量中一样定义获取方法和设置方法。一般来说,对象字面量支持的 所有简写的方法定义语法 都可以在类体中使用。这包括生成器方法(带*)和名字为方括号中表达式值的方法。

公有、私有和静态字段

ES6 标准只允许在 class 体内定义方法(包括获取方法、设置方法和生成器)和静态方法,还没有定义字段的语法。如果想在类实例上定义字段(这只是面向对象的“属性”的同义词)​,必须在构造函数或某个方法中定义。如果想定义类的静态字段,必须在类体之外,在定义类之后定义。

例如如下展示了一个类的静态字段的简单示例:

1
2
3
4
5
6
> class Test{ constructor(v) { this.v = v }; plusOne() { return Test.ONE + this.v; } }
> Test.ONE = 1;

> let t = new Test(10)
> t.plusOne()
11

不过,扩展类语法以支持定义实例和静态字段的标准化过程还在继续。如下是将来可能标准化的语法:

1
2
3
4
5
6
7
class Buffer {
size = 0;
capacity = 4096;
}

let b = new Buffer();
console.log(b.capacity);

字段初始化的代码从构造函数中挪了出来,直接写在了类体内(当然,这些代码仍然作为构造函数的一部分运行。如果没有定义构造函数,这些字段则作为隐式创建的构造函数的一部分被初始化)​。

试图标准化这些实例字段的同一提案也定义了私有实例字段。如果像前面示例中那样使用实例字段初始化语法,但字段名前面加上 #,则该字段就只能在类体中使用,对类体外部的任何代码都不可见、不可访问(因而无法修改)​。

1
2
3
4
5
6
7
8
9
10
# more buffer.js
class Buffer {
#size = 16;
capacity = 4096;

get size() { return this.#size; }
}

let b = new Buffer();
console.log(b.size);

还有一个相关提案希望将在字段前使用 static 关键字标准化。根据这份提案,如果在公有或私有字段声明前加上 static,这些字段就会被创建为构造函数的属性,而非实例属性。

为已有类添加方法

JavaScript 基于原型的继承机制是动态的。换句话说,对象从它的原型继承属性,如果在创建对象之后修改了原型的属性,对象会继承修改后的属性。这意味着只要给原型对象添加方法,就可以增强 JavaScript 类。

内置 JavaScript 类的原型对象也一样是开放的,如下是一个示例:

1
2
3
4
5
6
7
8
9
10
Number.prototype.times = function(f, context) {
let n = this.valueOf();

for (let i = 0; i < n; i++) {
f.call(context, i);
}
}

let n = 3;
n.times(i => { console.log(`hello ${i}`); })

像这样给内置类型的原型添加方法通常被认为是不好的做法。因为如果 JavaScript 未来某个新版本也定义了同名方法,就会导致困惑和兼容性问题。

子类

在面向对象编程中,类 B 可以扩展或子类化类 A。此时我们说 A 是父类,B 是子类:

  • B 的实例继承 A 的方法
  • 类 B 也可以定义自己的方法,其中有些方法可能覆盖类 A 的同名方法
  • 当 B 覆盖 A 的方法实现时,经常需要调用 A 中被覆盖的方法
  • 类似地,子类构造函数 B() 通常必须调用父类构造函数 A() 才能将实例完全初始化

子类及原型

如下展示了 ES6 之前的定义子类的旧方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Span(start, span) {
if (span >= 0) {
this.from = start;
this.to = start + span;
} else {
this.to = start;
this.from = start + span;
}
}

Span.prototype = Object.create(Range.prototype);

Span.prototype.constructor = Span;

Span.prototype.toString = function() {
return `(${this.from})... +${this.to - this.from}`
}

为了让 Span 成为 Range的子类,需要让 Span.prototype 继承 Range.prototype

健壮的子类化机制应该允许类调用父类的方法和构造函数,但在 ES6 之前,JavaScript中没有简单的办法做这些。ES6 通过 super 关键字作为 class 语法的一部分解决了这个问题。

在 ES6 及以后,要继承父类,可以简单地在类声明中加上一个 extends 子句,甚至对内置的类也可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
class EZArray extends Array {
get first() { return this[0]; }
get last() { return this[this.length - 1]; }
}

let a = new EZArray();
console.log(a instanceof EZArray);
console.log(a instanceof Array);
a.push(1, 2, 3, 4);
console.log(a) // [1, 2, 3, 4]
console.log(a.first) // 1
console.log(EZArray.isArray(a)) // true

子类实例不仅继承了 Array 的 push 等实例方法,子类本身也继承了 Array.isArray 等静态方法。这是 ES6 类语法带来的新特性:EZArray() 是个函数,但它继承 Array()

1
2
3
4
5
6
7
// EZArray.prototype 继承自 Array.prototype
// 这是为什么 EZArray 的实例能够继承 Array 的实例方法
console.log(Array.prototype.isPrototypeOf(EZArray.prototype)) // => true

// EZArray 继承自 Array,这是 extends 关键字所独有的特性,在 ES6 之前做不到
// 因此 EZArray 继承了 Array 的静态方法和属性
console.log(Array.isPrototypeOf(EZArray)) // => true;

我们可以使用 super 关键字调用父类构造函数和方法,此时需要知道:

  • 如果使用 extends 关键字定义了一个类,那么这个类的构造函数必须使用 super() 调用父类构造函数
  • 如果没有在子类中定义构造函数,解释器会自动为你创建一个。这个隐式定义的构造函数会取得传给它的值,然后把这些值再传给 super()
  • 在通过 super() 调用父类构造函数之前,不能在构造函数中使用 this 关键字。这条强制规则是为了确保父类先于子类得到初始化
  • 在没有使用 new 关键字调用的函数中,特殊表达式 new.target 的值是 undefined。而在构造函数中, new.target 引用的是被调用的构造函数。当子类构造函数被调用并使用 super() 调用父类构造函数时,该父类构造函数通过 new.target 可以获取子类构造函数
  • 覆盖父类方法的方法不一定调用父类的方法,如果确实需要调用,可以通过 super.XXX() 来实现

委托而不是继承

使用 extends 关键字可以轻松地创建子类。但这并不意味就应该创建很多子类。如果你写了一个类,这个类与另一类有相同的行为,可以通过创建子类来继承该行为。但是,在你的类中创建另一个类的实例,并在需要时委托该实例去做你希望的事反而更方便,也更灵活

这时候,不需要创建一个类的子类,只要包装或组合其他类即可。这种委托策略常常被称为 组合,也是面向对象编程领域奉行的一个准则,即开发者应该 能组合就不继承

类层次与抽象类

使用 JavaScript 类封装数据和组织代码通常是个不错的技术,因此你可能会经常使用 class 关键字。但是,你可能会发现自己更喜欢组合而不是继承,因而几乎不会用到 extends。

然而,确实也存在需要多级子类的情况,其中抽象类表示 不包含完整实现的类。抽象父类可以定义部分实现,供所有子类继承和共享。子类只需实现父类定义(但未实现)的抽象方法。JavaScript 没有正式定义抽象方法或抽象类的语法。这里只是借用它们来指代未实现的方法和未完全实现的类。

例如如下就是一个抽象类:

1
2
3
class Abstract {
has(x) { throw new Error("Not implemented"); }
}