摘自《深入理解ES6》
尽管一些JS
开发者强烈认为这门语言不需要类,但为处理类而创建的代码库如此之多,导致ES6
最终引入了类。
ES5 中的仿类结构
JS
在ES5
及更早版本中都不存在类。与类最接近的是:创建一个构造器,然后将方法指派到该构造器的原型上。这种方式通常被称为创建一个自定义类型。
function PersonType(name) {
this.name = name;
}
PersonType.prototype.sayName = function () {
console.log(this.name);
};
let person = new PersonType("Nicholas");
person.sayName(); // 输出 "Nicholas"
console.log(person instanceof PersonType); // true
console.log(person instanceof Object); // true
此代码中的PersonType
是一个构造器函数,并创建了单个属性name
。 sayName()
方法被指派到原型上,因此在PersonType
对象的所有实例上都共享了此方法。接下来,使用new
运算符创建了PersonType
的一个新实例 person
,此对象会被认为是一个通过原型继承了PersonType
与 Object
的实例。
类的声明
类在 ES6
中最简单的形式就是类声明,它看起来很像其他语言中的类。
这个PersonClass
类声明的行为非常类似上个例子中的 PersonType
。类声明允许你在其中使用特殊的constructor
方法名称直接定义一个构造器,而不需要先定义一个函数再把它当作构造器使用。
由于类的方法使用了简写语法,于是就不再需要使用 function
关键字。constructor
之外的方法名称则没有特别的含义,因此可以随你高兴自由添加方法。
PersonClass
声明实际上创建了一个拥有constructor
方法及其行为的函数,这也是typeof PersonClass
会得到"function"
结果的原因。
类与自定义类型之间的区别
尽管类与自定义类型之间有相似性,但仍然要记住一些重要的区别:
- 类声明不会被提升,这与函数定义不同。类声明的行为与
let
相似,因此在程序的执行到达声明处之前,类会存在于暂时性死区内。 - 类声明中的所有代码会自动运行在严格模式下,并且也无法退出严格模式。
- 类的所有方法都是不可枚举的,这是对于自定义类型的显著变化,后者必须用
Object.defineProperty()
才能将方法改变为不可枚举。 - 类的所有方法内部都没有
[[Construct]]
,因此使用new
来调用它们会抛出错误。 - 调用类构造器时不使用
new
,会抛出错误。 - 试图在类的方法内部重写类名,会抛出错误。
// 直接等价于 PersonClass
let PersonType2 = (function () {
"use strict";
const PersonType2 = function (name) {
// 确认函数被调用时使用了 new
if (typeof new.target === "undefined") {
throw new Error("Constructor must be called with new.");
}
this.name = name;
}
Object.defineProperty(PersonType2.prototype, "sayName", {
value: function () {
// 确认函数被调用时没有使用 new
if (typeof new.target !== "undefined") {
throw new Error("Method cannot be called with new.");
}
console.log(this.name);
},
enumerable: false,
writable: true,
configurable: true
});
return PersonType2;
}());
首先要注意这里有两个PersonType2
声明:一个在外部作用域的let
声明,一个在IIFE
内部的const
声明。这就是为何类的方法不能对类名进行重写、而类外部的代码则被允许。构造器函数检查了new.target
,以保证被调用时使用了 new
,否则就抛出错误。接下来,sayName()
方法被定义为不可枚举,并且此方法也检查了new.target
,它则要保证在被调用时没有使用 new
。最后一步是将构造器函数返回出去。
不变的类名
只有在类的内部,类名才被视为是使用const
声明的。这意味着你可以在外部重写类名,但不能在类的方法内部这么做。例如:
class Foo {
constructor() {
Foo = "bar"; // 执行时抛出错误
}
}
// 但在类声明之后没问题
Foo = "baz";
在此代码中,类构造器内部的 Foo
与在类外部的 Foo
是不同的绑定。内部的Foo
就像是用 const
定义的,不能被重写,当构造器尝试使用任何值重写Foo
时,都会抛出错误。但由于外部的Foo
就像是用 let
声明的,你可以随时重写类名。
作为一级公民的类
ES6
延续了传统,让类同样成为一级公民。这就使得类可以被多种方式所使用。例如,它能作为参数传入函数:
function createObject(classDef) {
return new classDef();
}
let obj = createObject(class {
sayHi() {
console.log("Hi!");
}
});
obj.sayHi(); // "Hi!"
访问器属性
自有属性需要在类构造器中创建,而类还允许你在原型上定义访问器属性。为了创建一个getter
,要使用 get
关键字,并要与后方标识符之间留出空格;创建setter
用相同方式,只是要换用set
关键字。例如:
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
console.log(descriptor.enumerable); // false
此代码中的CustomHTMLElement
类用于包装一个已存在的 DOM
元素。它的属性html
拥有getter
与 setter
,委托了元素自身的innerHTML
方法。该访问器属性被创建在CustomHTMLElement.prototype
上,并且像其他类属性那样被创建为不可枚举属性。非类的等价表示如下:
// 直接等价于上个范例
let CustomHTMLElement = (function () {
"use strict";
const CustomHTMLElement = function (element) {
// 确认函数被调用时使用了 new
if (typeof new.target === "undefined") {
throw new Error("Constructor must be called with new.");
}
this.element = element;
}
Object.defineProperty(CustomHTMLElement.prototype, "html", {
enumerable: false,
configurable: true,
get: function () {
return this.element.innerHTML;
},
set: function (value) {
this.element.innerHTML = value;
}
});
return CustomHTMLElement;
}());
正如之前的例子,此例说明了使用类语法能够少写大量的代码。仅仅为html
访问器属性定义的代码量,就几乎相当于等价的类声明的全部代码量了。
需计算的成员名
对象字面量与类之间的相似点还不仅前面那些。类方法与类访问器属性也都能使用需计算的名称。语法相同于对象字面量中的需计算名称:无须使用标识符,而是用方括号来包裹一个表达式。例如:
let methodName = "sayName";
class PersonClass {
constructor(name) {
this.name = name;
}
[methodName]() {
console.log(this.name);
}
}
let me = new PersonClass("Nicholas");
me.sayName(); // "Nicholas"
此版本的PersonClass
使用了一个变量来命名类定义内的方法。字符串"sayName"
被赋值给了 methodName
变量,而 methodName
变量则被用于声明方法。 sayName()
方法在此后能被直接访问。
生成器方法
class MyClass {
* createIterator() {
yield 1;
yield 2;
yield 3;
}
}
let instance = new MyClass();
let iterator = instance.createIterator();
此代码创建了一个拥有 createIterator()
生成器的MyClass
类。该方法返回了一个迭代器,它的值在生成器内部用硬编码提供。当你使用一个对象来表示值的集合、并要求能简单迭代这些值,那么生成器方法就非常有用。数组、 Set
与 Map
都拥有多个生成器方法,负责让开发者用多种方式来操作它们的项。
静态成员
直接在构造器上添加额外方法来模拟静态成员,这在ES5
及更早版本中是另一个通用的模式。例如:
function PersonType(name) {
this.name = name;
}
// 静态方法
PersonType.create = function (name) {
return new PersonType(name);
};
// 实例方法
PersonType.prototype.sayName = function () {
console.log(this.name);
};
var person = PersonType.create("Nicholas");
在其他编程语言中,工厂方法 PersonType.create()
会被认定为一个静态方法,它的数据不依赖 PersonType
的任何实例。
ES6
的类简化了静态成员的创建,只要在方法与访问器属性的名称前添加正式的static
标注。作为一个例子,此处有个与上例等价的类:
class PersonClass {
// 等价于 PersonType 构造器
constructor(name) {
this.name = name;
}
// 等价于 PersonType.prototype.sayName
sayName() {
console.log(this.name);
}
// 等价于 PersonType.create
static create(name) {
return new PersonClass(name);
}
}
let person = PersonClass.create("Nicholas");
PersonClass
的定义拥有名为create()
的单个静态方法,此语法与sayName()
基本相同,只多了一个 static
关键字。你能在类中的任何方法与访问器属性上使用 static
关键字,唯一限制是不能将它用于 constructor
方法的定义。
使用派生类进行继承
ES6
之前,实现自定义类型的继承是个繁琐的过程。严格的继承要求有多个步骤。例如,研究以下范例:
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()
方法来访问基类的构造器。此处是与上个例子等价的 ES6
代码:
class Rectangle {
constructor(length, width) {
this.length = length;
this.width = width;
}
getArea() {
return this.length * this.width;
}
}
class Square extends Rectangle {
constructor(length) {
// 与 Rectangle.call(this, length, length) 相同
super(length, length);
}
}
var square = new Square(3);
console.log(square.getArea()); // 9
console.log(square instanceof Square); // true
console.log(square instanceof Rectangle); // true
此次 Square
类使用了extends
关键字继承了 Rectangle
。Square
构造器使用了super()
配合指定参数调用了Rectangle
的构造器。注意与 ES5
版本的代码不同,Rectangle
标识符仅在类定义时被使用了(在 extends
之后)。
屏蔽类方法
派生类中的方法总是会屏蔽基类的同名方法。例如,你可以将 getArea()
方法添加到Square
类,以便重定义它的功能:
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
// 重写并屏蔽 Rectangle.prototype.getArea()
getArea() {
return this.length * this.length;
}
}
由于getArea()
已经被定义为Square
的一部分,Rectangle.prototype.getArea()
方法就不能在Square
的任何实例上被调用。当然,你总是可以使用 super.getArea()
方法来调用基类中的同名方法,就像这样:
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
// 重写、屏蔽并调用了 Rectangle.prototype.getArea()
getArea() {
return super.getArea();
}
}
用这种方式使用super
,this
值会被自动设置为正确的值,因此你就能进行简单的调用。
继承静态成员
如果基类包含静态成员,那么这些静态成员在派生类中也是可用的。继承的工作方式类似于其他语言,但对于JS
而言则是新概念。此处有个范例:
class Rectangle {
constructor(length, width) {
this.length = length;
this.width = width;
}
getArea() {
return this.length * this.width;
}
static create(length, width) {
return new Rectangle(length, width);
}
}
class Square extends Rectangle {
constructor(length) {
// 与 Rectangle.call(this, length, length) 相同
super(length, length);
}
}
var rect = Square.create(3, 4);
console.log(rect instanceof Rectangle); // true
console.log(rect.getArea()); // 12
console.log(rect instanceof Square); // false
在此代码中,一个新的静态方法 create()
被添加到Rectangle
类中。通过继承,该方法会以 Square.create()
的形式存在,并且其行为方式与 Rectangle.create()
一样。
从表达式中派生类
在ES6
中派生类的最强大能力,或许就是能够从表达式中派生类。只要一个表达式能够返回一个具有 [[Construct]]
属性以及原型的函数,你就可以对其使用 extends
。例如:
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);
}
}
var x = new Square(3);
console.log(x.getArea()); // 9
console.log(x instanceof Rectangle); // true
Rectangle
被定义为ES5
风格的构造器,而 Square
则是一个类。由于 Rectangle
具有[[Construct]]
以及原型, Square
类就能直接继承它。
继承内置对象
几乎从JS
数组出现那天开始,开发者就想通过继承机制来创建他们自己的特殊数组类型。在ES5
及早期版本中,这是不可能做到的。试图使用传统继承并不能产生功能正确的代码,例如:
console.log()
在此代码尾部的输出说明了:对数组使用传统形式的 JS
继承,产生了预期外的行为。MyArray
实例上的 length
属性以及数值属性,其行为与内置数组并不一致,因为这些功能并未被涵盖在 Array.apply()
或数组原型中。
// 内置数组的行为
var colors = [];
colors[0] = "red";
console.log(colors.length); // 1
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,
configurable: true,
enumerable: true
}
});
var colors = new MyArray();
colors[0] = "red";
console.log(colors.length); // 0
colors.length = 0;
console.log(colors[0]); // "red"
在ES6
中的类,其设计目的之一就是允许从内置对象上进行继承。为了达成这个目的,类的继承模型与 ES5
或更早版本的传统继承模型有轻微差异:
在
ES5
的传统继承中,this
的值会先被派生类(例如MyArray
)创建,随后基类构造器(例如Array.apply()
方法)才被调用。这意味着this
一开始就是MyArray
的实例,之后才使用了Array
的附加属性对其进行了装饰。在
ES6
基于类的继承中,this
的值会先被基类(Array
)创建,随后才被派生类的构造器(MyArray
)所修改。结果是this
初始就拥有作为基类的内置对象的所有功能,并能正确接收与之关联的所有功能。以下范例实际展示了基于类的特殊数组:
class MyArray extends Array {
// 空代码块
}
var colors = new MyArray();
colors[0] = "red";
console.log(colors.length); // 1
colors.length = 0;
console.log(colors[0]); // undefined
MyArray
直接继承了Array
,因此工作方式与正规数组一致。与数值索引属性的互动更新了length
属性,而操纵 length
属性也能更新索引属性。这意味着你既能适当地继承Array
来创建你自己的派生数组类,也同样能继承其他的内置对象。伴随着这些附加功能,ES6
与派生类型有效解决了从内置类型进行派生这最后的特殊情况,不过这种情况仍然值得继续探索。