ES6:类
从本质上说,ES6的classes主要是给创建老式构造函数提供了一种更加方便的语法,并不是什么新魔法 —— Axel Rauschmayer,Exploring ES6作者
从功能上来讲,class声明就是一个语法糖,它只是比我们之前一直使用的基于原型的行为委托功能更强大一点。本文将从新语法与原型的关系入手,仔细研究ES2015的class关键字。文中将提及以下内容:
- 定义与实例化类;
- 使用extends创建子类;
- 子类中super语句的调用;
- 以及重要的标记方法(symbol method)的例子。
在此过程中,我们将特别注意 class 声明语法从本质上是如何映射到基于原型代码的。
退一步说:Classes不是什么
JavaScript的『类』与Java、Python或者其他你可能用过的面向对象语言中的类不同。其实后者可能称作面向『类』的语言更为准确一些。
在传统的面向类的语言中,我们创建的类是对象的模板。需要一个新对象时,我们实例化这个类,这一步操作告诉语言引擎将这个类的方法和属性复制到一个新实体上,这个实体称作实例。实例是我们自己的对象,且在实例化之后与父类毫无内在联系。
而JavaScript没有这样的复制机制。在JavaScript中『实例化』一个类创建了一个新对象,但这个新对象却不独立于它的父类。
正相反,它创建了一个与原型相连接的对象。即使是在实例化之后,对于原型的修改也会传递到实例化的新对象去。
原型本身就是一个无比强大的设计模式。有许多使用了原型的技术模仿了传统类的机制,class便为这些技术提供了简洁的语法。
总而言之:
- JavaScript不存在Java和其他面向对象语言中的类概念;
- JavaScript 的class很大程度上只是原型继承的语法糖,与传统的类继承有很大的不同。
类基础:声明与表达式
我们使用class 关键字创建类,关键字之后是变量标识符,最后是一个称作类主体的代码块。这种写法称作类的声明。没有使用extends关键字的类声明被称作基类:
"use strict";
// Food 是一个基类
class Food {
constructor (name, protein, carbs, fat) {
this.name = name;
this.protein = protein;
this.carbs = carbs;
this.fat = fat;
}
toString () {
return `${this.name} | ${this.protein}g P :: ${this.carbs}g C :: ${this.fat}g F`
}
print () {
console.log( this.toString() );
}
}
const chicken_breast = new Food('Chicken Breast', 26, 0, 3.5);
chicken_breast.print(); // 'Chicken Breast | 26g P :: 0g C :: 3.5g F'
console.log(chicken_breast.protein); // 26 (LINE A)
需要注意到以下事情:
- 类只能包含方法定义,不能有数据属性;
- 定义方法时,可以使用简写方法定义;
- 与创建对象不同,我们不能在类主体中使用逗号分隔方法定义;
- 我们可以在实例化对象上直接引用类的属性(如 LINE A)。
类有一个独有的特性,就是 contructor 构造方法。在构造方法中我们可以初始化对象的属性。
构造方法的定义并不是必须的。如果不写构造方法,引擎会为我们插入一个空的构造方法
"use strict";
class NoConstructor {
/* JavaScript 会插入这样的代码:
constructor () { }
*/
}
const nemo = new NoConstructor(); // 能工作,但没啥意思
将一个类赋值给一个变量的形式叫类表达式,这种写法可以替代上面的语法形式:
"use strict";
// 这是一个匿名类表达式,在类主体中我们不能通过名称引用它
const Food = class {
// 和上面一样的类定义……
}
// 这是一个命名类表达式,在类主体中我们可以通过名称引用它
const Food = class FoodClass {
// 和上面一样的类定义……
// 添加一个新方法,证明我们可以通过内部名称引用 FoodClass……
printMacronutrients () {
console.log(`${FoodClass.name} | ${FoodClass.protein} g P :: ${FoodClass.carbs} g C :: ${FoodClass.fat} g F`)
}
}
const chicken_breast = new Food('Chicken Breast', 26, 0, 3.5);
chicken_breast.printMacronutrients(); // 'Chicken Breast | 26g P :: 0g C :: 3.5g F'
// 但是不能在外部引用
try {
console.log(FoodClass.protein); // 引用错误
} catch (err) {
// pass
}
这一行为与匿名函数与命名函数表达式很类似。
使用extends创建子类以及使用super调用
使用extends创建的类被称作子类,或派生类。这一用法简单明了,我们直接在上面的例子中构建:
"use strict";
// FatFreeFood 是一个派生类
class FatFreeFood extends Food {
constructor (name, protein, carbs) {
super(name, protein, carbs, 0);
}
print () {
super.print();
console.log(`Would you look at that -- ${this.name} has no fat!`);
}
}
const fat_free_yogurt = new FatFreeFood('Greek Yogurt', 16, 12);
fat_free_yogurt.print(); // 'Greek Yogurt | 26g P :: 16g C :: 0g F / Would you look at that -- Greek Yogurt has no fat!'
派生类拥有我们上文讨论的一切有关基类的特性,另外还有如下几点新特点:
- 子类使用class关键字声明,之后紧跟一个标识符,然后使用extend关键字,最后写一个任意表达式。这个表达式通常来讲就是个标识符,但理论上也可以是函数。
- 如果你的派生类需要引用它的父类,可以使用super关键字。
- 一个派生类不能有一个空的构造函数。即使这个构造函数就是调用了一下super(),你也得把它显式的写出来。但派生类却可以没有构造函数。
- 在派生类的构造函数中,必须先调用super,才能使用this关键字(译者注:仅在构造函数中是这样,在其他方法中可以直接使用this)。
在JavaScript中仅有两个super关键字的使用场景: - 在子类构造函数中调用。如果初始化派生类是需要使用父类的构造函数,我们可以在子类的构造函数中调用super(parentConstructorParams),传递任意需要的参数。
- 引用父类的方法。在常规方法定义中,派生类可以使用点运算符来引用父类的方法:super.methodName。
我们的 FatFreeFood 演示了这两种情况: - 在构造函数中,我们简单的调用了super,并将脂肪的量传入为0。
- 在我们的print方法中,我们先调用了super.print,之后才添加了其他的逻辑。