深入理解 ES6 #9-JavaScript 中的类
🏷️ 《深入理解 ES6》
大多数面向对象的编程语言都支持类和类继承的特性,而 JavaScript 却不支持这些特性,只能通过其他方法定义并关联多个相似的对象。这个状态一直从 ECMAScript 1 持续到 ECMAScript 5。
尽管一部分 JavaScript 开发强烈坚持 JavaScript 中不需要类,但由于类似的库层出不穷,最终还是在 ECMAScript 6 中引入了类的特性。
ECMAScript 6 中的类与其他语言中的还是不太一样,其语法的设计实际上借鉴了 JavaScript 的动态性
ECMAScript 5 中的近类结构
首先创建一个构造函数,然后定义另一个方法并赋值给构造函数的原型。
function PersonType() {
this.name = name;
}
PersonType.prototype.sayName = function() {
console.log(this.name);
};
var person = new PersonType("JiaJia");
person.sayName(); // "JiaJia"
console.log(person instanceof PersonType); // true
console.log(person instanceof Object); // true
类的声明
基本的类声明语法
通过 class
关键字声明类
class PersonClass {
// 等价于 PersonType 构造函数
constructor(name) {
this.name = name;
}
// 等价于 PersonType.prototype.sayName
sayName() {
console.log(this.name);
}
}
let person = new PersonClass("JiaJia");
person.sayName(); // "JiaJia"
console.log(person instanceof PersonClass); // true
console.log(person instanceof Object); // true
console.log(typeof PersonClass); // "function"
console.log(typeof PersonClass.prototype.sayName); // "function"
创建方法同之前创建 PersonType
构造函数类似,在这里直接在类中通过 constructor
方法名来定义构造函数。除 constructor
外没有其它的保留方法名。
私有属性是实例属性,不会出现在原型中。建议在构造函数中定义所有的私有属性,从而只通过一处就可以控制类中的所有私有属性。
通过上面例子最后两行的输出可以发现,其实 class
关键字只是个语法糖,最终生成的类 PersonClass
仍然是一个函数,而方法也是定义在该函数的原型上的。
NOTE
与函数不同的是,类属性不可被赋予新值,在之前的示例中, PersonClass.prototype
就是这样一个只可读的类属性。
为何使用类语法
首先看一下类声明与函数声明的差异:
函数声明可以被提升,而类声明与
let
类似,不能被提升;真正执行声明语句之前,它们会一直存在于临时死区中。类声明中的所有代码将自动运行与严格模式下,而且无法强行让代码脱离严格模式执行。
在自定义类型中,需要通过
Object.defineProperty()
方法手工指定某个方法为不可枚举;而在类中,所有方法都是不可枚举的。每个类都有一个名为
[[Construct]]
的内部方法,通过关键字new
调用那些不含[[Construct]]
的方法会导致程序抛出错误。使用除
new
关键字外的方式调用类的构造函数会导致程序抛出错误。在类中修改类名会导致程序报错。
使用出了类之外的语法为之前示例中的 PersonClass
声明编写等价代码。
// 等价于 PersonClass
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;
}());
注意,这段代码中有两处 PersonType2
声明:
- 外部作用域中的
let
声明。 - 立即执行函数表达式(IIFE)中的
const
声明。
这也从侧面说明了为什么可以在外部修改类名而内部却不可修改。
从这个示例可以看出,尽管在不使用 class
关键字的前提下实现类的所有功能,但代码变的极为复杂。
类表达式
类和函数都是两种存在形式:声明形式和表达式形式。
基本的类表达式语法
let PersonClass = class {
// 等价于 PersonType 构造函数
constructor(name) {
this.name = name;
}
// 等价于 PersonType.prototype.sayName
sayName() {
console.log(this.name);
}
}
let person = new PersonClass("JiaJia");
person.sayName(); // "JiaJia"
console.log(person instanceof PersonClass); // true
console.log(person instanceof Object); // true
console.log(typeof PersonClass); // "function"
console.log(typeof PersonClass.prototype.sayName); // "function"
console.log(PersonClass.name); // "PersonClass"
命名类表达式
let PersonClass = class PersonClass2 {
// 等价于 PersonType 构造函数
constructor(name) {
this.name = name;
}
// 等价于 PersonType.prototype.sayName
sayName() {
console.log(this.name);
}
}
console.log(PersonClass.name); // "PersonClass2"
console.log(typeof PersonClass); // "function"
console.log(typeof PersonClass2); // "undefined"
类的名称为 PersonClass2
,但是在声明外部并不存在一个名为 PersonClass2
的绑定,标识符 PersonClass2
只存在与类定义中。
将上述示例改成不使用 class
关键字的等价声明:
// 等价于命名类表达式 PersonClass
let PersonClass = (function () {
"use strict";
const PersonClass2 = function (name) {
// 确保通过关键字 new 调用该函数
if (typeof new.target === "undefined") {
throw new Error("必须通过 new 关键字调用构造函数");
}
this.name = name;
}
Object.defineProperty(PersonClass2.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 PersonClass2;
}());
在 JS 引擎中,类表达式的实现与类声明稍有不同。
类声明
通过let
定义的外部绑定与通过const
定义的内部绑定具有相同名称命名类表达式
通过const
定义名称
作为一等公民的类
在程序中,一等公民是指一个可以传入函数,可以从函数返回,并且可以赋值给变量的值。
JS 中函数是一等公民,ES6 中也将类设计为一等公民,允许通过多种方式使用类的特性。
function createOjbect(classDef) {
return new classDef();
}
let obj = createObject(class {
sayHi() {
console.log("Hi!");
}
});
obj.sayHi();
类表达式还有另外一种使用方式,通过立即调用类构造函数可以创建单例。
let person = new class {
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name);
}
}("JiaJia");
person.sayName(); // "JiaJia"
访问器属性
类支持在原型上定义访问器属性。
class CustomHTMLElement {
constructor(element) {
this.element = element;
}
// getter
get html() {
return this.element.innerHTML;
}
// setter
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
下面是上面示例非类形式的等价代码。
let CustomHTMLElement = (function() {
"use strict";
const CustomHTMLElement = function(element) {
// 确保通过关键字 new 调用该函数
if (typeof new.target === "undefined") {
throw new Error("必须通过关键字 new 调用构造函数");
}
this.element = element;
}
Object.defineProperty(CustomHTMLElement.prototype, "html", {
enumerable: false,
configurable: false,
get: function() {
return this.element.innerHTML;
},
set: function(value) {
this.element.innerHTML = value;
}
});
return CustomHTMLElement;
}());
比起非类等效实现,类语法可以节省很多代码。
可计算成员名称
用方括号包裹一个表达式即可使用可计算名称。
let methodName = "sayName";
class PersonClass {
constructor(name) {
this.name = name;
}
[methodName]() {
console.log(this.name);
}
}
let me = new PersonClass("JiaJia");
me.sayName(); // "JiaJia"
通过相同的方式可以在访问器属性中应用可计算名称:
let propertyName = "html";
class CustomHTMLElement {
constructor(element) {
this.element = element;
}
get [propertyName]() {
return this.element.innerHTML;
}
set [propertyName](value) {
this.element.innerHTML = value;
}
}
生成器方法
可以在方法前附加一个星号(*
)来定义生成器。
class MyClass {
*createIterator() {
yield 1;
yield 2;
yield 3;
}
}
let instance = new MyClass();
let iterator = instance.createIterator();
可以通过 Symbol.iterator
定义类的默认迭代器。
class Collection {
constructor() {
this.items = [];
}
*[Symbol.iterator]() {
yield *this.items;
}
}
var collection = new Collection();
collection.items.push(1);
collection.items.push(2);
collection.items.push(3);
for (let x of collection) {
console.log(x);
}
// 输出:
// 1
// 2
// 3
静态成员
在 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("JiaJia");
ES6 中简化了创建静态成员的过程,在方法或访问器属性前使用正式的静态注释即可。
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("JiaJia");
类中的所有方法和访问器属性都可以用 static 关键字来定义,唯一的限制是不能将 static
用于定义构造函数方法。
NOTE
不可在实例中访问静态成员,必须要直接在类中访问静态成员。
继承与派生类
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,
configruable: 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()
方法。
类的出现可以让我们更轻松的实现继承功能。
class Rectangle {
constructor(length, width) {
this.length = length;
this.width = width;
}
getArea() {
return this.length * this.width;
}
}
class Square extends Rectangle {
// 等价于 Rectangle.call(this, length, length)
constructor(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
构造函数并传入参数。
继承自其它类的类被称作派生类,如果在派生类中指定了构造函数则必须要调用 super()
,如果不这样做程序就会报错。
如果选择不使用构造函数,则当创建新的实例时会自动调用 super()
并传入所有参数。
关于 super() 的小贴士
- 只可在派生类的构造函数中使用
super()
,如果尝试在非派生类(不是用extends
声明的类)或函数中使用则会导致程序抛出错误。 - 在构造函数中访问 this 之前一定要调用
super()
,它负责初始化 this,如果在调用super()
之前尝试访问 this 会导致程序出错。 - 如果不想调用
super()
,则唯一的方法是让类的构造函数返回一个对象。
类方法遮蔽
派生类中的方法总会覆盖基类中的同名方法。
如果想调用基类中的同名方法,需使用 super.method()
的方式调用。
静态成员继承
如果基类有静态成员,那么这些静态成员在派生类中也可用。
派生自表达式的类
ECMAScript 6 最强大的一面或许是从表达式导出类的功能了。只要表达式可以被解析为一个函数并且具有 [[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
extends
强大的功能使得类可以继承自任意类型的表达式。
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);
}
}
var x = new Square(3);
console.log(x.getArea()); // 9
console.log(x instanceof Rectangle); // true
此示例实现的功能同之前的示例等价。
extends
后面跟的是方法调用 getBase()
, Square
类继承自该方法的返回值。
通过这种方式,可以动态的决定类的继承。
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;
}
}
var x = new Square(3);
console.log(x.getArea()); // 9
console.log(x.serialize()); // {"length":3,"width":3}
内建对象的继承
通过继承的方式创建属于自己的特殊数组。在 ES5 及早期版本中,这几乎是不可能的。
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.assign(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 类语法的一个目标就是支持内建对象继承,因而 ES6 中的类继承模型与 ES5 及早期版本中的稍有不同。
在 ES5 的传统继承方式中,先由派生类型创建 this 的值,然后调用基类型的构造函数。 这也意味着, this 的值开始指向的是 MyArray 的实例,但是随后会被来自 Array 的其它属性所修饰。
ES6 中的类继承则与之相反,先由基类创建 this 的值,然后派生类的构造函数再修改这个值。所以一开始可以通过 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
Symbol.species 属性
内建对象的一个实用之处是,原本在内建对象中返回实例自身的方法将自动返回派生类的实例。
class MyArray extends Array {
}
let items = new MyArray(1, 2, 3, 4),
subitems = items.slice(1, 3);
console.log(items instanceof MyArray); // true
console.log(subitems instanceof MyArray); // true
在 JS 引擎背后是通过 Symbol.species
属性实现该功能的。Symbol.species
被用于定义返回函数的静态访问器属性。被返回的函数是一个构造函数,每当要在实例的方法中(不是在构造函数中)创建类的实例时必须使用这个构造函数。
以下这些内建类型均已定义 Symbol.species
属性(该属性返回值为 this ,这也意味着该属性总会返回构造函数):
- Array
- ArrayBuffer
- Map
- Promise
- RegExp
- Set
- Typed arrays
几个内建类型像这样使用 Symbol.species
:
class MyClass {
static get [Symbol.species]() {
return this;
}
constructor(value) {
this.value = value;
}
clone() {
return new this.constructor[Symbol.species](this.value);
}
}
class MyDerivedClass1 extends MyClass {
}
class MyDerivedClass2 extends MyClass {
static get [Symbol.species]() {
return MyClass;
}
}
let instance1 = new MyDerivedClass1("foo"),
clone1 = instance1.clone(),
instance2 = new MyDerivedClass2("foo"),
clone2 = instance2.clone();
console.log(clone1 instanceof MyClass); // true
console.log(clone1 instanceof MyDerivedClass1); // true
console.log(clone2 instanceof MyClass); // true
console.log(clone2 instanceof MyDerivedClass2); // false
上例中 MyDerivedClass2 重写了 Symbol.species
属性,使其返回的不再是派生类的构造函数,而是基类的构造函数。所以最终 clone()
的结果不再是派生类型。
在类的构造函数中使用 new.target
在类的构造函数中,也可以通过 new.target
来确定类是如何被调用的。
在简单情况下, new.target
等于类的构造函数。
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle);
this.length = length;
this.width = width;
}
}
// new.tareget 的值是 Rectangle
var obj = new Rectangle(3, 4); // true
再看看另外一种情况:
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.tareget 的值是 Square
var obj = new Square(3); // false
上例中,通过派生类调用时, new.target
指向的是派生类的构造函数。
利用这个特性,可以创建一个抽象基类(不能被直接实例化的类)
// 抽象基类
class Shape {
constructor() {
if (new.target === Shape) {
throw new Error("这个类不能被直接实例化。");
}
}
}
class Rectangle extends Shape {
constructor(length, width) {
super();
this.length = length;
this.width = width;
}
}
var x = new Shape(); // 抛出错误
var y = new Rectangle(3, 4); // 没有错误
console.log(y instanceof Shape); // true