类(Class)是用于创建对象的模板,他们用代码封装数据以处理该数据,是面向对象编程方法的重要特性之一。JavaScript 中的 class
语法在 ES6 中引入,其底层实现基于原型(Prototype),系原型继承的语法糖(Syntactic Sugar)。
本博文将探讨 JavaScript 中如何使用类的相关知识,文章组织架构和内容基于 MDN 上关于类的章节。
定义类
类可以被看作一种“特殊的函数”,和函数的定义方法一样,类的定义方法有两种:类声明和类表达式。
第一种方法是,直接使用 class
关键字声明类,即类声明的方法。
1 | class User { |
但是,与函数声明不同的是,使用类声明的方式不会提升。这意味着必须先声明类,再使用它。
1 | const u = new User() // Uncaught ReferenceError: User is not defined |
另一种方法是,将 class
声明的类赋值给变量,即类表达式的方法。类表达式可以命名或匿名,其中,命名类表达式的名称(类的 name
属性)是该类体的局部名称。
1 | // 匿名类 |
同样,使用类表达式的方式也不会提升。
定义类之后,就可以使用 new
关键字实例化类了。
1 | const u = new User() |
构造函数
constructor()
方法或构造函数,是用于创建和初始化一个由 class
创建的对象的特殊方法,一个类只能拥有一个 constructor()
方法。
如果一个类中有构造函数,那么执行 new
创建实例时,将调用这个构造函数。
1 | class User { |
对于 new
创建实例时的每个参数,将依次赋值给构造函数。多余的参数将被忽略。
特别的,constructor()
方法中可以使用 super
关键字调用父类的 constructor()
方法。
1 | class User { |
原型方法
在类体中可以声明函数方法。从底层实现来看,这些方法将会在对象的原型链上定义出来,故称作原型方法。
1 | class Rectangle { |
上面的类中定义计算当前面积的方法 calcArea()
时,使用了 ES6 引入的更简短的定义语法,这种语法与 Setter 和 Getter 的语法相似,它直接将方法名赋值给了函数。
此外,由于 Setter 的特性,当我们在构造函数执行赋值操作,以及之后修改实例的属性时,将调用 Setter 的方法(即 Hook 函数)。因此在上面代码中的 rec
实例中,并不存在 height
和 width
属性,取而代之的是 _height
和 _width
属性。
静态方法和属性
在类的方法前面添加关键字 static
可以定义静态方法或静态属性,它们可以通过类直接调用,但不能通过类的实例调用。静态方法和静态属性常用于为一个使用类的应用程序创建工具函数。
1 | class Point { |
上面的代码中,当我们使用实例访问静态方法和属性时,会显示 undefined
。而当我们使用类来访问时,则能正常调用了。
原型方法和静态方法中的 this
当调用静态或原型方法时没有指定 this
的值,那么方法内的 this
值将被置为 undefined
。这是因为 class
内部的代码总是在严格模式下执行。
1 | class MyClass { |
作为对比,将上面的代码使用传统的基于函数的语法实现,在非严格模式下,若 this
的初值没有指定,则会被置为全局对象。
1 | function MyClass() {} |
生成器方法
生成器函数使用 function*
语法定义,例如 function* anyGenerator() {}
。而在类中,使用了更简短的定义语法,应将符号 *
放在方法名的前面,例如 *anyGenerator() {}
。
1 | class Polygon { // 定义五角形类 |
关于生成器的更多介绍可参考此页面。
箭头函数定义方法
类中还有另外一种常见的定义方法的方式:使用箭头函数。
1 | class Rectangle { |
特别的,箭头函数不会创建自己的 this
,而是从自己的作用域链的上一层继承 this
;子类继承父类的箭头函数定义的方法时,会出现属性遮蔽(Property Shadowing)的现象。对于后者,编写代码如下:
1 | class Father { |
上面的代码并没有像我们预想的那样,依次打印出 I am your father.
和 I am a chird.
,而是只打印出了 I am your father.
。
简单解释原因的话就是,箭头函数会挂到实例的属性上,而普通函数则是定义在原型链上。在 Chird
类中定义的 sayHello()
方法放到了原型链上,而从自己的父类 Father
继承的 sayHello()
方法挂载到了属性上。因此,当我们调用实例上的 sayHello()
方法时,优先从实例的属性上查找是否存在该方法(是的,在这里我们已经找到它了),如果存在则直接调用,如果不存在再在原型链上查找。
详细内容可以参考这篇博客。
在类中,对于直接使用 =
的声明,从本质上而言就是 Field Declarations 的语法,相当于直接声明了一个实例的属性。在接下来的字段声明小节中,也使用到了这个语法。
字段声明
在目前(2021 年 5 月),公共和私有字段声明仍是 JavaScript 标准委员会 TC39 提出的实验性功能(第 3 阶段)。浏览器中的支持是有限的,但是可以通过 Babel 等系统构建后使用此功能。
公有字段声明
在类中可以声明公有字段,使得类定义具有自我记录性,且这些字段将始终存在。字段的声明可以设置初始值。
1 | class Point { |
私有字段声明
在声明的字段前面加上 #
表明为私有字段。私有字段同样可以设置初始值。
1 | class Point { |
与公有字段不同的是:
- 不能从类外部引用私有字段。或私有字段在类外部不可见。
- 私有字段仅能在字段声明中预先定义。
- 在实例创建之后,不能再通过赋值来创建私有字段。
1 | class Point { |
在上面的代码中,我们尝试在类中不显式声明私有字段 #z
的情况下,访问 #z
,结果会抛出 SyntaxError
。此外,我们尝试在实例中直接对私有字段 #x
进行赋值和获取操作,也会抛出 SyntaxError
。
使用 extends
拓展子类
extends
可以用来创建子类,父类可以是自己定义的普通类,也可以是内建对象。对于后者,以继承内建的 Date
对象为例:
1 | class MyDate extends Date { |
类不过是一种语法糖,因此我们也可以用 extends
来继承传统的基于函数的“类”:
1 | function Animal (name) { // 定义 Animal “类” |
对于不可构造的常规对象,要实现继承的话,可以使用 Object.setPrototypeOf()
方法,它可以设置一个指定对象的原型到另一个对象:
1 | const Animal = { // 定义 Animal 对象 |
出于性能考量,应避免使用 Object.setPrototypeOf()
方法来实现继承,在这里了解它的更多。
使用 super
调用超类
使用 super
关键字可以调用对象的父对象上的函数。
1 | class Cat { |
假如我们将上面代码中 Lion
类里的 speak()
方法删去,那么打印的结果是 Li: meo~~!
。如果认真学到这里的话,原因想必也已经了然于胸:子类继承了父类的属性和方法。那么当子类定义了与父类相同名字的方法时,根据原型链上的调用规则,会调用子类定义的方法。这就是为什么我们需要 super
关键字的原因之一,方法名相同的情况下,在子类方法中我们仍可以调用父类的方法。
在构造函数中,super()
需要在使用 this
前调用:
1 | class Rectangle { |
super
也可以用来调用父类的静态方法:
1 | class Rectangle { |
在上面的代码中,Square
中的静态方法 help()
调用了父类的静态方法。静态方法中的 super
只能调用父类的静态方法,假如我们去除子类或父类方法前面的 static
关键字,会发生报错。
在本章节的例子中,似乎子类方法中的 super
都调用了父类中与之同名的方法,但实际上并没有这个限制,在编写的时候可以根据实际的需求自行调整命名或调用其它父类方法。
在箭头函数的使用章节的例子中,既然箭头函数定义的方法挂载到了实例的属性上,那么还能用 super
来调用吗?答案是否定的。JavaScript 没能在父对象的原型链上找到这个方法,于是什么也没有发生。
更多补充可以查阅 MDN 上关于 super
的介绍。
使用 Symbol.species
覆盖构造函数
Symbol.species
访问器属性允许子类覆盖对象的默认构造函数。
读着很拗口,那就看两个实际的例子。当使用 map()
这样的方法会返回默认的构造函数,我们可能想在对拓展的数组类 MyArray
执行操作时返回 Array
对象,那么可以这样编写代码:
1 | class MyArray extends Array { |
又例如,我们拓展 Promise
类为 TimeoutPromise
类,但我们不希望某一个超时的 Promise 请求影响整个 Promise 链,就可以使用 Symbol.species
来告诉 TimeoutPromise
类返回一个 Promise
对象,方便我们执行异常处理操作:
1 | class TimeoutPromise extends Promise { |
Symbol.species
允许自定义返回的类,不一定是子类继承实现的类。
Symbol.species
帮助我们在处理子类实例时,能够有一套标准的操作流程,方便了开发,在某些场景十分实用。
使用 Mix-ins 实现多重继承
在 ECMAScript 中,一个类只能有一个单超类,因此想通过工具类的方法实现多重继承行为是不可能的。为了实现多重继承,我们可以使用 Mixin 的方法。
什么是 Mixin?简单来说,Mixin 也是一个类,包括了一些方法,这些方法可以被其它类使用。但在其它类中使用这些方法不需要继承 Mixin。举一个简单的例子:
1 | let sayHiMixin = { // Mixin |
我们又知道,创建类的两种声明方式是等价的:
1 | class Mixin1 { |
其中,第二种方式,或者说使用类表达式声明类的方式,允许我们动态生成自定义的类。根据这个特性,我们就可以编写 Mixin 代码来实现多重继承了:
1 | class Animal { // 共同工具类 |
在上面的代码中,我们首先定义了一个通用的工具类 Animal
,其它 Mixin 类可能会用到这个工具类。接着我们定义了猫猫和狗狗使用的工具类 CatMixin
与 DogMixin
的创建规则,它们将传入的参数作为自己的父类,并创建一个新的类。最后,我们定义了想要的 MyMixin
类,它继承了 CatMixin(DogMixin(Animal))
类。从实现的角度来看,相当于执行了下面的操作:
1 | class DogMixin extends Animal { |
实际上,我们并没有让 CatMixin
类去继承 DogMixin
类,而是使用了 Mixin 的思想,让 MyMixin
继承了我们基于类表达式创建的一个新的类,实现了多重继承。
参考资料
本博文仅且记录了 JavaScript 中类在语法上的知识和使用,和少量的实现原理。关于底层的具体实现,就放到以后再深入探讨学习吧。
技术博文
- JavaScript或ES6如何实现多继承总结【Mixin混合继承模式】, 2020-09-18
- ES6 Class Methods 定义方式的差异, 2018-06-25
- [学习es6]setter/getter探究, 2016-11-02
- Metaprogramming in ES6: Symbols and why they’re awesome, 2015-06-18
其它资料
主要参考了 MDN 上关于类和相关内容的描述。