深入理解ES6-类

大多面向对象的编程语言都支持类和类继承的特性,而JavaScript却不支持这些特性,直到ES6。本篇会先介绍ES5如何实现类似的类特性,然后介绍ES6中的类。

ES5中的近类结构

在ES5及早期版本中没有类的概念,最相近的思路是创建一个自定义类型:首先创建一个构造函数,然后定义另一个方法并赋值给构造函数的原型。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
function PersonType(name){
this.name = name;
}
PersonType.prototype.sayName = function(){
console.log(this.name);
};
var person = new PersonType("Nicholas");
person.sayName();
console.log(person instanceof PersonType); // true
console.log(person instanceof Object); // true

类的声明

基本的类声明语法

使用class关键字,紧跟着的是类的名字,其他部分的语法类似于对象字面量方法的简写形式,但不需要在类的各元素之间使用逗号分隔。请看下面这段简单的类声明代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class PersonClass{
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name);
}
}
let person = new PersonClass("Nicholas");
person.sayName();
console.log(person instanceof PersonClass); // true
console.log(person instanceof Object); // true
console.log(typeof PersonClass); // "function"
console.log(typeof PersonClass.prototype.sayName); // "function"

通过类声明语法定义PersonClass的行为与之前创建PersonType构造函数的过程相似,只是这里直接在类中通过特殊的constructor方法名来定义构造函数,且由于这种类使用简洁语法来定义的方法,因而不需要添加function关键字。除constructor外没有其他保留的方法名,所以可以尽情添加方法。

私有属性是实例中的属性,不会出现在原型上,且只能在类的构造函数或方法中创建,此例中的name就是一个私有属性。这里建议在构造函数中创建所有私有属性,从而只通过一处就可以控制类的所有私有属性。

有趣的是,类声明仅仅是基于已有自定义类型声明的语法糖。typeof PersonClass 最终返回的是”function”,所以PersonClass声明实际上创建了一个具有构造函数行为的函数。此例中的sayName()方法实际上是PersonClass.prototype上的一个方法。

为何使用类语法

  • 函数声明可以被提升,而类声明与let声明类似,不能被提升;真正执行声明语句之前,它们会一直存在于临时死区中。
  • 类声明中的所有代码将自动运行在严格模式下,而且无法强制让代码脱离严格模式执行。
  • 在自定义类型中,需要通过Object.defineProperty()方法手工指定某个方法为不可枚举;而在类中,所有方法都是不可枚举的。
  • 每个类都有一个名为[[Construct]]的内部方法,通过关键字new调用那些不含[[Construct]]的方法会导致程序抛出错误。
  • 使用除关键字new以外的方式调用类的构造函数会导致程序抛出错误。
  • 在类中修改类名会导致程序报错。

了解了这些差异之后,我们可以用除了类之外的语法为之前示例中的PersonClass声明编写等价的代码:

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
26
27
let PersonType2 = (function(){
"use strict";
const PersonType2 = function(name) {
// 确保通过关键字new来调用该函数
if(typeof new.target === "undefined"){
throw new Error("必须通过关键字new调用构造函数");
}
this.name = name;
};
Object.defineProperty(PersonType2.prototype, "sayName", {
value: function() {
// 确保不会通过关键字new调用该方法
if(typeof new.target !== "undefined"){
throw new Error("不可使用关键字new调用该方法");
}
console.log(this.name);
},
enumerable: false,
writable: true,
configurable: true
});
return PersonType2;
}());

类的名称只在类中为常量,所以尽管不能在类的方法中修改类名,但可以在外部修改。

类表达式

类和函数都有两种存在形式:声明形式和表达式形式。

1
2
3
4
5
6
7
8
9
let PersonClass = class {
constructor(name){
this.name = name;
}
sayName() {
console.log(this.name);
}
};

类声明和类表达式最重要的区别是:name 属性不同,匿名类表达式的 name 属性是一个空字符串,而类声明的 name 属性值为类名。

命名类表达式

上面示例中,定义的类是匿名的,其实类与函数一样,都可以定义为命名表达式。声明时,在关键字 class 后添加一个标识符即可定义为命名类表达式:

1
2
3
4
5
6
let PersonClass = class PersonClass2 {
}
console.log(typeof PersonClass); // "function"
console.log(typeof PersonClass2); // "undefined"

在此示例中,类表达式被命名为PersonClass2,由于标识符PersonClass2只存在于类定义中,因此它可以被用在像sayName()这样的方法中。而在类的外面是访问不到PersonClass2的。

作为一等公民的类

一等公民是指一个可以传入函数,可以从函数返回,并且可以赋值给变量的值。JavaScript函数是一等公民,ES6延续了这一传统,将类也设计为一等公民,允许通过多种方式使用类的特性。例如,可以将类作为参数传入函数:

1
2
3
4
5
6
7
8
9
10
11
function createObject(classDef){
return new classDef();
}
let obj = createObject(class {
sayHi() {
console.log("Hi!");
}
});
obj.sayHi();

类表达式还有另一种使用方式,通过立即调用类构造函数可以创建单例。例如:

1
2
3
4
5
6
7
8
9
10
let person = new class{
constructor(name){
this.name = name;
}
sayName() {
console.log(this.name);
}
}("Nicholas");

这里先创建一个匿名类表达式,然后立即执行。这种模式可以使用类语法创建单例,并且不会在作用域中暴露类的引用。

访问器属性

尽管应该在类构造函数中创建自己的属性,但是类也支持直接在原型上定义访问器属性。创建 getter 时,需要在关键字 get 后紧跟一个空格和相应的标识符;创建 setter 时,只需把关键字 get 替换为 set 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CustomHTMLElement {
constructor(element) {
this.element = element;
}
get html() {
return this.element.innerHTML;
}
set html(value) {
this.element.innerHTML = value;
}
}
var descriptor = Object.getOwnPropertyDescriptor(CustomHTMLElement.prototype, "html");
console.log("get" in descriptor); // true
console.log("set" in descriptor); // true

等同于这个示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let CustomHTMLElement = (function(){
"use strict";
const CustomHTMLElement = function(element){
if(typeof new.target === "undefined"){
throw new Error("必须通过关键字new 调用构造函数");
}
this.element = element;
};
Object.defineProperty(CustomHTMLElement.prototype, "html", {
enumerable: false,
configurable: true,
get: function(){
return this.element.innerHTML;
},
set: function(){
this.element.innerHTML = value;
}
});
return CustomHTMLElement;
})();

可计算成员名称

类和对象字面量还有更多相似之处,类方法和访问器属性也支持使用可计算名称,就像在对象字面量中一样,用方括号包裹一个表达式即可使用计算名称。

1
2
3
4
5
6
7
8
9
10
11
let methodName = "sayName";
class PersonClass {
constructor(name) {
this.name = name;
}
[methodName]() {
console.log(this.name);
}
}

生成器方法

1
2
3
4
5
6
7
8
9
10
class MyClass {
*createIterator() {
yield 1;
yield 2;
yield 3;
}
}
let instance = new MyClass();
let iterator = instance.createIterator();

这个例子只是简单定义了一个生成器方法,下面例子是给类定义了默认的迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Collection {
constructor() {
this.items = [];
}
*[Symbol.iterator]() {
yield *this.items.values();
}
}
let collection = new Collection();
collection.items.push(1);
collection.items.push(2);
for(let x of collection){
console.log(x);
}

代码中yeild * 表达式是委托给了Array.values()方法返回的迭代器。

静态成员

ES5中定义静态方法是直接将方法添加到构造函数中,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function PersonType(name){
this.name = name;
}
// 静态方法
PersonType.create = function(){
return new PersonType(name);
};
// 实例方法
PersonType.prototype.sayName = function(){
console.log(this.name);
};
var person = PersonType.create("Nicholas");

等价于

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class PersonClass {
constructor(name){
this.name = name;
}
// 等价于PersonType.prototype.sayName
sayName() {
console.log(this.name);
}
// 等价于PersonType.create
static create(name) {
return new PersonClass(name);
}
}

继承与派生类

在ES6之前,实现继承是一个不小的工作,如下:

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
26
27
function Rectangle(length, width) {
this.length = length;
this.width = width;
}
Rectangle.prototype.getArea = function() {
return this.length * this.width;
};
function Square(length) {
Rectangle.call(this, length, length);
}
Square.prototype = Object.create(Rectangle.prototype, {
constructor: {
value: Square,
enumerable: true,
writable: true,
configurable: true
}
});
var square = new Square(3);
console.log(square.getArea()); // 9
console.log(square instanceof Square); // true
console.log(square instanceof Rectangle); // true

Square继承自Rectangle,为了这样做,必须用一个创建自Rectangle.prototype的新对象重写Square.prototype并调用Rectangle.call()方法。

类的出现可以轻松地实现继承功能,使用熟悉的extends关键字可以指定类继承的函数。原型会自动调整,通过调用super()方法即可访问基类的构造函数。下面这段代码来实现同样的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Rectangle {
constructor(length, width) {
this.length = length;
this.width = width;
}
getArea() {
return this.length * this.width;
}
}
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
}

继承自其他类的类被称作派生类,如果在派生类中指定了构造函数则必须要调用super(), 如果不这样程序会报错。如果选择不使用构造函数,则当创建新的类实例时会自动调用super()并传入所有参数。举个例子,以下两个类完全相同:

1
2
3
4
5
6
7
8
9
class Square extends Rectangle {
// 没有构造函数
}
class Square extends Rectangle {
constructor(...args) {
super(...args);
}
}

使用super()小贴士

  • 只可在派生类的构造函数中使用super(),如果尝试在非派生类或函数中使用则会导致程序抛出异常。
  • 在构造函数中访问this之前一定要调用super(), 它负责初始化this,如果在调用super()之前尝试访问this会导致程序出错。
  • 如果不想调用super(),则唯一的方法是让类的构造函数放回一个对象。

类方法遮蔽

派生类中的方法总会遮蔽基类中的同名方法。

静态成员继承

如果基类有静态成员,那么这些静态成员在派生类中也可用。

派生自表达式的类

ES6最强大的一面或许是从表达式导出类的功能了。只要表达式可以被解析为一个函数并且具有[[Construct]]属性和原型,那么就可以用extends进行派生。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Rectangle(length, width) {
this.length = length;
this.width = width;
}
Rectangle.prototype.getArea = function() {
return this.length * this.width;
};
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
}

extends强大的功能使得类可以继承自任意类型的表达式,从而创造更多可能性,例如动态地确定类的继承目标。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Rectangle(length, width) {
this.length = length;
this.width = width;
}
Rectangle.prototype.getArea = function() {
return this.length * this.width;
};
function getBase() {
return Rectangle;
}
class Square extends getBase() {
constructor(length) {
super(length, length);
}
}

getBase()函数是类声明的一部分,直接调用后返回Rectangle,此示例实现的功能与之前的示例等价。由于可以动态确定使用哪个基类,因而可以创建不同的继承方法。例如,可以像这样创建mixin:

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
let SerializableMixin = {
serialize() {
return JSON.stringify(this);
}
};
let AreaMixin = {
getArea() {
return this.length * this.width;
}
};
function mixin(...mixins) {
var base = function() {};
Object.assign(base.prototype, ...mixins);
return base;
}
class Square extends mixin(AreaMixin, SerializableMixin) {
constructor(length) {
super();
this.length = length;
this.width = length;
}
}

Square继承了AreaMixin和SerializableMixin的方法,有点多继承的味道。

内建对象的继承

自JS数组诞生以来,开发者一直都希望通过继承的方式创建属于自己的特殊数组。在ES5及早期的版本中这几乎是不可能的,用传统的继承方式无法实现这样的功能。例如:

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
26
27
28
// 内建数组行为
var colors = [];
colors[0] = "red";
console.log(colors.length);
colors.length = 0;
console.log(colors[0]); // undefined
// 尝试通过ES5语法继承数组
function MyArray() {
Array.apply(this, arguments);
}
MyArray.prototype = Object.create(Array.prototype, {
constructor: {
value: MyArray,
writable: true,
enumerable: true,
configurable: true
}
});
var colors = new MyArray();
colors[0] = "red";
console.log(colors.length); // 0
colors.length = 0;
console.log(colors[0]); // "red"

这段代码最后console.log()的输出结果与预期不符,MyArray实例的length和数值型属性的行为与内建数组中的不一致,这是因为通过传统的JavaScript继承形式实现的数组继承没有从Array.apply()或原型赋值中继承相关功能。

ES6类语法的一个目标是支持内建对象的继承,ES6中的类继承模型与ES5会有不同,主要体现在两个方面。

在ES5的传统继承方式中,先由派生类型(MyArray)创建this的值,然后调用基类的构造函数。这意味着,this的值开始指向的是MyArray的实例,但是随后会被来自Array的其他属性所修饰。

ES6中的类继承则与之相反,先由基类创建this的值,然后派生类的构造函数再修饰这个值。所以一开始就可以通过this来访问基类的所有内建功能,然后再正确地接所有与之相关的功能。

1
2
3
4
5
6
7
8
9
10
class MyArray extends Array {
}
var colors = [];
colors[0] = "red";
console.log(colors.length); // 1
colors.length = 0;
console.log(colors[0]); // undefined

Symbol.species属性

内建对象继承的一个实用之处是,原本在内建对象中返回实例自身的方法将自动返回派生类的实例。所以,如果你有一个继承自Array的派生类MyArray,那么像slice()这样的方法也会返回一个MyArray的实例。

1
2
3
4
5
6
7
8
9
class MyArray extends Array {
}
let items = new MyArray(1, 2),
subitems = items.slice();
console.log(items instanceof MyArray); // true
console.log(subitems instanceof MyArray); // true

正常情况下,继承自Array的slice()方法应该返回Array的实例,但是这段代码slice()方法返回的是MyArray的实例。在浏览器引擎背后是通过Symbol.species属性实现这一行为。

Symbol.species被用于定义返回函数的静态访问器属性。被返回的函数是一个构造函数,每当要在实例方法中创建类的实例时必须使用这个构造函数。以下这些内建类型均已定义Symbol.species属性:

  • Array
  • ArrayBuffer
  • Map
  • Promise
  • RegExp
  • Set
  • Typed arrays

内建类型使用Symbol.species的方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class MyClass {
static get [Symbol.species]() {
return this;
}
constructor(value) {
this.value = value;
}
clone() {
return new this.constructor[Symbol.species](this.value);
}
}

调用this.constructor[Symbol.species]会返回MyClass。

如果派生类覆盖这个值会发生什么呢?

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
26
27
28
29
30
31
class MyClass {
static get [Symbol.species]() {
return this;
}
constructor(value) {
this.value = value;
}
clone() {
return new this.constructor[Symbol.species](this.value);
}
}
class MyClass1 extends MyClass {}
class MyClass2 extends MyClass {
static get [Symbol.species]() {
return MyClass;
}
}
let instance1 = new MyClass1("foo"),
clone1 = instance1.clone(),
instance2 = new MyClass2("bar"),
clone2 = instance2.clone();
console.log(clone1 instanceof MyClass); // true
console.log(clone1 instanceof MyClass1); // true
console.log(clone2 instanceof MyClass); // true
console.log(clone2 instanceof MyClass2); // false

上面示例MyClass2 覆盖了Symbol.species的值,使之返回MyClass,则当调用MyClass2实例的clone方法时,返回值是一个MyClass 的实例。通过Symbol.species 可以定义当派生类的方法返回实例时,应该返回的值得类型。

一般来说,只要想在类方法中调用this.constructor,就应该使用Symbol.species属性,从而让派生类重写返回类型。而且如果你正从一个已定义Symbol.species属性的类创建派生类,那么要确保使用那个值而不是使用构造函数。

在类的构造函数中使用new.target

new.target属性允许你检测函数或构造方法是否通过是通过new运算符被调用的。在通过new运算符被初始化的函数或构造方法中,new.target返回一个指向构造方法或函数的引用。在普通的函数调用中,new.target 的值是undefined。

一般情况下new.target等于类的构造函数,如下所示:

1
2
3
4
5
6
7
8
9
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle);
this.length = length;
this.width = width;
}
}
var obj = new Rectangle(3, 4); // 输出 true

但是其值有时也会不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle);
this.length = length;
this.width = width;
}
}
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
}
// new.target为Square
var obj = new Square(3); // 输出false

因为每个构造函数都可以根据自身被调用的方式改变自己的行为。

利用new.target可以实现“抽象类”,如下:

1
2
3
4
5
6
7
8
9
10
11
class Shape {
constructor() {
if(new.target === Shape) {
throw new Error("该类不能被实例化")
}
}
}
class Rectangle extends Shape {
}

在这个示例中每当new Shape()就会报错,但是可以作为基类来派生其它类。